Claude CodeとCodexを協業させる話から続く「個人開発を量産するための自動化基盤」シリーズです。今回は、就活のエントリーフォームをワンクリックで埋める Chrome 拡張機能を、サーバーに一切データを送らずローカル完結で作った話を書きます。MV3・ヒューリスティックなフィールド推定・生年月日3分割対応など、実際に書いたコードに沿って解説します。
🔗 実際に使えます: 就活フォーム自動入力(Chrome ウェブストア)
なぜ作ったか
就活を始めてすぐ気づくのが、エントリーフォームのうんざりするような反復です。どの会社も氏名・住所・学歴を同じように聞いてきます。LastPass や Chrome の組み込みオートフィルは「同じサイトの同じフォーム」には強いですが、各社バラバラのシステム(マイナビ・リクナビ系・各社独自システム)にまたがる就活フォームには歯が立ちません。フィールドの name 属性もバラバラで、field_001 のような連番のものも少なくありません。
既製の自動入力ツールは外部サーバーにプロフィールを送信するタイプが多く、氏名・住所・生年月日を第三者に渡すことになります。就活情報は特に慎重に扱いたいので、データはブラウザのローカルストレージにのみ保存し、外に一切出さない設計にすることにしました。
全体構成
Manifest V3 で作っています。ファイル構成はシンプルです。
jobform-autofill/
├── manifest.json
├── src/
│ ├── util.js # 変換ユーティリティ・プロフィール項目定義
│ ├── matcher.js # フィールド文脈の推定ロジック
│ ├── filler.js # DOM への入力プリミティブ
│ └── content.js # フォーム走査 → 分類 → 入力の統合
├── popup/ # 拡張アイコンをクリックして開くプロフィール入力画面
└── test/
├── matcher.test.js # classifyField の単体テスト(jsdom 不要)
├── e2e.test.js # table/div 混在フォームの統合テスト
├── e2r.test.js # e2r 系フォームの回帰テスト(海外注記付き)
└── button-gate.test.js # ボタン表示判定のテスト
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
}
]
}
"permissions": ["storage"] だけです。activeTab も scripting も要りません。コンテントスクリプトとして全 URL にインジェクトするため、content_scripts の matches を <all_urls> にしています。all_frames: true は、フォームが <iframe> 内に埋め込まれているサイト(一部の採用システムが iframe を使います)にも対応するためです。
設計の核心:フィールドのヒューリスティック推定
自動入力ツールの肝は「この入力欄が氏名なのか、郵便番号なのか」を判断する部分です。理想は autocomplete 属性を見るだけで済むことですが、就活フォームで autocomplete が正しく設定されていることはほぼありません。
そこで 「フィールドの周辺にある文字列をかき集めて推定する」 アプローチを取りました。処理は matcher.js に集約されています。
buildContext:周辺ラベルを収集する
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); }
};
// 属性から
["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));
// 祖先を遡って直前のラベル候補を拾う(最大5階層)
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);
}
// 前の兄弟要素をフォーム部品が含まないか確認してから拾う
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(" | "));
}
ポイントは「<table> の <th>・<dl> の <dt>・<div> の前兄弟要素」を横断的に拾える設計です。就活フォームは HTML の書き方がサイトごとにバラバラで、table レイアウト・div レイアウト・dl レイアウトが混在します。これらすべてで動くようにするには、DOM の祖先を一定深さまで遡りながら「フォーム部品を持たない要素のテキスト」を抜き出す必要がありました。
また、stripExample という前処理を入れています。「例(姓:マツシタ 名:タロウ)」のようなプレースホルダー的な注記を除去するためです。これが無いと、「姓」の欄の例示テキストの中に「名」が含まれることで「名(first name)」と誤分類される問題が起きます。
classifyField:文脈文字列を分類する
function classifyField(ctx) {
const has = (re) => re.test(ctx);
// スキップすべき欄(最優先)
if (has(/頭文字|イニシャル|一文字|1文字/)) return null;
if (has(/その他.*詳細|詳細を入力|系統その他|区分その他/)) return null;
// メール(メールアドレス2 と本体/確認用を区別)
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";
// 高校(大学より先に判定)
if (has(/高校|高等学校|出身高/)) {
if (has(/卒業|修了/)) return "highSchoolGradYear";
if (has(/入学/)) return "highSchoolAdmYear";
return "highSchool";
}
if (has(/卒業|修了|graduat/)) return "graddate";
if (has(/生年月日|誕生日|date.?of.?birth/)) return "birthdate";
// 氏名(複合語の「名」を誤認しないよう除去してから判定)
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";
// 海外専用欄(最後に判定。スキップ)
if (has(/海外在住|日本国外|overseas/)) return null;
return null;
}
この関数は純粋関数です。DOM 参照が一切なく、文字列を受け取って分類キーを返すだけなので、Node.js 上で jsdom なしに直接テストできます。コードの重要な設計判断のひとつです。
生年月日・卒業年月の3分割対応
就活フォームで特に厄介なのが日付フィールドです。「年・月・日」が別々の <select> に分かれているパターンが多く、しかも <option> の value が 2003 だったり '03 だったり 2003年 だったりとサイトによって異なります。
filler.js にある detectDateRole は、select の option の値レンジから「これは年か月か日か」を推定します。
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;
}
最大値が1900以上なら「年」、12以下なら「月」、31以下なら「日」です。単純ですが、就活フォームで見かけるほぼすべてのパターンに対応できています。
選択は selectNumber が担います。'5'・'05'・'5月'・'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}`
]);
}
React / Vue フォームへの対応
単純に el.value = "..." と書くだけでは React や Vue が「変更を検知しない」問題があります。これらのフレームワークは仮想 DOM で value を管理しているため、直接 DOM を書き換えても state が更新されず、送信時に空欄として扱われることがあります。
filler.js の setNativeValue はこの問題を回避しています。
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 }));
}
ポイントは Object.getOwnPropertyDescriptor でネイティブの setter を引き出してから呼ぶ部分です。React は HTMLInputElement.prototype の value setter を上書きする形で変更検知を仕掛けているため、インスタンスではなくプロトタイプから setter を引き出して呼ぶことでその検知をくぐり抜けられます。加えて input と change イベントを発火することで、Vue の v-model も追随します。
電話・郵便番号の分割ボックス対応
電話番号が「090 | 1717 | 0135」のように3つに分かれている、郵便番号が「171 | 0031」に分かれているパターンも多いです。
fillSplitNumber がこれを処理します。桁数から定番パターンで分割し、各ボックスに流し込みます。
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]; // 携帯 090-XXXX-XXXX
else if (digits.length === 10)
pattern = digits.startsWith("0") ? [2, 4, 4] : [3, 3, 4]; // 固定 03-XXXX-XXXX
}
}
// maxlength が信頼できるときはそれを使う
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;
}
カナ変換:全角・ひらがな・半角カナを一括対応
フリガナ欄はサイトによって「全角カタカナで入力」「ひらがなで入力」「半角カナで入力」と要求がバラバラです。プロフィールには全角カタカナで保存しておき、フィールドの文脈ヒントに応じて変換します。
function adaptKana(value, hint) {
if (/半角|ハンカク|hankaku|半角カナ/.test(hint)) return fullToHalfKana(value);
if (/ひらがな|ふりがな|hiragana/.test(hint)) return kataToHira(value);
return value; // 既定は全角カタカナ
}
buildContext が「半角カナで」「ひらがなで入力」などのラベルテキストをまとめて収集しているので、ここでそのヒントを使います。
ボタン表示の判定:エントリーフォームかどうかを判別する
コンテントスクリプトは <all_urls> に挿入されます。求人検索ページや企業トップページにまで「自動入力」ボタンを出すのは邪魔なので、「本物のエントリーフォームかどうか」を判定してからボタンを表示します。
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;
}
「プロフィール項目に分類できる欄が2件以上ある」という基準です。求人絞り込みページはチェックボックスが並ぶだけで、氏名・住所・メールには分類されません。エントリーフォームならこれらが容易に2件以上見つかるので、閾値2で十分弁別できています。
SPA(ページ遷移なしで一覧↔フォームを切り替えるタイプの採用システム)では、DOM 変化を MutationObserver で監視してボタンを動的に出し入れします。
let evalTimer = null;
const scheduleEval = () => {
clearTimeout(evalTimer);
evalTimer = setTimeout(evaluateButton, 400);
};
new MutationObserver(scheduleEval)
.observe(document.body, { childList: true, subtree: true });
debounce を入れているのは、ボタン自身の追加でも MutationObserver が発火するからです。
テスト:jsdom で全ロジックをブラウザなしに検証
コードを書くにあたって一番気にしたのは「ブラウザを起動しないとテストできない」状況を避けることでした。Chrome 拡張のコンテントスクリプトはブラウザ上で動くものですが、ロジックを純粋関数に切り出すことで Node.js 上でテストできるようにしています。
テストは4ファイルに分かれています。
matcher.test.js(jsdom 不要):classifyField の単体テスト。実フォームで踏んだ誤分類をそのまま回帰テストとして追加しています。
const cases = [
["氏名", "fullName"],
["お名前", "fullName"],
["姓", "lastName"],
["名", "firstName"],
// ... 実フォームで踏んだ罠の回帰テスト
["現住所市区郡・地名", "city"], // 「地名」を名(first)と誤認しない
["学校名の頭文字", null], // 頭文字欄はスキップ
["会社名", null], // 複合語の「名」を名(first)にしない
["氏名 姓", "lastName"], // 行の「氏名」で潰されず姓と判定
["氏名 名", "firstName"], // 同上、名と判定
["海外在住の方はこちら", null], // 海外専用欄はスキップ
// ...
];
e2e.test.js:sample-form.html(table/div 両レイアウトを含む)に jsdom を使って content.js を実行し、各フィールドが正しく埋まるかを検証します。
e2r.test.js:e2r 系フォームの回帰テスト。「国内用の分割ボックス」と「海外用の単独ボックス」が同一行に並ぶ特殊レイアウトで、海外ボックスが空のまま、国内ボックスだけ正しく埋まることを確認します。
button-gate.test.js:looksLikeForm の判定テスト。求人検索ページでボタンを出さない、エントリーフォームでは出す、の2ケースを確認します。
現時点でのテスト結果は以下の通りです。
matcher.test.js : 63/63 全通過
e2e.test.js : 34/34 全通過
e2r.test.js : 20/20 全通過
button-gate.test.js: 3/3 全通過
合計: 120/120 全通過
踏んだ落とし穴
1. 例(姓:マツシタ 名:タロウ) が分類を汚染する
ラベルに隣接する「入力例」テキストが buildContext に混入すると、「姓」の欄でも「名」という文字列が出てきて誤分類します。stripExample で除去するようにしてから解消しました。
2. 「会社名」「学校名」の「名」が first name に引っかかる
名 という文字が含まれると firstName に分類されてしまう問題です。複合語リスト COMPOUND を作り、「品名・件名・大学名・会社名・学校名」などは除外するようにしました。
3. 「氏名 姓」という行ラベル+列ヘッダーの複合パターン
table レイアウトで行ヘッダーに「氏名」、列ヘッダーに「姓」が来るケースがありました。buildContext が両方を拾うので文脈は「氏名 姓」になります。この場合 hasFull(氏名あり)と isLast(姓あり)が両方 true になります。先に isLast を判定すれば lastName に落ちるため、判定の順序で解決しました。
4. e2r 系の「国内ボックス」と「海外ボックス」の混在
一部の採用システム(e2r)は「国内用の小さな分割ボックス3つ」と「海外用の単独大ボックス1つ」を同じ行に並べます。ナイーブに「同じ行の phone 欄」をまとめると海外ボックスに全桁が入ってしまいます。splitCluster で maxlength が小さい(1〜6桁)ボックスだけを分割クラスタとして取り出し、大きな maxlength のボックスを除外することで解決しました。
5. メールアドレスを2度ご記入ください を「メール2」と誤認
「2度」という表現の「2」がメール序数の「2」と混同されました。[22][\s ]*(度|回|目|通) というパターンを先に除去してから序数チェックするようにしました。
6. React フォームで value が送信時に空になる
el.value = "..." で見た目は埋まっても、React が state を更新していないため submit 時に空になる問題です。setNativeValue で prototype の setter 経由で書き込み+イベント発火することで対応しました。
本番フォームでの動作について
正直に書くと、本番の実際の採用フォームでの網羅的な検証はまだできていません。
jsdom ベースのテストはロジックの正しさを確認するものであり、「実際の採用システムで正しく入力できるか」は別の問題です。フォームの HTML 構造・イベントリスナーの実装・SPA のフレームワークはサービスごとに異なります。sample-form.html と sample-e2r.html は典型的なパターンをカバーするよう作りましたが、すべてのケースを網羅しているわけではありません。
使う際は必ず内容を確認してから送信してください。拡張が入力後、「内容を必ず確認して」というトースト通知を出すようにしていますが、責任は利用者側にあります。
まとめ
-
MV3 +
"permissions": ["storage"]のみ:外部との通信なし、プロフィールはブラウザのローカルに保存 -
buildContext:<table>/<div>/<dl>を横断してラベルを収集。祖先5階層まで遡る -
classifyField:純粋関数。正規表現の順序が分類の優先度になる。stripExampleで例示テキストを先に除去 -
detectDateRole:option の値レンジから年・月・日を推定。3分割 select に対応 -
setNativeValue:prototype の setter 経由 + イベント発火で React/Vue にも追随 -
splitCluster:maxlength で国内用と海外用ボックスを区別 - テスト:jsdom を使って Node.js 上で 120 本のテストをブラウザなしに実行
- 本番フォームでの完全検証は未実施:使う際は内容確認必須
就活フォームのバリエーションは本当に多く、このコードが想定外のレイアウトで誤動作することは十分あり得ます。content.js の DEBUG = true にすると console.log で未分類フィールドの文脈が出るので、問題があれば classifyField に条件を足す形でパッチを当てています。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 作ったアプリは ポートフォリオ にまとめています📱
- 新着・開発の裏側は X @bokuwalily で発信しています🌍
- OSS: github.com/bokuwalily 🐙
- この仕組みで「作業」じゃなく「環境」を回して月120万に戻した話は noteに無料で 書いています
皆さんの ❤️ やシェアが励みになります!