|
| 1 | +--- |
| 2 | +title: "Automating job applications in Python" |
| 3 | +description: "The build-it-yourself route: browser automation plus an LLM API, the four components a real pipeline needs, why naive scripts break on wizards, validation and anti-bot layers, and the honest case for using what already exists." |
| 4 | +parent: "Job Application Automation" |
| 5 | +nav_order: 3 |
| 6 | +--- |
| 7 | + |
| 8 | + |
| 9 | +# Automating job applications in Python |
| 10 | + |
| 11 | +Building a job-application bot in Python is a genuinely instructive project: it |
| 12 | +touches browser automation, LLM integration, state, and the parts of the web |
| 13 | +that push back. This page lays out what the real components are, shows why the |
| 14 | +obvious eighty-line script breaks, and closes with the honest note that this |
| 15 | +exact project has already been built in the open, because that is literally how |
| 16 | +AIHawk started. |
| 17 | + |
| 18 | +One rule this page keeps, like every page under this parent: no job platform is |
| 19 | +named, and the examples run against generic forms or pages you serve yourself. |
| 20 | +The mechanics are the same everywhere, which is exactly why the generic version |
| 21 | +is worth writing down. |
| 22 | + |
| 23 | +## The naive version, to have something to break |
| 24 | + |
| 25 | +Every attempt starts roughly here: Playwright opens the form, an LLM drafts the |
| 26 | +answers, the script fills and submits. |
| 27 | + |
| 28 | +```python |
| 29 | +from playwright.sync_api import sync_playwright |
| 30 | + |
| 31 | +BACKGROUND = open("background.md").read() # your real history, as text |
| 32 | + |
| 33 | +def draft(question: str) -> str: |
| 34 | + # any LLM API; the contract is what matters: |
| 35 | + # answer from BACKGROUND only, say "ASK" when the answer is not in it |
| 36 | + ... |
| 37 | + |
| 38 | +with sync_playwright() as p: |
| 39 | + browser = p.firefox.launch() |
| 40 | + page = browser.new_page() |
| 41 | + page.goto("http://127.0.0.1:8000/application-form") # a page you serve |
| 42 | + for field in page.locator("form [data-question]").all(): |
| 43 | + answer = draft(field.get_attribute("data-question")) |
| 44 | + field.fill(answer) |
| 45 | + page.click("#submit") |
| 46 | +``` |
| 47 | + |
| 48 | +On a form you wrote yourself, this works on the first try, which is what makes |
| 49 | +the approach look one weekend wide. The distance between this and a pipeline |
| 50 | +that survives real pages is the rest of this page. |
| 51 | + |
| 52 | +## The four components a real pipeline needs |
| 53 | + |
| 54 | +**Form detection.** Real forms do not label themselves with `data-question`. |
| 55 | +Fields associate with their questions through `<label for>`, `aria-label`, |
| 56 | +`aria-labelledby`, placeholder text, or nothing but visual proximity; required |
| 57 | +markers and error text sit in separate nodes; some forms arrive inside an |
| 58 | +embedded iframe with its own document. Mapping "what is this field asking" to |
| 59 | +"which element do I fill" is a real subproblem, and it is where an LLM earns its |
| 60 | +place: handed the rendered page, a model can read the form the way a person |
| 61 | +does, which is the approach described in |
| 62 | +[getting an AI agent to fill out forms](ai-agent-fill-out-forms.md). |
| 63 | + |
| 64 | +**Answer generation.** The model needs your background as grounding and a |
| 65 | +contract that keeps it honest: every claim from the provided history, an |
| 66 | +explicit escape hatch ("ASK") for questions it cannot answer from it, and no |
| 67 | +improvisation. Tailoring - picking which true things to emphasize for this role |
| 68 | +- is the value. Fabricating qualifications is a bug, and it is one your own |
| 69 | +pipeline will happily ship at scale if the prompt does not forbid it. |
| 70 | + |
| 71 | +**Session persistence.** Applying is logged-in work spread over days. That |
| 72 | +means a persistent browser profile rather than a fresh context per run, so |
| 73 | +cookies and logins survive; it also means the same browser identity each time, |
| 74 | +because a login that hops between fingerprints looks stolen. Plan for a profile |
| 75 | +directory and for state you can resume after a crash mid-wizard. |
| 76 | + |
| 77 | +**Pacing.** The component most builds skip and the first one to matter in |
| 78 | +production. Per-account and per-IP rate limits are real, and a submission every |
| 79 | +few seconds is a signature no fingerprint work can hide. Pacing is jitter |
| 80 | +between actions, minutes between applications, a daily cap, and backoff on |
| 81 | +anything that looks like throttling. It is also the ethical component: the |
| 82 | +volume ceiling is where "my bot" stops being distinguishable from spam, a |
| 83 | +lesson this project's own history documents in detail on |
| 84 | +[the history page](open-source-job-application-bot.md). |
| 85 | + |
| 86 | +## Why naive scripts break |
| 87 | + |
| 88 | +**Multi-step wizards.** Real applications are three to eight steps with state: |
| 89 | +conditional fields that appear based on earlier answers, a review step that |
| 90 | +re-renders everything, a back button that resets more than it should. A script |
| 91 | +that models "the form" as one page loses to the first wizard it meets. You need |
| 92 | +a loop over steps - read, fill, advance, re-read - and idempotent resume, |
| 93 | +because step four is where the timeout happens. |
| 94 | + |
| 95 | +**Client-side validation.** Fields validate on blur and on keystroke; masked |
| 96 | +inputs reformat as you type; selects are custom widgets that only respond to |
| 97 | +real interaction. Setting `element.value` from JavaScript skips the events the |
| 98 | +page listens for, so the value silently fails validation, or arrives empty at |
| 99 | +submit. Fill through real typing and clicking. It is telling that AIHawk's own |
| 100 | +agent, per its README, refuses to set form fields from JavaScript even when it |
| 101 | +would be faster, because a page can tell the difference. |
| 102 | + |
| 103 | +**File uploads.** The resume field is a file input, often behind a styled |
| 104 | +button, sometimes a drag-and-drop zone. Playwright's `set_input_files` covers |
| 105 | +the plain case; the styled cases need the real input located first. And a |
| 106 | +tailored resume means generating a file per application, which is its own |
| 107 | +pipeline stage. |
| 108 | + |
| 109 | +**The anti-bot layer.** This is the wall that surprises builders most, because |
| 110 | +it has nothing to do with your code being correct. Stock automation is |
| 111 | +detectable below your script: driver artifacts, headless tells, a fingerprint |
| 112 | +inconsistent with the claimed platform, a TLS handshake that does not match the |
| 113 | +user agent. No amount of selector work fixes a page that has already decided |
| 114 | +what you are. The mechanism layer - fingerprinting surfaces, detection vendors, |
| 115 | +network tells - is documented in depth on the |
| 116 | +[invisible_playwright wiki](https://github.com/feder-cr/invisible_playwright/wiki), |
| 117 | +and the agent-level symptoms in |
| 118 | +[why does my AI agent get blocked?](why-does-my-ai-agent-get-blocked.md). If |
| 119 | +you stay on the build-it-yourself road, that engine is usable as a library: |
| 120 | +[invisible_playwright](https://github.com/feder-cr/invisible_playwright) is a |
| 121 | +Firefox patched at the C++ level, driven through the standard Playwright API, |
| 122 | +so the code above ports by changing the launch. |
| 123 | + |
| 124 | +## Or use what exists |
| 125 | + |
| 126 | +Here is the honest close. The pipeline this page describes - form reading, |
| 127 | +grounded answer generation, persistent sessions, pacing, on a browser built to |
| 128 | +look like a real one - is an open-source project you can read instead of |
| 129 | +rediscover. [AIHawk](https://github.com/feder-cr/AIHawk) began in 2024 as |
| 130 | +exactly this Python bot, reached about 30,000 stars and a wave of press |
| 131 | +coverage, and grew into a general web agent on that same hardened engine. It is |
| 132 | +MIT-licensed; run it as an interface with `uvx aihawk ui`, script it headless |
| 133 | +with `uvx aihawk do "..."`, or take just the browser layer as a library and |
| 134 | +keep your own agent logic on top. |
| 135 | + |
| 136 | +Building your own is still a fine choice when the point is learning or when |
| 137 | +your flow is unusual. But go in knowing which parts are the actual work: not |
| 138 | +the form filling, which a weekend gets you, but the wizard state, the |
| 139 | +validation-safe input, the stable identity, and the restraint. |
| 140 | + |
| 141 | +## Conclusion |
| 142 | + |
| 143 | +A real job-application pipeline in Python is four components - form detection, |
| 144 | +grounded answer generation, session persistence, pacing - sitting on a browser |
| 145 | +that can survive being looked at. The naive script fails on multi-step wizards, |
| 146 | +event-driven validation, uploads, and an anti-bot layer that inspects the |
| 147 | +browser underneath your code. All four components, on a hardened engine, exist |
| 148 | +in the open already; whether you build or adopt, the volume ceiling and the |
| 149 | +review-before-submit rule are the parts that keep the pipeline worth running. |
| 150 | + |
| 151 | +## Short answers to the questions that lead here |
| 152 | + |
| 153 | +**Can I automate job applications with Python and Playwright?** Yes, and the |
| 154 | +form-filling part is genuinely easy. The real work is wizard state, |
| 155 | +validation-safe typing, persistent sessions, pacing, and a browser that does |
| 156 | +not advertise itself as automation. |
| 157 | + |
| 158 | +**Which LLM do I need?** Any API model works for answer generation. The prompt |
| 159 | +contract matters more than the model: answers only from your provided |
| 160 | +background, with an explicit "ask the human" escape for anything else. |
| 161 | + |
| 162 | +**Why does my script's input disappear at submit?** You are probably setting |
| 163 | +values from JavaScript, which skips the input events client-side validation |
| 164 | +listens for. Type and click like a user; the value then exists the way the page |
| 165 | +expects. |
| 166 | + |
| 167 | +**Why does my bot get blocked even though the code works?** Detection operates |
| 168 | +below your script: driver artifacts, headless tells, fingerprint and TLS |
| 169 | +inconsistencies. See the |
| 170 | +[invisible_playwright wiki](https://github.com/feder-cr/invisible_playwright/wiki) |
| 171 | +for the mechanism layer; correctness of your Python is not the question being |
| 172 | +asked. |
| 173 | + |
| 174 | +**How fast can I safely go?** Slower than the code allows. Jitter inside a |
| 175 | +form, minutes between applications, a daily cap you would defend out loud. |
| 176 | +Past that ceiling you are building a spam tool with extra steps, and the |
| 177 | +account it burns is yours. |
| 178 | + |
| 179 | +**Is there an open-source version already?** Yes - |
| 180 | +[AIHawk](https://github.com/feder-cr/AIHawk), which started as exactly this |
| 181 | +bot and is now a general web agent on a hardened Firefox. Reading its layout is |
| 182 | +a shortcut even if you then build your own. |
| 183 | + |
| 184 | +## Sources |
| 185 | + |
| 186 | +- The [AIHawk README](https://github.com/feder-cr/AIHawk#readme), retrieved |
| 187 | + 2026-09-03, for the interface and headless commands, the library and MCP |
| 188 | + layers, the license, and the agent's refusal to set form fields from |
| 189 | + JavaScript. |
| 190 | +- [404 Media's October 2024 |
| 191 | + report](https://www.404media.co/i-applied-to-2-843-roles-the-rise-of-ai-powered-job-application-bots/), |
| 192 | + retrieved 2026-09-03, for what the original bot generation actually did in |
| 193 | + the field: biographical fill, generated resumes, customized cover letters, |
| 194 | + unattended volume. |
| 195 | +- This project's own engine documentation, linked throughout, for the |
| 196 | + fingerprinting and detection mechanics summarized in the anti-bot section. |
| 197 | + |
| 198 | +**See also:** [getting an AI agent to fill out |
| 199 | +forms](ai-agent-fill-out-forms.md) for the form-reading approach in isolation, |
| 200 | +[automating job applications with |
| 201 | +Claude](automate-job-applications-with-claude.md) for the no-code version of |
| 202 | +this pipeline, [the open-source job application bot, and what it |
| 203 | +became](open-source-job-application-bot.md) for where the build-it-yourself |
| 204 | +road led once, and the |
| 205 | +[job application automation hub](guides-job-application-automation.md). |
| 206 | + |
| 207 | +--- |
| 208 | + |
| 209 | +*Written by the maintainer of [AIHawk](https://github.com/feder-cr/AIHawk), |
| 210 | +which exists because someone built this exact Python project and then spent two |
| 211 | +years on the parts this page says are the actual work.* |
0 commit comments