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

작성자

카테고리:

← 피드로
DEV Community · Lily · 2026-07-21 개발(SW)

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": ["<all_urls>"],
      "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_urls>. all_frames: true is there to support sites where the form is embedded inside an <iframe> (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 < 80 && !seen.has(t)) { seen.add(t); parts.push(t); }
  };

  // From attributes
  ["name", "id", "placeholder", "aria-label", "autocomplete", "title", "data-label"]
    .forEach((a) => 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 < 5 && node && node.tagName !== "BODY"; depth++) {
    if (node.tagName === "TD" || node.tagName === "TH") {
      const row = node.closest("tr");
      if (row) {
        const h = row.querySelector("th") || row.querySelector("td");
        if (h && h !== node) push(h.textContent);
      }
    }
    if (node.tagName === "DD" && node.previousElementSibling?.tagName === "DT") {
      push(node.previousElementSibling.textContent);
    }
    // Check that the previous sibling contains no form controls before picking it up
    let sib = node.previousElementSibling, hops = 0;
    while (sib && hops < 3) {
      const isControl = sib.matches?.("input, select, textarea, button");
      const hasControl = sib.querySelectorAll?.("input, select, textarea").length > 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 <table>‘s <th>, a <dl>‘s <dt>, and a <div>‘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 <select> elements, and to make it worse, the <option> 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 <= 12) return "month";
  if (max <= 31) return "day";
  return null;
}

Enter fullscreen mode Exit fullscreen mode

If the maximum value is 1900 or above it’s “year,” 12 or below it’s “month,” and 31 or below it’s “day.” Simple, but it handles nearly every pattern you see on job application forms.

The selection is handled by selectNumber. It absorbs notation variations like '5', '05', '5月', and '5日'.

function selectNumber(el, num) {
  const n = parseInt(num, 10);
  if (isNaN(n)) return false;
  return selectOption(el, [
    String(n), String(n).padStart(2, "0"),
    `${n}月`, `${n}日`, `${n}年`, `平成${n}`
  ]);
}

Enter fullscreen mode Exit fullscreen mode

Supporting React / Vue Forms

Simply writing el.value = "..." has the problem that React and Vue “don’t detect the change.” Because these frameworks manage value through a virtual DOM, rewriting the DOM directly doesn’t update the state, and the field can be treated as empty on submission.

setNativeValue in filler.js works around this problem.

function setNativeValue(el, value) {
  const proto =
    el.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype :
    el.tagName === "SELECT" ? HTMLSelectElement.prototype :
    HTMLInputElement.prototype;
  const desc = Object.getOwnPropertyDescriptor(proto, "value");
  if (desc && desc.set) desc.set.call(el, value);
  else el.value = value;
  el.dispatchEvent(new Event("input", { bubbles: true }));
  el.dispatchEvent(new Event("change", { bubbles: true }));
}

Enter fullscreen mode Exit fullscreen mode

The key point is pulling out the native setter with Object.getOwnPropertyDescriptor before calling it. Because React installs its change detection by overriding the value setter on HTMLInputElement.prototype, pulling the setter from the prototype rather than the instance and calling it lets you slip past that detection. On top of that, firing the input and change events makes Vue’s v-model keep up too.

Handling Split Boxes for Phone and Postal Codes

There are also many patterns where a phone number is split into three like “090 | 1717 | 0135,” or a postal code is split into “171 | 0031.”

fillSplitNumber handles this. It splits by digit count using standard patterns and pours the values into each box.

function fillSplitNumber(els, value, kind) {
  const digits = String(value || "").replace(/\D/g, "");
  if (!digits) return 0;
  let pattern;
  if (kind === "postal") {
    pattern = digits.length === 7 ? [3, 4] : null;
  } else if (kind === "phone") {
    if (els.length === 3) {
      if (digits.length === 11) pattern = [3, 4, 4];          // Mobile 090-XXXX-XXXX
      else if (digits.length === 10)
        pattern = digits.startsWith("0") ? [2, 4, 4] : [3, 3, 4]; // Landline 03-XXXX-XXXX
    }
  }
  // Use maxlength when it's trustworthy
  if (!pattern) {
    const lens = els.map((e) => {
      const m = parseInt(e.getAttribute("maxlength"), 10);
      return m > 0 && m < 20 ? m : null;
    });
    pattern = lens.every((l) => 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 <all_urls>. 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 <table> / <div> / <dl>; 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*

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다