diff --git a/api/app/autopilot.py b/api/app/autopilot.py index 65f1847..9868dd7 100644 --- a/api/app/autopilot.py +++ b/api/app/autopilot.py @@ -387,6 +387,12 @@ async def decide(filled: dict) -> bool: _keep_screenshot(run, "confirm", final.get("confirm_screenshot") or "") if final.get("submitted"): outcome, reasons = "submitted", [] + elif final.get("code_asked"): + # The site emailed the candidate a code to prove a person is applying (Catapult, on Greenhouse: "enter the + # 8-character code to confirm you're a human"). That check is theirs to pass: nothing went, so it waits. + outcome, reasons = "needs_you", [f"The job site emailed you a code to confirm a person is applying, so this " + f"wasn't sent. It says: “{final['code_asked']}” Apply on the job's page " + f"with this run's CV and cover letter."[:600]] elif final.get("submit_attempted"): detail = final.get("submit_detail") or "no confirmation shown" outcome, reasons = "unconfirmed", [f"The submit button was clicked, but the page didn't confirm it ({detail}). " @@ -409,6 +415,8 @@ async def decide(filled: dict) -> bool: if app_id: note = {"submitted": "Sent by autopilot.", "unconfirmed": "Autopilot clicked submit but saw no confirmation.", "needs_you": "Autopilot filled this in and held it for you."}[outcome] + if final.get("code_asked"): + note = "Autopilot filled this in, but the job site asked for a code it emailed you: apply on its page." tracker.update_status(app_id, status, note, user_id=user_id) tracker.set_application_questions(app_id, questions, user_id=user_id) reused = (f"Used the CV and cover letter from the {'practice ' if earlier['practice'] else ''}run of " diff --git a/api/app/browser_agent.py b/api/app/browser_agent.py index 660aacf..e4551b1 100644 --- a/api/app/browser_agent.py +++ b/api/app/browser_agent.py @@ -1319,6 +1319,39 @@ async def _empty_required_fields(browser_session) -> list[str]: }""" +# After a submit the page didn't confirm: the site's request for a code it emailed the candidate (Greenhouse: "A +# verification code was sent to … enter the 8-character code to confirm you're a human"). The line that says it was +# sent, else one that asks for such a code; "" when there's neither. +_CODE_ASKED_JS = r"""() => { + const docs = [document]; + for (const frame of document.querySelectorAll('iframe')) { + try { if (frame.contentDocument) docs.push(frame.contentDocument); } catch (e) {} + } + const sent = /\bcode\b[^.]*\b(sent|emailed)\b|\b(sent|emailed)\b[^.]*\bcode\b/i; + const asks = /\b(verification|security|confirmation|one[- ]time) code\b|confirm you'?re (a )?human/i; + let found = ''; + for (const doc of docs) { + const text = (doc.body && doc.body.innerText) || ''; + for (const line of text.split(/\n+/).map(l => l.trim()).filter(Boolean)) { + if (sent.test(line)) return JSON.stringify(line.slice(0, 300)); + if (!found && asks.test(line)) found = line.slice(0, 300); + } + } + return JSON.stringify(found); +}""" + + +async def _code_asked(browser_session) -> str: + """The page's request for a code the site emailed the candidate (see _CODE_ASKED_JS); "" if none or unreadable.""" + try: + page, _ = await _focused_page(browser_session) + asked = json.loads(await page.evaluate(_CODE_ASKED_JS)) + return asked if isinstance(asked, str) else "" + except Exception as exc: + logger.debug("code check failed: %s", exc) + return "" + + async def _submit_button_shown(browser_session) -> bool: """Whether the focused page shows the button that sends the application; False if it can't be read.""" try: @@ -1486,7 +1519,8 @@ async def _finish_unattended(decide: Decider, filled: dict, browser_session, llm """ Send the filled form if `decide` says to, else leave it. The last event is done=True and carries the fill's results plus `form_screenshot` (the filled form, before anything is sent); when the submit agent - ran, also `submit_attempted`, `submitted` (only if the page confirmed it), `submit_detail` and + ran, also `submit_attempted`, `submitted` (only if the page confirmed it), `submit_detail`, `code_asked` (the + page's request for a code the site emailed the candidate, when it made one instead of confirming) and `confirm_screenshot` (the page after the click). """ final = {"action": "Filled in; not sent", "screenshot": None, "blocked": False, "reason": None, "done": True, @@ -1501,9 +1535,11 @@ async def _finish_unattended(decide: Decider, filled: dict, browser_session, llm yield {"action": "Submitting the application…", "screenshot": None, "blocked": False, "reason": None, "done": False} ok, detail = await _submit_with_agent(llm, browser_session, sensitive_data, secret) - final.update(submit_attempted=True, submitted=ok, submit_detail=detail, + code = "" if ok else _redact(await _code_asked(browser_session), secret) + final.update(submit_attempted=True, submitted=ok, submit_detail=detail, code_asked=code, confirm_screenshot=await _screenshot_b64(browser_session), - action="Application submitted" if ok else "Couldn't confirm the submission") + action="Application submitted" if ok else + "The site asked for a code it emailed you" if code else "Couldn't confirm the submission") yield final @@ -1651,8 +1687,13 @@ async def on_step(browser_state, agent_output, step_number): notes = _with_empty_fields(summary, empty, left_blank, self_answered)[:_MAX_NOTES_CHARS] if decide is not None: - filled = {"ready": ready, "error": agent_error, "notes": notes, "questions": questions, "empty": empty, - "self_answered": self_answered} + # The agent's summary is its own account, written as it stopped, and can disagree with the form as it ends + # up: Catapult's said the consent box was left unticked, yet the form showed it ticked from the saved + # answer. Unattended, nobody checks it against the page, so the notes keep what the final form shows; the + # summary stays only when the agent couldn't finish, to say why. + checked = _with_empty_fields("" if ready else summary, empty, left_blank, self_answered) + filled = {"ready": ready, "error": agent_error, "notes": checked[:_MAX_NOTES_CHARS], + "questions": questions, "empty": empty, "self_answered": self_answered} async for step in _finish_unattended(decide, filled, browser_session, llm, sensitive_data, job_password): yield step return diff --git a/api/tests/browser/site/code.html b/api/tests/browser/site/code.html new file mode 100644 index 0000000..4196599 --- /dev/null +++ b/api/tests/browser/site/code.html @@ -0,0 +1,13 @@ + +