Building a Local-Only Chrome Extension That Autofills Job Application Forms

Building a Local-Only Chrome Extension That Autofills Job Application Forms

If you've ever filled out a dozen job application forms in a row, you know the special kind of tedium involved. This post is part of my ongoing "automation foundation for shipping personal projects at scale" series, continuing from Getting Claude Code and Codex to collaborate. This time I'm writing about a Chrome extension that fills out job application forms with one click, built so that no data is ever sent to a server — everything stays local. I'll walk through MV3, heuristic field inference, handling birth dates split across three fields, and more, following the code I actually wrote. 🔗 You can actually use it: Job Form Autofill (Chrome Web Store) Why I Built It One thing you notice as soon as you start job hunting is the mind-numbing repetition of entry forms. Every company asks for your name, address, and educational background in the same way. LastPass and Chrome's built-in autofill are strong on "the same form on the same site," but they're helpless against job application forms that span each company's wildly different systems (Mynavi, Rikunabi-family systems, each company's proprietary system). The name attributes of the fields are all over the place too, and it's not unusual to see sequential names like field_001. Many off-the-shelf autofill tools send your profile to an external server, which means handing over your name, address, and birth date to a third party. Job hunting information is something I especially want to handle carefully, so I decided on a design where data is stored only in the browser's local storage and never leaves it. Overall Structure It's built with Manifest V3. The file structure is simple. jobform-autofill/ ├── manifest.json ├── src/ │ ├── util.js # Conversion utilities & profile field definitions │ ├── matcher.js # Field context inference logic │ ├── filler.js # DOM input primitives │ └── content.js # Integration of form scanning → classification → input ├── popup/ # Profile input screen opened by clicking the extension icon └── test/ ├── matcher.test.js # Unit tests for classifyField (no jsdom needed) ├── e2e.test.js # Integration test for mixed table/div forms ├── e2r.test.js # Regression tests for e2r-style forms (with overseas notes) └── button-gate.test.js # Tests for button display logic Enter fullscreen mode Exit fullscreen mode Here are the important parts of manifest.json. { "manifest_version": 3, "name": "就活フォーム自動入力", "permissions": ["storage"], "content_scripts": [ { "matches": [""], "js": ["src/util.js", "src/matcher.js", "src/filler.js", "src/content.js"], "run_at": "document_idle", "all_frames": true } ] } Enter fullscreen mode Exit fullscreen mode It's just "permissions": ["storage"]. Neither activeTab nor scripting is needed. Because it injects into all URLs as a content script, content_scripts's matches is set to . all_frames: true is there to support sites where the form is embedded inside an (some recruiting systems use iframes). The Core of the Design: Heuristic Field Inference The heart of an autofill tool is the part that decides "is this input field a name, or a postal code?" Ideally you'd just look at the autocomplete attribute, but on job application forms autocomplete is almost never set correctly. So I took the approach of "gathering up the strings surrounding a field and inferring from them." This processing is concentrated in matcher.js. buildContext: Collecting Surrounding Labels function buildContext(el) { const parts = []; const seen = new Set(); const push = (v) => { if (v == null) return; const t = stripExample(String(v).replace(/\s+/g, " ").trim()).replace(/\s+/g, " ").trim(); if (t && t.length push(el.getAttribute(a))); // aria-labelledby / label[for] const lb = el.getAttribute("aria-labelledby"); if (lb) lb.split(/\s+/).forEach((id) => { const e = document.getElementById(id); if (e) push(e.textContent); }); if (el.id) document.querySelectorAll(`label[for="${CSS.escape(el.id)}"]`) .forEach((l) => push(l.textContent)); // Walk up ancestors to pick up preceding label candidates (up to 5 levels) let node = el; for (let depth = 0; depth 0; if (!isControl && !hasControl) { const t = sib.textContent?.trim(); if (t) { push(t); break; } } sib = sib.previousElementSibling; hops++; } node = node.parentElement; } return normalizeContext(parts.join(" | ")); } Enter fullscreen mode Exit fullscreen mode The key point is a design that can pick up labels across "a 's , a 's , and a 's previous sibling element." Job application forms write their HTML differently from site to site, mixing table layouts, div layouts, and dl layouts. To make it work with all of these, I needed to walk up the DOM ancestors to a certain depth while extracting "the text of elements that don't hold form controls." I also added preprocessing called stripExample. It's there to remove placeholder-like notes such as "example (last name: Matsushita first name: Taro)". Without it, the "first name" (名) that appears inside the example text for the "last name" (姓) field causes it to be misclassified as "first name." classifyField: Classifying the Context String function classifyField(ctx) { const has = (re) => re.test(ctx); // Fields that should be skipped (highest priority) if (has(/頭文字|イニシャル|一文字|1文字/)) return null; if (has(/その他.*詳細|詳細を入力|系統その他|区分その他/)) return null; // Email (distinguish "email address 2" from the primary/confirmation field) if (has(/メール|e-?mail|mail/)) { const e2 = ctx.replace(/[22][\s ]*(度|回|目|通)/g, " "); if (/(メールアドレス|メール)[\s ]*[22]|サブ|予備|セカンド|secondary/.test(e2)) return "email2"; return "email"; } if (has(/郵便|〒|zip|postal/)) return "postalCode"; if (has(/携帯|けいたい|mobile|cell/)) return "phoneMobile"; if (has(/自宅|固定電話|home.?phone/)) return "phoneHome"; if (has(/電話|tel(?!l)|phone/)) return "phone"; // High school (judged before university) if (has(/高校|高等学校|出身高/)) { if (has(/卒業|修了/)) return "highSchoolGradYear"; if (has(/入学/)) return "highSchoolAdmYear"; return "highSchool"; } if (has(/卒業|修了|graduat/)) return "graddate"; if (has(/生年月日|誕生日|date.?of.?birth/)) return "birthdate"; // Name (strip the "名" in compound words so it isn't misread before judging) const stripped = ctx.replace(/氏名|お名前|名前|フリガナ|ふりがな|カナ氏名|fullname|name|kana/g, " "); const COMPOUND = /(地名|署名|記名|件名|品名|名称|名義|会社名|学校名|大学名)/; const isKana = has(/フリガナ|ふりがな|カナ|カナ|kana|furigana/); const isLast = /姓|せい|セイ|苗字|名字|last|family/.test(stripped); const isFirst = /めい|メイ|first|given/.test(stripped) || (/名/.test(stripped) && !COMPOUND.test(ctx)); const hasFull = has(/氏名|お名前|名前|fullname|\bname\b/); if (isKana) { if (isLast) return "lastNameKana"; if (isFirst) return "firstNameKana"; return "fullNameKana"; } if (isLast) return "lastName"; if (isFirst) return "firstName"; if (hasFull) return "fullName"; // Overseas-only fields (judged last. Skip) if (has(/海外在住|日本国外|overseas/)) return null; return null; } Enter fullscreen mode Exit fullscreen mode This function is a pure function. It has no DOM references — it just takes a string and returns a classification key — so it can be tested directly in Node.js without jsdom. This is one of the important design decisions in the code. Handling Birth Dates and Graduation Dates Split Across Three Fields Date fields are especially troublesome on job application forms. There are many patterns where "year / month / day" are split into separate elements, and to make it worse, the value might be 2003, or '03, or 2003年, differing from site to site. detectDateRole in filler.js infers "is this year, month, or day?" from the value range of the select's options. function detectDateRole(el) { const nums = Array.from(el.options) .map((o) => parseInt((o.value || o.textContent).replace(/[^0-9]/g, ""), 10)) .filter((x) => !isNaN(x)); if (!nums.length) return null; const max = Math.max(...nums); if (max >= 1900) return "year"; if (max { const m = parseInt(e.getAttribute("maxlength"), 10); return m > 0 && m l) ? lens : null; } const parts = pattern ? sliceByLens(digits, pattern) : [digits]; let n = 0; els.forEach((el, i) => { if (parts[i] != null) { setNativeValue(el, parts[i]); el.dispatchEvent(new Event("blur", { bubbles: true })); n++; } }); return n ? 1 : 0; } Enter fullscreen mode Exit fullscreen mode Kana Conversion: Handling Full-Width, Hiragana, and Half-Width Kana All at Once The furigana field's requirements vary by site: "enter in full-width katakana," "enter in hiragana," "enter in half-width kana." I store the profile in full-width katakana and convert it according to the field's context hints. function adaptKana(value, hint) { if (/半角|ハンカク|hankaku|半角カナ/.test(hint)) return fullToHalfKana(value); if (/ひらがな|ふりがな|hiragana/.test(hint)) return kataToHira(value); return value; // Default is full-width katakana } Enter fullscreen mode Exit fullscreen mode Since buildContext collects up label text like "in half-width kana" or "enter in hiragana," I use those hints here. Deciding Button Display: Determining Whether It's an Entry Form The content script is injected into . Showing an "autofill" button even on job search pages or company top pages would be intrusive, so it decides "is this a real entry form?" before displaying the button. const MIN_CLASSIFIED = 2; function looksLikeForm() { let n = 0; for (const el of document.querySelectorAll(TEXT_FIELDS)) { if (classifyField(buildContext(el)) && ++n >= MIN_CLASSIFIED) return true; } return false; } Enter fullscreen mode Exit fullscreen mode The criterion is "there are two or more fields that can be classified into a profile item." A job-narrowing page just has a row of checkboxes, which don't classify into name, address, or email. On an entry form these are easily found two or more times, so a threshold of 2 is enough to discriminate. On SPAs (recruiting systems that switch between list ↔ form without a page transition), it watches DOM changes with a MutationObserver and dynamically shows/hides the button. let evalTimer = null; const scheduleEval = () => { clearTimeout(evalTimer); evalTimer = setTimeout(evaluateButton, 400); }; new MutationObserver(scheduleEval) .observe(document.body, { childList: true, subtree: true }); Enter fullscreen mode Exit fullscreen mode The debounce is there because the MutationObserver also fires on the addition of the button itself. Testing: Verifying All the Logic Without a Browser Using jsdom The thing I cared about most when writing the code was avoiding the situation of "you can't test without launching a browser." A Chrome extension's content script runs in the browser, but by carving the logic out into pure functions I made it testable on Node.js. The tests are split into four files. matcher.test.js (no jsdom needed): Unit tests for classifyField. Misclassifications I hit on real forms are added as regression tests as-is. const cases = [ ["氏名", "fullName"], ["お名前", "fullName"], ["姓", "lastName"], ["名", "firstName"], // ... regression tests for traps hit on real forms ["現住所市区郡・地名", "city"], // don't misread "地名" as first name ["学校名の頭文字", null], // skip initial-letter fields ["会社名", null], // don't treat the "名" in a compound word as first name ["氏名 姓", "lastName"], // not clobbered by the row's "氏名"; judged as last name ["氏名 名", "firstName"], // same, judged as first name ["海外在住の方はこちら", null], // skip overseas-only fields // ... ]; Enter fullscreen mode Exit fullscreen mode e2e.test.js: Runs content.js against sample-form.html (which includes both table and div layouts) using jsdom and verifies that each field is filled in correctly. e2r.test.js: Regression tests for e2r-style forms. On a special layout where "domestic split boxes" and "an overseas standalone box" sit in the same row, it confirms that the overseas box stays empty while only the domestic boxes are filled correctly. button-gate.test.js: Tests for the looksLikeForm decision. It confirms two cases: don't show the button on a job search page, and do show it on an entry form. The test results at this point are as follows. matcher.test.js : 63/63 all passing e2e.test.js : 34/34 all passing e2r.test.js : 20/20 all passing button-gate.test.js: 3/3 all passing Total: 120/120 all passing Enter fullscreen mode Exit fullscreen mode Pitfalls I Hit 1. 例(姓:マツシタ 名:タロウ) Contaminates Classification When the "input example" text adjacent to a label gets mixed into buildContext, the string "名" (first name) shows up even in a "姓" (last name) field, causing misclassification. It was resolved once I removed it with stripExample. 2. The "名" in "会社名" and "学校名" Gets Caught as First Name This is the problem where containing the character 名 gets it classified as firstName. I created a compound-word list COMPOUND and excluded things like "品名 (product name)," "件名 (subject)," "大学名 (university name)," "会社名 (company name)," and "学校名 (school name)." 3. The Compound Pattern of a Row Label "氏名" + Column Header "姓" In table layouts there were cases where the row header was "氏名 (full name)" and the column header was "姓 (last name)." Since buildContext picks up both, the context becomes "氏名 姓." In this case both hasFull (has full name) and isLast (has last name) become true. Because judging isLast first makes it fall into lastName, it was resolved through the order of judgment. 4. The Mix of e2r-Style "Domestic Boxes" and "Overseas Boxes" Some recruiting systems (e2r) place "three small domestic split boxes" and "one standalone large overseas box" in the same row. If you naively group "the phone fields in the same row," all the digits go into the overseas box. It was resolved by having splitCluster extract only the boxes with a small maxlength (1–6 digits) as the split cluster and excluding the boxes with a large maxlength. 5. メールアドレスを2度ご記入ください Is Misread as "Email 2" The "2" in the expression "2度 (twice)" got confused with the "2" of an email ordinal. I made it remove the pattern [22][\s ]*(度|回|目|通) first, then do the ordinal check. 6. In React Forms the Value Becomes Empty on Submission This is the problem where it looks filled with el.value = "..." but becomes empty on submit because React hasn't updated its state. It was handled by writing via the prototype's setter and firing events with setNativeValue. About Behavior on Real Forms To be honest, I haven't yet been able to do comprehensive verification on real, actual recruiting forms. The jsdom-based tests confirm the correctness of the logic, and "can it actually input correctly on a real recruiting system?" is a separate matter. The form's HTML structure, the implementation of event listeners, and the SPA framework differ from service to service. I made sample-form.html and sample-e2r.html to cover typical patterns, but they don't cover every case. When using it, please always check the content before submitting. The extension shows a toast notification saying "be sure to check the content" after input, but the responsibility is on the user. Summary MV3 + only "permissions": ["storage"]: no external communication; the profile is stored locally in the browser buildContext: collects labels across / / ; walks up to 5 levels of ancestors classifyField: a pure function; the order of regular expressions becomes the classification priority; removes example text first with stripExample detectDateRole: infers year/month/day from the option value range; supports 3-way split selects setNativeValue: writes via the prototype's setter + fires events to keep up with React/Vue splitCluster: distinguishes domestic and overseas boxes by maxlength Tests: runs 120 tests on Node.js without a browser using jsdom Full verification on real forms not yet done: checking the content is mandatory when using it The variety of job application forms really is enormous, and it's entirely possible this code malfunctions on an unexpected layout. Setting DEBUG = true in content.js prints the context of unclassified fields via console.log, so when there's a problem I patch it by adding conditions to classifyField. Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio · X · GitHub*

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.