Files
raulgupta 89ca9ad647 Initial commit: matchmaking-v2 on-demand multi-board job-search agent
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
2026-06-18 19:22:03 +05:30

193 lines
11 KiB
Python

"""
apply.py — apply to one real job with a browser-use agent.
Flow:
1. Parse Resume.pdf with a vision model -> structured `profile` (source of truth).
2. Open the LinkedIn job in the dedicated (logged-in) Chrome profile.
3. Liveness gate: if the posting is closed/expired, STOP and report — never fake a submit.
4. Click Apply -> follow the hand-off to OpenAI's official job board (new tab).
5. Upload Resume.pdf, fill every field from `profile`, answer screening questions truthfully.
6. Submit (auto, per Rahul's choice) and report a structured result.
Run (after `python login_once.py`):
python apply.py
Toggle AUTO_SUBMIT=0 to make the agent fill everything but STOP before the final submit.
"""
from __future__ import annotations
import asyncio
import json
import os
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()
JOB_URL = "https://www.linkedin.com/jobs/view/4418852767/"
APPLY_MODEL = os.environ.get("APPLY_MODEL", "gpt-4.1") # strong model; a real form needs reasoning
AUTO_SUBMIT = os.environ.get("AUTO_SUBMIT", "1") != "0"
MAX_STEPS = int(os.environ.get("MAX_STEPS", "55"))
# Credentials are referenced by PLACEHOLDER NAMES only. browser-use injects the real values
# from .env at the keystroke, masks them in logs/screenshots, and scopes them to linkedin.com —
# the LLM never sees the real password.
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
def build_task(profile: dict) -> str:
submit_clause = (
"Submit the application (click the final Submit / Apply button)."
if AUTO_SUBMIT else
"Fill everything but DO NOT click the final submit button — stop at the review step "
"and report that it is ready for human review."
)
login_clause = (
"0. LOGIN: Go to https://www.linkedin.com/feed/. If you are already logged in, continue. "
"If you see a login form, log in using the placeholders linkedin_user and linkedin_pass "
"(type them into the email and password fields and submit). If LinkedIn shows a security "
"checkpoint, CAPTCHA, or 'is this you' verification code, STOP and report status=\"blocked\" "
"with notes saying a human needs to clear the checkpoint — do NOT attempt to bypass it.\n"
if SENSITIVE_DATA else
"0. LOGIN: Assume you are already logged into LinkedIn (a session was set up beforehand).\n"
)
return f"""You are applying to ONE job on behalf of the candidate below. Use ONLY the
candidate's real data — never invent qualifications, employers, dates, or work authorization.
CANDIDATE PROFILE (the source of truth, parsed from their résumé):
{json.dumps(profile, indent=2, ensure_ascii=False)}
Résumé file to upload when asked: {RESUME_PATH}
CONFIRMED FACTS (use these EXACT answers — they OVERRIDE any guess from the résumé):
- Preferred contact email: rahul.gupta2608@yahoo.com (the résumé email).
- The candidate lives in India and is an Indian citizen — authorized to work in India.
- The candidate is NOT authorized to work in the United States, and WOULD require visa
sponsorship for any US-based role. Do not overstate authorization — honesty over optimism.
- So answer work-authorization / sponsorship questions truthfully based on the job's actual
location as shown on the form:
* "Authorized to work in the country where the job is located?" -> US role: NO. India role: YES.
* "Will you require visa sponsorship?" -> US role: YES. India role: NO.
- Availability / start date: can start as soon as possible. If a date is required, pick the
earliest selectable date; if it is a text field, enter "Immediately available". Never leave a
required start-date field blank.
STEPS:
{login_clause}1. Go to {JOB_URL}.
2. LIVENESS CHECK FIRST. If the page shows "No longer accepting applications", is a 404, or
redirects to a generic job search, then the job is DEAD: STOP immediately, do not apply, and
in your final answer report status="closed" with what you saw. A real apply to a dead job is
worse than no apply.
3. If the job is open, click the Apply button. This job hands off to OPENAI'S OFFICIAL JOB BOARD
(an external AshbyHQ careers site), usually opening a NEW TAB. Switch to that new tab.
IMPORTANT: the Ashby page is a JavaScript app that takes several seconds to render — it may
look BLANK at first. Do NOT conclude it failed on the first look: `wait` ~6 seconds and re-read
the page; if still blank, `wait` again and refresh the tab ONCE. Only report a load failure if
it is still blank after two waits AND a refresh. (If instead it opens LinkedIn "Easy Apply"
inline, handle that form. If it dead-ends or demands an account you cannot create, STOP and
report.)
4. RÉSUMÉ UPLOAD — do this FIRST and treat it as the make-or-break step (a half-finished upload
is what got the submission flagged as spam before). Follow this exactly:
a. Find the résumé / CV upload control and upload the file at the path above.
b. IMMEDIATELY use the `wait` action for ~8 seconds. Ashby uploads the file in the BACKGROUND
after you select it; the form is not valid until that finishes.
c. VERIFY the upload actually completed using CONCRETE signals (not a guess):
- the résumé FILENAME is displayed in the upload area, AND
- NO spinner / progress bar / "uploading" / "we're updating your application" text remains, AND
- ideally, Ashby has auto-parsed the résumé and pre-filled some fields (name / email /
experience) — that is strong proof the file reached their server.
d. If those signals are NOT all present, the upload FAILED or is pending: remove the file if
possible, re-upload, `wait` ~8s again, and re-verify. Retry up to 2 more times.
e. Only once the upload is CONFIRMED, continue. Remember whether it was confirmed for the
final report (resume_upload_confirmed).
5. Now fill the rest of the form top to bottom, never skipping a REQUIRED field. Two fields are
tricky widgets — fill them so the value actually COMMITS, then visually confirm it stuck:
- "Where are you currently located?" is a TYPEAHEAD: type the city (e.g. "New Delhi"), WAIT for
the suggestion dropdown, and CLICK the matching option. Typing alone does NOT commit — verify
the field now shows the selected location, not the "Start typing…" placeholder.
- "When can you start a new role?" is a DATE PICKER: open it, click an actual date (earliest
selectable), and verify the field now shows a date, not "Pick date…".
- Also fill name, the yahoo email, phone, LinkedIn/portfolio links from the profile, and any
experience/skills questions.
6. Screening questions: answer truthfully from the profile and CONFIRMED FACTS. For anything not
covered, choose the most truthful/neutral valid option, leave optional ones blank, and note it.
NEVER fabricate. Tick any required certification/consent checkbox.
7. Before submitting, re-scan the whole form: fix any empty REQUIRED field or validation error,
and CONFIRM the résumé upload is still showing as complete (not reverted to "uploading").
8. {submit_clause}
9. AFTER clicking submit, VERIFY — never assume it worked. IMPORTANT: click Submit only ONCE, then
`wait` ~5s and read the page. Do NOT rapid-click Submit repeatedly — repeated clicks themselves
look like spam.
- If it says files are still uploading / "try again when finished": the upload was not done.
Go back, re-verify the upload per step 4, then click Submit ONE more time.
- If validation errors appear, fix the flagged fields and submit once more.
- If it says the submission was "flagged as possible spam": STOP, do NOT resubmit, and report
status="failed" with that exact message (resubmitting hardens the flag).
- Look for an EXPLICIT confirmation: a success page or text such as "Application submitted",
"Thank you for applying", or a confirmation/reference ID. Read its exact wording.
- ONLY report status="submitted" if you actually SEE such an explicit confirmation, and quote
it verbatim in "confirmation". If you clicked submit but NO confirmation/success page
appears, report status="failed" with exactly what was on screen. NEVER claim success
without on-screen proof.
10. Final answer = a JSON object: {{"status": "submitted"|"ready_for_review"|"closed"|"failed"|"blocked",
"surface": "openai_board"|"linkedin_easy_apply"|"none", "resume_upload_confirmed": true|false,
"confirmation": <exact text/id or null>,
"answered": [<question: answer>...], "uncertain": [<questions you guessed or skipped>],
"notes": <short explanation>}}.
"""
async def main():
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] Launching Chrome (dedicated profile) and the apply agent "
f"(model={APPLY_MODEL}, auto_submit={AUTO_SUBMIT})...")
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=SENSITIVE_DATA,
)
print(f" login mode: {'credentials via sensitive_data' if SENSITIVE_DATA else 'persisted session'}")
try:
history = await agent.run(max_steps=MAX_STEPS)
print("\n[3/3] ========== AGENT RESULT ==========")
print(history.final_result())
print("=====================================")
# Capture the final page as proof of the confirmation (or of whatever is on screen).
try:
shot = os.path.join(os.path.dirname(os.path.abspath(__file__)), "confirmation.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())