On-demand Scout service (replaces the nightly aggregator): - FastAPI agent-mesh service; card name "matchmaking-service" (HTTP /a2a/tasks + Redis-stream worker matching the sibling-service pattern) - run_search: parallel multi-board sweep (Naukri/blackfalcondata + Foundit + LinkedIn), city-filtered at the board, cheapest-per-result first - per-board adapters + normalizers -> ScoutJob with rich read-only details and offsite apply links; recall mode + MVQ guard - result cache for dev replay ($0); per-board cost knobs; retry-on-5xx - research/: engine design, board cost economics, India-actor shortlist, POC
205 lines
12 KiB
Python
205 lines
12 KiB
Python
"""
|
|
apply_ashby.py — master the Ashby application in ISOLATION (no LinkedIn).
|
|
|
|
Goes DIRECTLY to the OpenAI / AshbyHQ application URL. That page is public — no login, so no
|
|
LinkedIn new-device alerts and no ban vector. "5 applications ≠ 5 logins": here it's 0 logins.
|
|
|
|
This is the focused "master Ashby" script. The LinkedIn discovery+login flow is preserved as a
|
|
COMMENTED block below (we'll re-enable it and merge the two into one unit once Ashby is solid).
|
|
|
|
ACCURACY IS GO/NO-GO: the agent must satisfy a hard checklist — NO required field left empty —
|
|
before it will submit or hand off. It reports every required field and whether all were filled.
|
|
|
|
Run:
|
|
ASHBY_URL="https://jobs.ashbyhq.com/openai/<id>/application" python apply_ashby.py
|
|
AUTO_SUBMIT=0 ASHBY_URL="..." python apply_ashby.py # fill + gate, STOP before submit (you submit)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from datetime import date, timedelta
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
from browser_use import Agent, BrowserSession, ChatOpenAI
|
|
from browser_config import make_profile, RESUME_PATH
|
|
from parse_resume import parse_resume_to_profile
|
|
|
|
load_dotenv()
|
|
|
|
ASHBY_URL = os.environ.get("ASHBY_URL", "") # REQUIRED: the exact application page URL
|
|
APPLY_MODEL = os.environ.get("APPLY_MODEL", "gpt-4.1")
|
|
AUTO_SUBMIT = os.environ.get("AUTO_SUBMIT", "1") != "0"
|
|
MAX_STEPS = int(os.environ.get("MAX_STEPS", "55"))
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# FUTURE — LinkedIn discovery + login (kept for when we merge into one unit; intentionally OFF).
|
|
# Do NOT delete. When combining: prepend a login step using these, then navigate from the
|
|
# LinkedIn job to Apply, then fall through to the Ashby steps below.
|
|
#
|
|
# LINKEDIN_USER = os.environ.get("LINKEDIN_USER", "")
|
|
# LINKEDIN_PASS = os.environ.get("LINKEDIN_PASS", "")
|
|
# SENSITIVE_DATA = {
|
|
# "https://*.linkedin.com": {"linkedin_user": LINKEDIN_USER, "linkedin_pass": LINKEDIN_PASS}
|
|
# } if (LINKEDIN_USER and LINKEDIN_PASS) else None
|
|
# LOGIN_STEP = (
|
|
# "0. LOGIN: Go to https://www.linkedin.com/feed/. If a login form appears, log in with the "
|
|
# "placeholders linkedin_user / linkedin_pass. If a security checkpoint/CAPTCHA appears, STOP "
|
|
# "and report status='blocked' (a human must clear it). NOTE: prefer a pre-logged-in session "
|
|
# "to avoid repeated automated logins (each triggers a LinkedIn new-device alert)."
|
|
# )
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def build_task(profile: dict) -> str:
|
|
# Compute dates at runtime so this never goes stale. Suggest a realistic ~2-week-out start.
|
|
today = date.today()
|
|
start_target = today + timedelta(days=14)
|
|
today_str = today.strftime("%Y-%m-%d")
|
|
start_str = start_target.strftime("%Y-%m-%d (%B %-d, %Y)")
|
|
submit_clause = (
|
|
"Submit the application (click the final Submit / Apply button) — but only AFTER the "
|
|
"accuracy gate in step 6 passes."
|
|
if AUTO_SUBMIT else
|
|
"DO NOT click the final submit button. Stop at the review step and report it is ready for "
|
|
"a human to submit — but only AFTER the accuracy gate in step 6 passes."
|
|
)
|
|
return f"""You are completing ONE job application on a public OpenAI / AshbyHQ careers page on
|
|
behalf of the candidate below. Use ONLY the candidate's real data — never invent qualifications,
|
|
employers, dates, or work authorization.
|
|
|
|
CANDIDATE PROFILE (source of truth, parsed from their résumé):
|
|
{json.dumps(profile, indent=2, ensure_ascii=False)}
|
|
|
|
Résumé file to upload: {RESUME_PATH}
|
|
|
|
CONFIRMED FACTS (use these EXACT answers — they OVERRIDE any résumé guess):
|
|
- Preferred contact email: rahul.gupta2608@yahoo.com (the résumé email).
|
|
- The candidate lives in India, is an Indian citizen, and is authorized to work in India.
|
|
- This role is India-based, so: "authorized to work in the country where the job is located" = YES;
|
|
"require visa sponsorship" = NO. (If the page clearly shows a non-India location, answer
|
|
truthfully instead: not authorized / needs sponsorship for a US role. Honesty over optimism.)
|
|
- Availability: available to start soon. Today's date is {today_str}. For a "when can you start"
|
|
date field, pick a date ON OR AFTER today — NEVER a past date. A good choice is about two weeks
|
|
out: {start_str}. Use that unless the picker only allows a different valid future date.
|
|
- MOTIVATION (use this VERBATIM for any "what interests you about this job" / "why do you want to
|
|
work here" / "Additional Information" free-text field — a strong application never leaves this
|
|
blank):
|
|
"I want to help organizations actually put frontier AI to work, and the AI Deployment Engineer,
|
|
Codex role is the most direct place I've seen to do that. My background is hands-on ML and data
|
|
engineering — I shipped machine-learning systems at WalmartLabs and have built with LLM/GenAI
|
|
APIs, vector retrieval, and cloud infrastructure across AWS, Azure, and GCP. Codex is changing
|
|
how software gets written, and I'm excited to work directly with teams to turn that capability
|
|
into real, reliable deployments, then carry what I learn on the ground back into the product.
|
|
Being based in India, I'm especially motivated to help bring this technology to engineering
|
|
teams here. Deploying AI that people can trust and genuinely depend on is the work I most want
|
|
to be doing."
|
|
|
|
STEPS:
|
|
1. Go DIRECTLY to the application page: {ASHBY_URL}. No login is needed. The Ashby page is a
|
|
JavaScript app and may look BLANK for a few seconds — `wait` ~6s and re-read; refresh ONCE if
|
|
still blank. Only report a load failure after two waits AND a refresh.
|
|
2. LIVENESS CHECK: if the page shows "no longer accepting applications", a 404, or the role is
|
|
closed, STOP and report status="closed" with what you saw. Never fake a submit on a dead job.
|
|
3. AUTOFILL FIRST (most reliable way to fill fields): find the "Autofill from resume" control near
|
|
the top of the form and upload the résumé file THERE. `wait` ~8s for Ashby to parse it and
|
|
pre-fill fields. This populates name/email/experience automatically.
|
|
4. RÉSUMÉ ATTACHMENT (make-or-break — a half-finished upload got us flagged before):
|
|
- Ensure the required "Resume" field shows the file ATTACHED: the filename is visible and NO
|
|
spinner / "uploading" / "we're updating your application" text remains.
|
|
- If autofill did not attach it to the Resume field, upload the file there, `wait` ~8s, verify.
|
|
Retry up to 2 times. Do not continue until the attachment is CONFIRMED.
|
|
5. FILL EVERY FIELD THAT STRENGTHENS THE APPLICATION — not only the "*" required ones. "Required"
|
|
and "needed for a good application" are different: a blank motivation box is technically allowed
|
|
but makes a WEAK application. So fill every field you reasonably can from the profile + CONFIRMED
|
|
FACTS, and leave a field blank ONLY if it is truly irrelevant or asks for something the candidate
|
|
genuinely does not have. Specifics:
|
|
- "Where are you currently located?" = TYPEAHEAD: type "New Delhi", WAIT for the dropdown, and
|
|
CLICK the matching suggestion. Verify it no longer shows "Start typing…".
|
|
- "When can you start a new role?" = DATE PICKER: open it, CLICK an actual date, verify it no
|
|
longer shows "Pick date…".
|
|
- Work-authorization = YES, sponsorship = NO (India).
|
|
- "What interests you about this job" / "Additional Information" free-text box: fill it with the
|
|
MOTIVATION text from CONFIRMED FACTS verbatim. NEVER leave this blank — it is what makes or
|
|
breaks the application even though it has no "*".
|
|
- REQUIRED CONSENT CHECKBOX: tick "I confirm I have read the above." (the certification near the
|
|
bottom). This is REQUIRED even though it has no "*". After clicking it, VERIFY the box is
|
|
actually checked (a check mark / filled box) — re-click if it did not register.
|
|
6. ACCURACY GATE — GO/NO-GO (the whole point). Before doing step 7, go through the form field by
|
|
field and build a checklist of every field that must be filled. This includes: every "*" field;
|
|
every required consent CHECKBOX (e.g. "I confirm I have read the above") even with no "*"; AND
|
|
the motivation / "what interests you" / "Additional Information" free-text box. For each, record
|
|
its current value/state. A field counts as FILLED only if:
|
|
- text/select fields: a real value, NOT a placeholder ("Start typing…", "Pick date…",
|
|
"Type here…", "hello@example.com…");
|
|
- checkboxes/toggles: actually CHECKED/selected;
|
|
- the motivation box: contains the MOTIVATION text (not empty).
|
|
If ANY of these is empty, a placeholder, or an unchecked required box, it is NOT done: fill/tick
|
|
it now and re-check. You may NOT proceed to step 7 until EVERY one passes. Then CLOSE any open
|
|
date-picker/dropdown so the form is settled. Set all_required_filled=true only if every item
|
|
(including the consent checkbox and the motivation box) passes.
|
|
7. {submit_clause}
|
|
8. AFTER submit (if you submitted), VERIFY — never assume. Click Submit only ONCE, `wait` ~5s, read
|
|
the page. Do NOT rapid-click Submit (looks like spam).
|
|
- "still uploading / try again": upload wasn't done — re-verify step 4, then Submit once more.
|
|
- validation errors: fix the flagged fields, Submit once more.
|
|
- "flagged as possible spam": STOP, do NOT resubmit, report status="failed" with the exact text.
|
|
- Only report status="submitted" if you SEE an explicit confirmation ("Application submitted",
|
|
"Thank you for applying", or a reference id) — quote it verbatim. No proof => status="failed".
|
|
9. Final answer = JSON: {{"status": "submitted"|"ready_for_review"|"closed"|"failed"|"blocked",
|
|
"all_required_filled": true|false, "resume_upload_confirmed": true|false,
|
|
"required_fields": {{<field label>: <value you entered>, ...}},
|
|
"confirmation": <exact text/id or null>,
|
|
"uncertain": [<anything you guessed or skipped>], "notes": <short explanation>}}.
|
|
"""
|
|
|
|
|
|
async def main():
|
|
if not ASHBY_URL or "ashby" not in ASHBY_URL.lower():
|
|
print("✋ ASHBY_URL is not set (or doesn't look like an Ashby URL).")
|
|
print(" Open the OpenAI application tab and copy its address bar URL, then run:")
|
|
print(' ASHBY_URL="https://jobs.ashbyhq.com/openai/<id>/application" python apply_ashby.py')
|
|
return
|
|
|
|
print(f"[1/3] Parsing résumé via vision model ({RESUME_PATH})...")
|
|
profile = await parse_resume_to_profile(RESUME_PATH)
|
|
print(f" -> {profile.get('full_name')} · {profile.get('years_experience')} yrs · "
|
|
f"{len(profile.get('skills', []))} skills")
|
|
|
|
print(f"[2/3] Direct to Ashby (NO LinkedIn). model={APPLY_MODEL}, auto_submit={AUTO_SUBMIT}")
|
|
print(f" url: {ASHBY_URL}")
|
|
session = BrowserSession(browser_profile=make_profile())
|
|
agent = Agent(
|
|
task=build_task(profile),
|
|
llm=ChatOpenAI(model=APPLY_MODEL),
|
|
browser_session=session,
|
|
available_file_paths=[RESUME_PATH],
|
|
# sensitive_data not needed — no login on the Ashby page.
|
|
)
|
|
|
|
try:
|
|
history = await agent.run(max_steps=MAX_STEPS)
|
|
print("\n[3/3] ========== AGENT RESULT ==========")
|
|
print(history.final_result())
|
|
print("=====================================")
|
|
try:
|
|
shot = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ashby_result.png")
|
|
await session.take_screenshot(path=shot, full_page=True)
|
|
print(f"📸 Saved final-page screenshot -> {shot}")
|
|
except Exception as e:
|
|
print(f"(could not save screenshot: {e})")
|
|
finally:
|
|
try:
|
|
await asyncio.get_event_loop().run_in_executor(
|
|
None, input, "\nPress Enter to close the browser... "
|
|
)
|
|
except Exception:
|
|
pass
|
|
await session.kill()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|