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
193 lines
11 KiB
Python
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())
|