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

日本のフォームの「姓・名」「郵便番号 3+4」「電話 3 分割」を、Chrome 拡張でどう埋めたか

1
Posted at

保存したプロフィールでフォームを埋める Chrome 拡張(FormCarbon)を、Claude Code に作らせました。コードはほとんど Claude Code が書いています。私は仕様と方針を決め、できたものを検証しました。この記事は、できあがったコードを読んで、日本のフォームの扱いを解説したものです。ただし拡張そのものは AI を呼ばず、辞書と並びのルールだけで欄の種類を決めています。

できあがったコードを読んで分かったのは、日本のフォームは「欄の見分け」より「値の渡し方」のほうで手間がかかる、ということでした。この記事では、分かれた欄の見分け方、ハイフンの扱い、React のフォームへの書き込み方を、実際のコードの抜粋と一緒に書きます。

あらかじめ書いておくと、確かめたのは手元で用意したテスト用のフォームです。実際のサイトは形が多様なので、「これで全部入る」という話ではありません。

方針:確信が高いものだけ入れる

欄の判定の結果には確信度を付けています。

  • 0.75 以上:自動で入れる候補にする
  • 0.45 以上:候補として出すが、チェックは外しておく
  • それ未満:入れない

入れる前にプレビューを出し、[入力]を押すまで DOM には何も書きません。入れたものは 1 回分だけ元に戻せます。以降の話は、この方針の上で「どう見分けて、どう渡すか」です。

1. 欄の種類をどう決めるか

手がかりは次の順で見ています。

  1. HTML の autocomplete(あれば確信 0.95。ただしラベルが強く食い違えば候補止まり)
  2. 辞書:ラベル、aria-label、name / id、placeholder、近くの文字、title
  3. 並びのルール:隣の欄との関係で、「何分割の何番目か」を決める

辞書では、手がかりごとに重みを変えています。ラベルを一番強くしました。

// 手がかりの供給源と重み
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)です。欄ごとに書き方が違うので、書く直前に整えます。

  1. 値が欄の pattern に合うか調べる
  2. 合わなければ、数字だけにしたもの、郵便番号なら 123-4567 の形、の順に試す
  3. それでも 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 )。この記事は、日本語のフォームの扱いで困った点の記録です。エンジニアの一人として、同じ問題に当たっている方の参考になれば幸いです。

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