0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

就活フォーム自動入力のChrome拡張をローカル完結で作る

0
Last updated at Posted at 2026-06-26

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"] だけです。activeTabscripting も要りません。コンテントスクリプトとして全 URL にインジェクトするため、content_scriptsmatches<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>value2003 だったり '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.jssetNativeValue はこの問題を回避しています。

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 を引き出して呼ぶことでその検知をくぐり抜けられます。加えて inputchange イベントを発火することで、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.jssample-form.html(table/div 両レイアウトを含む)に jsdom を使って content.js を実行し、各フィールドが正しく埋まるかを検証します。

e2r.test.js:e2r 系フォームの回帰テスト。「国内用の分割ボックス」と「海外用の単独ボックス」が同一行に並ぶ特殊レイアウトで、海外ボックスが空のまま、国内ボックスだけ正しく埋まることを確認します。

button-gate.test.jslooksLikeForm の判定テスト。求人検索ページでボタンを出さない、エントリーフォームでは出す、の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 欄」をまとめると海外ボックスに全桁が入ってしまいます。splitClustermaxlength が小さい(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.jsDEBUG = true にすると console.log で未分類フィールドの文脈が出るので、問題があれば classifyField に条件を足す形でパッチを当てています。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?