The hardest part of auto-apply isn't submit
From the outside auto-apply looks deceptively simple.
Open a vacancy. Paste a letter. Press submit.
In practice submit is hardly a problem.
The problem is something else: the application page on HH isn’t one and the same page. It can mean completely different system states: the wrong resume with no way to switch, an application already sent, an expired session, a questionnaire, radio, checkbox, select, mixed forms with an extra text field, or simply a state that can’t be automated safely.
That’s why the apply flow in a project like this begins not with an action but with recognising reality.
def classify_page_variant(snapshot: Dict[str, Any]) -> PageVariant:
if not isinstance(snapshot, dict):
return PageVariant.UNKNOWN
if snapshot.get("auth_required"):
return PageVariant.AUTH_REQUIRED
if snapshot.get("already_applied"):
return PageVariant.ALREADY_APPLIED
if snapshot.get("hh_resume_override"):
return PageVariant.FIXED_RESUME
questionnaire_required = bool(snapshot.get("questionnaire_required"))
if questionnaire_required:
items = list(snapshot.get("questionnaire_items") or [])
safe_types = ApplyFlow.SAFE_QUESTIONNAIRE_TEXT_TYPES
has_unsupported = any(
str(item.get("field_type") or "input").strip().lower() not in safe_types
or bool(item.get("requires_human"))
for item in items
if isinstance(item, dict)
)
if has_unsupported or not items:
return PageVariant.QUESTIONNAIRE_UNSUPPORTED
return PageVariant.QUESTIONNAIRE_TEXT
if snapshot.get("primary_submit_label") and not questionnaire_required:
return PageVariant.SIMPLE_PICKABLE
return PageVariant.UNKNOWN
classify_page_variant(…) is needed for the first decision: what page is in front of the system now. not isinstance(snapshot, dict) cuts off a broken snapshot so the project doesn’t make decisions on garbage. AUTH_REQUIRED stops the pipeline if a login wall opens instead of an application. ALREADY_APPLIED keeps the system from making duplicates where an application already exists. FIXED_RESUME catches the case where HH itself imposes a resume and doesn’t allow a proper switch. questionnaire_required moves the page into another class of difficulty where the simple submit path no longer works. items = list(…) gathers a structured view of the questionnaire, without which automation goes blind. safe_types limits the set of fields the system considers safe to process at all. has_unsupported checks whether it has met a form where confidence is no longer sufficient. field_type not in safe_types cuts off everything outside the current safe circuit. requires_human gives the form the right to bring a person into the circuit early if automation is already doubtful. QUESTIONNAIRE_UNSUPPORTED is there so the project honestly admits the limit of its confidence and goes no further. QUESTIONNAIRE_TEXT records the case where the questionnaire can still follow a controlled scenario. primary_submit_label checks whether there’s a direct path to applying without a questionnaire. SIMPLE_PICKABLE marks the happy path where the page can be processed almost linearly. UNKNOWN leaves the system the right not to act if the page state isn’t recognised.
That’s where the real difficulty lies.
Here the project doesn’t “press a button”. Here it first interprets the state of the page and only then chooses one of the paths: automatic submit, questionnaire path, handoff to a person, skip, or stop.
At some point it became clear that questionnaires aren’t a special case but a separate machine inside the project. The system has to do more than paste text: it has to distinguish form types, understand the limits of its confidence and not act where the page is no longer clear.
So auto-apply turned out not to be the task of “pressing a button instead of a person”.
It turned out to be the task of “understanding what form is in front of you before taking a single action”.