保存したプロフィールでフォームを埋める Chrome 拡張(FormCarbon)を、Claude Code に作らせました。コードはほとんど Claude Code が書いています。私は仕様と方針を決め、できたものを検証しました。この記事は、できあがったコードを読んで、日本のフォームの扱いを解説したものです。ただし拡張そのものは AI を呼ばず、辞書と並びのルールだけで欄の種類を決めています。
できあがったコードを読んで分かったのは、日本のフォームは「欄の見分け」より「値の渡し方」のほうで手間がかかる、ということでした。この記事では、分かれた欄の見分け方、ハイフンの扱い、React のフォームへの書き込み方を、実際のコードの抜粋と一緒に書きます。
あらかじめ書いておくと、確かめたのは手元で用意したテスト用のフォームです。実際のサイトは形が多様なので、「これで全部入る」という話ではありません。
方針:確信が高いものだけ入れる
欄の判定の結果には確信度を付けています。
- 0.75 以上:自動で入れる候補にする
- 0.45 以上:候補として出すが、チェックは外しておく
- それ未満:入れない
入れる前にプレビューを出し、[入力]を押すまで DOM には何も書きません。入れたものは 1 回分だけ元に戻せます。以降の話は、この方針の上で「どう見分けて、どう渡すか」です。
1. 欄の種類をどう決めるか
手がかりは次の順で見ています。
- HTML の
autocomplete(あれば確信 0.95。ただしラベルが強く食い違えば候補止まり) - 辞書:ラベル、aria-label、name / id、placeholder、近くの文字、title
- 並びのルール:隣の欄との関係で、「何分割の何番目か」を決める
辞書では、手がかりごとに重みを変えています。ラベルを一番強くしました。
// 手がかりの供給源と重み
export function sourcesOf(d: FieldDescriptor): Source[] {
const out: Source[] = [];
for (const l of d.labels) out.push({ src: 'label', w: 1, text: foldText(l) });
if (d.ariaLabel) out.push({ src: 'aria', w: 0.9, text: foldText(d.ariaLabel) });
// name / id は 0.8、placeholder は 0.7、近くの文字は 0.6、title は 0.5
return out;
}
姓・名とふりがな
日本語のラベルは、まず NFKC で正規化してから辞書に当てます。全角の括弧や英数字が半角に揃うので、書き方の揺れがだいぶ減ります。
難しいのは 1 文字の「名」です。「会社名」「部署名」「建物名」の「名」は氏名ではありません。そこで、「括弧や区切りの直後か先頭にあるときだけ」名として拾います。
const L = '(?:^|[()\\s・/「」])'; // 括弧・区切りの前後(NFKC 後)
const R = '(?:[()\\s・/「」]|$)';
['name.given', new RegExp(`${L}名${R}|^名$|下の名前|ファーストネーム`)],
ふりがなの欄は、「フリガナ」と「姓」「名」の組み合わせで familyKana / givenKana に振ります。「フリガナ」だけなら、氏名全体の読みの欄です。
もう 1 つ、読みの欄が「ひらがな」を求めるのか「カタカナ」を求めるのかも見ます。カタカナで保存した読みを、ひらがなの欄にはひらがなに直して入れます。「カナ不可」「ひらがな禁止」のような拒否の言い回しは、別の正規表現で拾います。
const READING = /フリガナ|ふりがな|よみがな|ヨミガナ|読み仮名|カタカナ|ひらがな|カナ(?!ダ)/; // カナダは読みではない
export const toHiragana = (s: string): string =>
s.replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
カナ(?!ダ) は、国の選択肢の「カナダ」を読みの欄と取り違えないための除外です。こういう例外が、辞書の中にいくつも入っています。
郵便番号 3+4、電話 3 分割
分かれた欄は、同じ種類の欄が並んでいたら 1 つの組にまとめます。組の各欄に「n 個中 i 番目、各欄の長さ」を持たせるのです。
組にする手がかりは次の 3 つです。
- 欄と欄の間の文字が、ハイフン・括弧・空白だけか(見た目の区切りがハイフンなら、1 つの番号の部品とみなす)
- 欄の長さ(
maxlength)が短いか -
autocompleteのtel-area-codeやtel-local-prefixなどがあるか
組が決まったら、値を配ります。保存してある 03-1234-5678 を数字の塊に分け、欄の数と各欄の長さに合うときだけ渡します。
let groups: string[] = [...(v.match(/\d+/g) ?? [])]; // 03-1234-5678 → ['03','1234','5678']
const fits = groups.length === part.n
&& groups.every((g, k) => part.lens[k] === null || g.length <= (part.lens[k] as number));
if (fits) return groups[part.i];
// 区切りのない 1 つの数字列:各欄の maxlength の合計と桁数が一致するときだけ、桁で切る
const lens = part.lens as number[];
if (digits.length === lens.reduce((x, y) => x + y, 0)) {
const start = lens.slice(0, part.i).reduce((x, y) => x + y, 0);
return digits.slice(start, start + lens[part.i]);
}
切れないときは、無理に入れず null を返します。区切りがなくて市外局番の長さが分からないとき、桁数が合わないとき、英字が混ざっているときなどです。
2. ハイフンの有無
保存するプロフィールの値は 1 つ(たとえば郵便番号 100-0001)です。欄ごとに書き方が違うので、書く直前に整えます。
- 値が欄の
patternに合うか調べる - 合わなければ、数字だけにしたもの、郵便番号なら
123-4567の形、の順に試す - それでも
patternやmaxlengthに合わないなら、入れない
function reformats(type: FieldType | null, v: string): string[] {
if (type === 'tel' || type?.startsWith('tel.') || type === 'addr.postal') {
const digits = v.normalize('NFKC').replace(/\D/g, ''); // 全角数字も半角にして数字だけ
const out = [digits];
if (type === 'addr.postal' && digits.length === 7) out.push(`${digits.slice(0, 3)}-${digits.slice(3)}`);
return out.filter((x) => x && x !== v);
}
return [];
}
大事にしたのは、値を切り詰めないことです。maxlength が 7 の郵便番号欄にハイフン付きの 8 文字を黙って切って入れると、123-456 という壊れた値になります。入らないなら入れず、プレビューにその理由を出します。
3. React・Vue のフォームに値を入れる
el.value = '...' と代入するだけでは、React のフォームは値が変わったと受け取らないことがあります。React は要素の value を自前でラップして「前の値」を覚えているので、同じ値の代入とみなされ、onChange が呼ばれません。
そこで、要素に仕込まれたラッパーを避けて、プロトタイプにある元の setter を呼びます。そのあとで input と change を出します。
function setProp(el: Element, prop: 'value' | 'checked' | 'selectedIndex', v: unknown): void {
const proto = el instanceof HTMLSelectElement ? HTMLSelectElement.prototype
: el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const set = Object.getOwnPropertyDescriptor(proto, prop)?.set;
if (set) set.call(el, v);
else (el as unknown as Record<string, unknown>)[prop] = v;
}
function fire(el: Element): void {
el.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertReplacementText' }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
細かい点が 3 つあります。
- 書く前にフォーカスを当てます。blur で検証するフォームや、Vue の
v-model.lazyを動かすためです - チェックボックスとラジオは、
checkedへの代入ではなくclick()で切り替えます。ブラウザ自身が input / change を出すので、フレームワークが正しく反応します -
onchange="this.form.submit()"のように、書き込むだけで送信されるフォームがあります。そういう欄は書きません
4. 権限は activeTab だけにした
常駐するスクリプトは持たせず、ツールバーのボタンを押したタブにだけ content script を注入しています。権限は activeTab・scripting・storage で足りました(「開いたら自動入力」だけは、サイトごとに利用者がオンにしたときに、そのサイトの権限をあとから求めます)。フォームの内容やプロフィールは外に出ません。
5. 手間がかかったところ
ボット除けの見えない欄。 honeypot や「空欄のまま」と書かれた欄に値を入れると、ボットと判定されて弾かれます。名前とラベルの語で見分けるのに加えて、画面外にあったり透明だったりして見えない欄には入れません。
止めすぎ。 誤入力を 0 にしたくて慎重にしたら、前は入っていた欄が入らなくなりました。そこで方針を、「止めるのは確かに違うと分かるときだけ。分からないときは前のとおり(または候補)」に直しました。たとえば国の一覧が読めないときは、住所を止めません。
ブラジルの電話。 「DDD(市外局番)+番号」で、1 欄・2 欄・3 欄とばらばらでした。「DDD」という語自体が、番号まるごとを入れてはいけない合図でもあります。長さ、並び、fieldset、言い回しを組み合わせないと決まらず、直すたびに別の見本が壊れました。見本のフォームがないと直せませんでした。
海外のフォームは Claude Code がとても役に立った
日本のフォームは見慣れていますが、海外のフォームは勝手が違います。辞書は 9 言語ぶん必要で、国の選択肢は国旗つき・連番の value・括弧のコード付きなど形がばらばらですし、ブラジルの電話のような国ごとの決まりもあります。
この洗い出しと辞書づくり、見本のフォームの用意、直したときに別の見本が壊れていないかの確認は、ほとんど Claude Code に任せました。9 言語ぶんの辞書を私が一人で作るのは難しく、とても助けになりました。判断の方針(「確かに違うと分かるときだけ止める」)を決めたのは私で、コードを書いたのは Claude Code、という分け方です。
確かめ方
テスト用のフォームを 53 本用意し、本物の Chromium に拡張を読み込んで、期待どおりの欄に期待どおりの値が入るかを毎回測っています。見ている数字は次のとおりです。
| 指標 | 基準 |
|---|---|
| 誤入力 | 0 |
| 型違いの入力 | 0 |
| 送信・遷移 | 0 |
| プレビューで書いた数 | 0 |
ただし、見本がすべて通ることと、実際のフォームで通ることは別です。見本を CSP なしで配っていて、本番の CSP では動かなかった、ということもありました(今はテストにも本番と同じ CSP を付けています)。分からないものは候補にとどめて、入れたものは元に戻せるようにしています。
おわりに
作った拡張は FormCarbon といいます(製品のページ: https://quillfold.dev/formcarbon 、ウェブストア: https://chromewebstore.google.com/detail/aigkaeiocpphbdkladampolcbhboifaj )。この記事は、日本語のフォームの扱いで困った点の記録です。エンジニアの一人として、同じ問題に当たっている方の参考になれば幸いです。