cd /news/developer-tools/building-a-local-only-chrome-extensi… · home topics developer-tools article
[ARTICLE · art-66223] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer built a local-only Chrome extension that autofills job application forms with one click, ensuring no data is ever sent to a server. The extension uses Manifest V3, heuristic field inference from surrounding strings, and stores profile data only in the browser's local storage.

read14 min views2 publishedJul 21, 2026

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)

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.

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

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
    }
  ]
}

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 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

.

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(" | "));
}

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."

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;
}

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.

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;
}

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}`
  ]);
}

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 }));
}

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.

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;
}

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
}

Since buildContext

collects up label text like "in half-width kana" or "enter in hiragana," I use those hints here.

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;
}

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 });

The debounce is there because the MutationObserver also fires on the addition of the button itself.

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
  // ...
];

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

例(姓:マツシタ 名:タロウ)

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

.

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)."

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.

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.

メールアドレスを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.

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

.

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.

"permissions": ["storage"]

buildContext

<table>

/ <div>

/ <dl>

; walks up to 5 levels of ancestorsclassifyField

stripExample

detectDateRole

setNativeValue

splitCluster

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*

── more in #developer-tools 4 stories · sorted by recency
── more on @chrome 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-a-local-onl…] indexed:0 read:14min 2026-07-21 ·