環境
| 項目 | バージョン / 内容 |
|---|---|
| ブラウザ | Google Chrome 120(Manifest V3) |
| 実行環境 | Chrome拡張のcontent script(ES2022) |
| 検証スクリプト | Node.js 20.10 + cheerio 1.0 |
| 対象ページ | ASPの案件一覧(サーバーサイドレンダリング、要ログイン) |
| 取得方法 |
fetch したHTMLを DOMParser でパース |
何が起きたか
ASPの案件一覧を自動収集するChrome拡張を作っていました。ページ全体を DOMParser(ブラウザ内蔵のHTML解析API)で読み込み、案件ごとの「リスティング可否」を判定して一覧化する仕組みです。
判定ロジックは、当初こう書いていました。
// NG: alt属性だけで状態を判定していた頃のコード
const doc = new DOMParser().parseFromString(html, 'text/html');
const rows = [...doc.querySelectorAll('.casino-list__item')];
const items = rows.map((row) => {
const icon = row.querySelector('.status-icon');
return {
name: row.querySelector('.casino-list__name')?.textContent.trim(),
listing: icon?.alt === 'リスティングOK' ? 'ok' : 'ng',
};
});
一見問題なさそうです。ところが実際に出力された一覧は、「不可」の案件まで全部 ok になっていました。
原因は単純で、「リスティングOK」というアイコン画像が、実は 許可・条件付き・不可の3状態すべてで同じ alt を持っていた からです。alt が同じなら当然すべて ok に倒れます。テストを書いていなかったので、気づいたのは出力CSVを目視したときでした。
なぜaltは状態判定に使えないのか
alt属性は「画像が表示できないときに代わりに読ませるテキスト」であって、状態コードではありません。3状態を同じ文言で表現していても、アクセシビリティ的にはむしろ自然な設計です(人間にはアイコンの色や形で区別がつくため)。つまり altは人間向けのラベル、状態は別の場所に埋まっている と考えたほうが安全です。
解決策: srcのファイル名を第2のキーにする
DevToolsでimg要素を全件見ていくと、画像ファイル名が状態ごとに分かれていることが分かりました。
| src のファイル名 | alt | 実際の状態 |
|---|---|---|
icon06.png |
リスティングOK | 許可 |
icon07.png |
リスティングOK | 条件付き |
icon06_n.png |
リスティングOK | 不可 |
icon06 / icon07 / 末尾 _n という命名規則です。altだけを見ていたら絶対に区別できません。
そこで、判定を「alt → src」の順ではなく、srcを主キー・altをフォールバック に変更しました。
// OK: srcのファイル名を主キーにした判定
const STATE_BY_FILE = {
icon06: 'ok',
icon07: 'conditional',
icon06_n: 'ng',
};
function normalizeState(img) {
if (!img) return 'unknown';
// srcset や遅延読み込みにも備えて候補URLを全部集める
const raw = img.getAttribute('src')
|| img.getAttribute('data-src')
|| img.getAttribute('srcset')?.split(',')[0]?.trim()
|| '';
// クエリ文字列・ハッシュを落とし、拡張子を除いてベース名を取る
const base = raw
.split(/[?#]/)[0]
.split('/')
.pop()
.replace(/\.[a-z0-9]+$/i, '')
.toLowerCase();
if (STATE_BY_FILE[base]) return STATE_BY_FILE[base];
// 命名規則から外れたら alt にフォールバック(誤判定より unknown を優先)
const alt = (img.getAttribute('alt') || '').trim();
if (alt === 'リスティングOK') return 'unknown'; // 3状態を区別できないので確定させない
return 'ng';
}
ポイントは、altで確定できなかったときに ok へ倒さない ことです。unknown を返して後段で弾く設計にしないと、今回と同じ事故が再発します。
鵜呑みにしない: 保存HTML全件との突き合わせ
srcの命名規則も、いつ差し替えられるか分かりません。そこで採用前に、保存したHTML全件を使って検証しました。
// verify.mjs — 保存済みHTMLと突き合わせる検証スクリプト
import fs from 'node:fs';
import * as cheerio from 'cheerio';
const files = fs.readdirSync('./snapshots').filter((f) => f.endsWith('.html'));
let checked = 0;
const mismatches = [];
for (const file of files) {
const $ = cheerio.load(fs.readFileSync(`./snapshots/${file}`, 'utf8'));
$('.casino-list__item').each((_, el) => {
const name = $(el).find('.casino-list__name').text().trim();
const src = $(el).find('.status-icon').attr('src') || '';
const base = src.split('/').pop().replace(/\.[a-z0-9]+$/i, '');
// 本文中の「出稿NG」表記を正解データとして使う
const bodyText = $(el).text();
const expected = bodyText.includes('出稿NG') ? 'ng' : null;
if (expected) {
checked++;
if (base !== 'icon06_n') {
mismatches.push({ file, name, src, expected });
}
}
});
}
console.log(`検証件数: ${checked} / 不一致: ${mismatches.length}`);
console.table(mismatches);
結果は 不一致0件。これで初めて「srcの命名規則で判定する」を採用しました。逆に言えば、この突き合わせをやっていなければ、icon06 が icon06_n にリネームされた日に静かに壊れていたはずです。
ハマりポイント
-
srcsetと遅延読み込み: 高DPI用にsrcsetが併記され、srcにはプレースホルダーが入っていることがあります。src→data-src→srcsetの順でフォールバックを用意しましょう。 -
クエリ文字列:
icon06.png?v=2のようなキャッシュバスターが付きます。split(/[?#]/)[0]を忘れると辞書引きが全滅します。 -
相対URLの多様さ:
/img/icon06.png、../img/icon06.pngなど。ファイル名だけ取り出せば吸収できます。 -
大文字小文字と連番の飛び:
ICON06.PNGのような表記ゆれに備えてtoLowerCase()。連番はicon06が欠番になる可能性もあるため、辞書にない値はunknownに落とすのが安全です。 -
拡張機能側の制約: Manifest V3ではリモートコードの実行(
evalやnew Function)がCSPで禁止されます。DOMParserはブラウザAPIなので問題ありませんが、パース処理を外部スクリプトから動的注入する設計は避けましょう。 -
アイコン差し替えリスク: 画像ファイル名はデザイン変更で一番壊れやすい箇所です。検証スクリプトをCIに置き、スナップショットを定期取得して
unknownの増加をアラートにしておくと安心です。
判定方法の比較
| 判定方法 | 3状態の区別 | 耐障害性 | 実装コスト | 向いているケース |
|---|---|---|---|---|
| alt属性のみ | ×(今回の事故) | 低 | 低 | 状態が1種類しかない画面 |
| srcのファイル名 | ○ | 中(リネームで壊れる) | 低 | 命名規則が安定しているサイト |
| 本文テキスト | ○ | 中〜高(文言変更で壊れる) | 中 | 「出稿NG」等の明示表記がある場合 |
| src + 本文の突き合わせ | ○ | 高 | 中 | 今回の採用案。検証も同時にできる |
導入時の手順(チェックリスト)
- 対象ページのHTMLを数ページ分保存する(状態が異なる案件を含めること)
-
img要素を全件抽出し、alt×srcの組み合わせを集計する - 出現頻度表を作り、状態ごとにファイル名が分かれていないか確認する
- 本文中の明示表記(「出稿NG」など)を正解データとして突き合わせる
-
normalizeState()のユニットテストを書く(icon06/icon07/icon06_n/ 未知の値) - スナップショット取得を定期実行し、
unknownの件数を監視する
FAQ
Q. altとsrc、判定の初手はどちらにしますか?
A. srcを先に見ます。altは人間向けラベルで状態と1対1になる保証がなく、実際に3状態が同じaltでした。srcで確定できない場合のみaltにフォールバックし、それでも曖昧なら unknown にして弾くのが安全です。
Q. unknown が増えたらどうする?
A. 画像のリネームかDOM構造の変更を疑い、保存HTMLで差分を確認します。判定ロジックを直すのではなく、まず原因を特定する順序を守ると二次被害を防げます。
Q. 画像認識やOCRで判定するのはあり?
A. 最終手段としてはありですが、運用コストが高く、判定根拠の説明もしづらくなります。まずは命名規則・本文テキスト・class名といった構造化された情報を探すべきです。
Q. 同じaltに複数状態が割り当てられているのは仕様違反では?
A. 多くの場合違反ではありません。altは画像の代替テキストであり、色や形で区別できる人間には同一文言で十分だからです。スクレイピング側が「altは状態コード」という前提を置いていたのが誤りでした。
まとめ
- alt属性は状態判定の主キーにしない。人間向けラベルと状態コードは別物
- 同じaltで複数状態があるなら、srcのファイル名など別の構造化情報を探す
- 判定を決める前に、保存HTML全件と本文表記を突き合わせてずれ0件を確認する
- 判定不能時は握りつぶさず
unknownを返し、監視でリネームに気づけるようにする
似た画面を触るときは、altで判定する前にsrcの命名規則を先に見る。これが今回いちばん効いた教訓でした。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中