個人開発で運用しているサイトに、Gemini API のアクセシビリティ監査を初めて走らせた夜のことを、今でもよく覚えております。
返ってきた JSON には、37 件の違反が並んでいました。胸が高鳴りました。ところが一件ずつブラウザで確かめていくと、コントラスト比 3.1:1 と断定された箇所は実測 7.2:1 で、まったく問題がありませんでした。逆に、フォーカスリングが背景に完全に溶けている本物の欠陥は、レポートのどこにも書かれていませんでした。
AI は画面を「見て」いるようで、ピクセルの色を測ってはいなかったのです。
この記事は、その失敗から組み直した監査パイプラインの記録です。要点は一つ。計算で確定する項目を、言語モデルに推測させないこと 。この線引きを守るだけで、監査結果は「参考情報」から「CI で落とせる指標」に変わります。
AI に測らせてはいけないもの、任せるべきもの
WCAG の達成基準は、性質の異なる二種類に分かれます。数式で答えが一意に決まるものと、文脈を読まないと判断できないものです。
前者を言語モデルに投げると、もっともらしい数字が返ってきます。しかしそれは測定値ではなく、生成された数字です。一方、後者はルールベースのツールが最も苦手とする領域で、ここにこそ Gemini の価値があります。
レイヤー 担当 対象基準の例 判定の根拠
決定論レイヤー
Playwright + 計算コード
1.4.3 コントラスト比 / 2.5.8 ターゲットサイズ / 1.1.1 alt 属性の有無 / 3.1.1 lang
computed style と矩形サイズから数式で確定
判断レイヤー
Gemini API
1.1.1 alt テキストの妥当性 / 2.4.4 リンクテキストの明確さ / 1.3.1 見出しの意味的階層 / 1.4.1 色以外の手がかり
文脈・意味の理解が必要で、数式では表せない
参考レイヤー
Gemini(スクリーンショット)
視覚的な違和感の指摘、レイアウト崩れの疑い
人間のレビューを呼び出すためのフラグ としてのみ扱う
同じ「1.1.1 非テキストコンテンツ」でも、alt が存在するかどうかは DOM を見れば一行で確定します。しかし alt="image1" が代替テキストとして役に立っているかは、周囲の文脈を読まなければ判断できません。前者を Gemini に、後者を正規表現に任せてしまうのが、最もよくある設計ミスです。
私が最初に踏んだのも、まさにこの落とし穴でした。本番運用に持ち込む前に気づけたのは、単に運が良かっただけだと思っております。
WCAG 2.2 と、自動化できる範囲の現実
WCAG 2.2 の達成基準は 80 を超えます。その全てを機械が判定できるわけではありません。四つの原則ごとに、自動化の相性は大きく違います。
原則 代表的な基準 決定論で確定 AI の寄与
知覚可能 コントラスト、テキスト代替 高い 代替テキストの質の評価
操作可能 キーボード操作、ターゲットサイズ 中程度 フォーカス順序の妥当性
理解可能 読みやすさ、入力支援 低い エラーメッセージの具体性
堅牢 マークアップの適合性 高い ほとんど不要
ルールベースのツールが検出できるのは、達成基準全体のおよそ 30〜40% と言われています。残りが人間の判断領域です。
Gemini を投入する狙いは、この「残り」の一部を機械可読な指摘に変えることであって、既に axe が正確に測れている領域を置き換えることではありません。ここを取り違えると、正しかった検査結果まで確率的になってしまいます。
環境構築
必要なのは、ブラウザ制御と Gemini SDK、そして構造化出力を検証するための Pydantic です。
# pip install google-genai playwright pydantic
# playwright install chromium
from __future__ import annotations
import json
import hashlib
from typing import Literal
import google.genai as genai
from google.genai import types
from playwright.sync_api import sync_playwright
from pydantic import BaseModel, Field
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
API キーは環境変数から読む前提です。監査対象のページがログインを要する場合は、Playwright の storage_state に認証済みセッションを保存しておくと、CI からでも同じ画面を再現できます。
決定論レイヤー — コントラスト比は計算で出す
コントラスト比は WCAG 2.x が式を定義しています。相対輝度 L を求め、明るい側と暗い側で (L1 + 0.05) / (L2 + 0.05) を計算するだけです。推測の余地はありません。
問題は「背景色をどう取るか」です。getComputedStyle が返す background-color は多くの場合 rgba(0, 0, 0, 0)、つまり透明を返します。祖先を遡って、最初に不透明な背景に行き当たるまで探索する必要があります。
CONTRAST_PROBE = """
() => {
const srgb = (c) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
const luminance = ([r, g, b]) =>
0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
const parse = (s) => (s.match(/[ \\ d.]+/g) || []).map(Number);
// 透明な背景は祖先へ遡って解決する(ここを省くと全て白扱いになる)
const effectiveBg = (el) => {
let node = el;
while (node && node !== document.documentElement) {
const c = parse(getComputedStyle(node).backgroundColor);
if (c.length >= 3 && (c[3] === undefined || c[3] > 0)) return c.slice(0, 3);
node = node.parentElement;
}
return [255, 255, 255];
};
const ratio = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const out = [];
for (const el of document.querySelectorAll('p, a, span, li, button, h1, h2, h3, h4, label')) {
const text = (el.innerText || '').trim();
if (!text) continue;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
const size = parseFloat(cs.fontSize);
const bold = parseInt(cs.fontWeight, 10) >= 700;
// 大きなテキストの定義: 24px 以上、または 18.66px 以上かつ太字
const isLarge = size >= 24 || (size >= 18.66 && bold);
const required = isLarge ? 3.0 : 4.5;
const r = ratio(parse(cs.color).slice(0, 3), effectiveBg(el));
if (r < required) {
out.push({
selector: el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''),
text: text.slice(0, 60),
ratio: Math.round(r * 100) / 100,
required,
font_px: size,
});
}
}
return out;
}
"""
def measure_contrast (url: str ) -> list[ dict ]:
"""コントラスト比を実測する。AI は一切介在しない。"""
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page( viewport = { "width" : 1280 , "height" : 720 })
page.goto(url, wait_until = "networkidle" )
violations = page.evaluate( CONTRAST_PROBE )
browser.close()
return violations
ここで返る ratio は測定値であり、何度実行しても同じ数字になります。冒頭で私を惑わせた「3.1:1」のような幻の数値は、もう出てきません。
同じ発想でターゲットサイズも確定できます。WCAG 2.2 の 2.5.8 は最小 24×24 CSS ピクセルを求めているので、getBoundingClientRect() の幅と高さを見れば済みます。
TARGET_SIZE_PROBE = """
() => {
const MIN = 24;
const out = [];
for (const el of document.querySelectorAll('a, button, input, select, [role="button"]')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue; // 非表示要素は対象外
if (r.width < MIN || r.height < MIN) {
out.push({
selector: el.tagName.toLowerCase(),
label: (el.innerText || el.getAttribute('aria-label') || '').slice(0, 40),
width: Math.round(r.width),
height: Math.round(r.height),
});
}
}
return out;
}
"""
決定論レイヤーの出力は、そのまま CI の失格条件に使えます。数字が閾値を割ったかどうか、それだけの話だからです。
私はこの二つのプローブを、監査パイプラインの最初に置くことを推奨します。ここが確定していれば、後段の指摘が疑わしくても、判断の足場は崩れません。
判断レイヤー — Gemini に「意味」を問う
Gemini に渡すのは、計算では答えの出ない問いだけです。alt が存在することは Python 側で確認済みなので、聞くべきは「その alt は代替として機能しているか」になります。
そして、出力形式は自然言語の指示ではなく response_schema で拘束します。プロンプトに「JSON で返して」と書くだけでは、実行のたびにキー名や配列の入れ子が揺れます。監査結果が揺れると、後述するベースライン差分が機能しません。
class Violation ( BaseModel ):
criterion: str = Field( description = "WCAG 達成基準番号(例: 1.1.1)" )
severity: Literal[ "critical" , "major" , "minor" ]
selector: str = Field( description = "問題箇所を特定できる CSS セレクタ" )
excerpt: str = Field( description = "該当要素のマークアップ抜粋(120文字以内)" )
reason: str = Field( description = "なぜ達成基準を満たさないのか" )
fix: str = Field( description = "具体的な修正案。可能ならコード片で" )
class SemanticAudit ( BaseModel ):
violations: list[Violation]
uncertain: list[ str ] = Field( description = "人間の確認が必要と判断した箇所" )
SEMANTIC_PROMPT = """あなたは WCAG 2.2 の監査担当者です。
以下の HTML について、**文脈を読まなければ判断できない項目だけ** を検査してください。
検査する項目:
- 2.4.4 リンクの目的: 「こちら」「詳細」など、リンク先が推測できないテキスト
- 1.1.1 代替テキストの妥当性: alt が空でないが、内容を説明していないもの
(例: alt="image1", alt="写真")
- 1.3.1 情報と関係性: 見出しに見えるが h1-h6 でない要素、表に見えるが table でない要素
- 3.3.2 ラベル: フォーム項目のラベルが操作内容を説明していないもの
- 1.4.1 色の使用: 「赤い項目は必須です」のように色だけで意味を伝える記述
検査しない項目(別レイヤーで実測済み):
- コントラスト比、要素の寸法、alt 属性の有無、lang 属性の有無
確信が持てない箇所は violations ではなく uncertain に入れてください。
推測で違反を作らないでください。
HTML:
{html}
"""
def audit_semantics (clean_html: str ) -> SemanticAudit:
response = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = SEMANTIC_PROMPT .format( html = clean_html),
config = types.GenerateContentConfig(
temperature = 0.0 , # 監査は創造性を必要としない
seed = 42 , # 再実行時の揺れをさらに抑える
response_mime_type = "application/json" ,
response_schema = SemanticAudit,
),
)
return SemanticAudit.model_validate_json(response.text)
uncertain フィールドを設けたのが、この設計で最も効いた工夫でした。
これがないと、モデルは自信のない推測まで violations に押し込みます。逃げ道を用意した瞬間、偽陽性がはっきり減りました。「分からないと言ってよい」と明示することは、人間相手でも機械相手でも同じように効くのだと感じます。
なお response_schema で列挙型や必須フィールドを固定する際の落とし穴は、Gemini API のレスポンスを Schema 検証で守る — 想定外フォーマットを本番で漏らさない設計 に整理しています。
DOM を切るなら、意味の境界で切る
Gemini に渡す HTML を「先頭から 30,000 文字」で切り落とす実装をよく見かけます。しかし、そのやり方ではフッターやページ後半のフォームが丸ごと監査対象から漏れます。しかも漏れたことに気づけません。
ランドマークごとに分割し、それぞれを独立して監査する方が安全です。
LANDMARKS = [ "header" , "nav" , "main" , "aside" , "footer" , "form" ]
MAX_CHARS = 12000
def split_by_landmark (page) -> list[tuple[ str , str ]]:
"""ランドマーク単位で HTML を分割する。長すぎる main はさらに section で割る。"""
chunks: list[tuple[ str , str ]] = []
for tag in LANDMARKS :
for i, handle in enumerate (page.query_selector_all(tag)):
html = handle.evaluate( "el => el.outerHTML" )
if len (html) <= MAX_CHARS :
chunks.append(( f " { tag } [ { i } ]" , html))
continue
for j, sub in enumerate (handle.query_selector_all( "section, article" )):
sub_html = sub.evaluate( "el => el.outerHTML" )
chunks.append(( f " { tag } [ { i } ]>section[ { j } ]" , sub_html[: MAX_CHARS ]))
return chunks
分割すると API 呼び出し回数は増えます。ただしチャンクが小さいほど指摘の精度は上がり、どのランドマークで違反が出たかも同時に記録できます。監査対象が 1 ページ 5 チャンク程度なら、gemini-2.5-flash で十分に現実的なコストに収まります。
スクリーンショット監査 — 過信しないための位置づけ
視覚的な監査そのものを捨てる必要はありません。捨てるべきは「スクリーンショットから数値を読み取らせる」使い方です。
有効なのは、決定論レイヤーでは表現しづらい状態を撮ること。たとえば、Tab キーを押した直後のフォーカスリングです。
def _focus_clip (page, pad: int = 24 ) -> dict :
"""フォーカス中の要素の周囲だけを切り出す矩形を返す。"""
box = page.evaluate(
"() => { const r = document.activeElement.getBoundingClientRect();"
" return {x : r.x, y: r.y, width: r.width, height: r.height} ; }"
)
return {
"x" : max (box[ "x" ] - pad, 0 ),
"y" : max (box[ "y" ] - pad, 0 ),
"width" : box[ "width" ] + pad * 2 ,
"height" : box[ "height" ] + pad * 2 ,
}
def capture_focus_states (url: str , steps: int = 8 ) -> list[ bytes ]:
"""Tab を押しながらフォーカス状態を連続撮影する。"""
shots: list[ bytes ] = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page( viewport = { "width" : 1280 , "height" : 720 })
page.goto(url, wait_until = "networkidle" )
for _ in range (steps):
page.keyboard.press( "Tab" )
page.wait_for_timeout( 120 ) # 遷移アニメーションの完了を待つ
focused = page.evaluate( "() => document.activeElement?.tagName" )
if not focused or focused == "BODY" :
break
shots.append(page.screenshot( clip = _focus_clip(page)))
browser.close()
return shots
def review_focus_visibility (shots: list[ bytes ]) -> str :
parts = [types.Part.from_bytes( data = s, mime_type = "image/png" ) for s in shots]
parts.append(
"各画像は Tab 操作で移動したフォーカス位置の拡大図です。"
"フォーカスされた要素の輪郭が背景から視覚的に判別できるかを、"
"画像ごとに『判別できる/判別しにくい/判別できない』の三択で答え、"
"理由を一文で添えてください。コントラスト比の数値は推定しないでください。"
)
response = client.models.generate_content(
model = "gemini-2.5-pro" ,
contents = parts,
config = types.GenerateContentConfig( temperature = 0.0 ),
)
return response.text
プロンプトの最後の一文が要です。数値を推定させないと明示すると、モデルは三択の判定に集中します。
そして、この出力は違反として扱いません。人間のレビューを呼び出すフラグとして扱います。ここを混ぜないことが、レポートの信頼性を保つ最後の砦になります。
Playwright を軸にした AI 主導のテスト設計そのものについては、Gemini API × Playwright — AI駆動E2Eテスト自動生成システムの構築ガイド でも触れています。
誤検出を抑える — ベースライン差分と抑制リスト
監査を自動化した途端、既存サイトでは数十件の指摘が一度に出ます。全てを直すまで CI を赤にしておくのは現実的ではありません。かといって全部無視すれば、新しく混入した違反にも気づけません。
そこで、違反ごとに安定した指紋を作り、既知の集合との差分だけを見ます。
def fingerprint (v: dict ) -> str :
"""行番号や本文の揺れに影響されない指紋を作る。"""
key = f " { v[ 'criterion' ] } | { v[ 'selector' ] } | { v.get( 'reason' , '' )[: 40 ] } "
return hashlib.sha256(key.encode( "utf-8" )).hexdigest()[: 16 ]
def diff_against_baseline (current: list[ dict ], baseline_path: str ) -> dict :
try :
with open (baseline_path, encoding = "utf-8" ) as f:
known = set (json.load(f)[ "fingerprints" ])
except FileNotFoundError :
known = set ()
seen = {fingerprint(v): v for v in current}
new_ids = set (seen) - known
fixed_ids = known - set (seen)
return {
"new" : [seen[i] for i in new_ids],
"fixed_count" : len (fixed_ids),
"total" : len (seen),
}
指紋に本文全体ではなく reason の先頭 40 文字だけを混ぜているのは、文言のわずかな揺れで別物と判定されないようにするためです。ここを本文全体にすると、モデルの言い回しが一語変わっただけで「新規違反」が湧きます。
抑制リストも用意します。装飾画像の alt="" のように、意図的な設計を毎回指摘されても意味がありません。
# a11y-suppress.yml
- criterion : "1.1.1"
selector : "img.decorative"
reason : "装飾画像。role=presentation を付与済み"
expires : "2026-12-31" # 期限を切って、恒久的な言い訳にしない
expires を必須にしたのは、抑制が増え続けるのを防ぐためです。期限が切れた項目は監査時に警告として浮かび上がり、放置されたまま忘れ去られることがありません。
抑制リストは、本番運用で最も腐りやすい場所です。期限という単純な仕掛け一つで、その腐敗を回避できます。
CI に載せる — 落とすべき失敗と、落としてはいけない失敗
ゲートの設計を誤ると、チームは監査そのものを無効化します。私は最初、全ての指摘でビルドを落とす設定にして、三日で自分自身がスキップフラグを付け始めました。
失敗の重みを分けます。
種別 CI の挙動 理由
決定論レイヤーの新規違反(critical) ビルドを落とす 測定値であり、議論の余地がない
判断レイヤーの新規違反 PR にコメント、落とさない 確率的な出力に開発を止めさせない
uncertain / 視覚フラグ 週次レポートに集約 人間のレビュー待ち行列として扱う
既知の違反(ベースライン内) 件数のみ記録 減少傾向を可視化する
# .github/workflows/a11y.yml
name : accessibility
on :
pull_request :
paths : [ "app/**" , "components/**" , "styles/**" ]
jobs :
audit :
runs-on : ubuntu-latest
timeout-minutes : 10
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with :
python-version : "3.12"
- run : pip install google-genai playwright pydantic && playwright install --with-deps chromium
- name : Run audit
env :
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
run : python scripts/a11y_audit.py --url "${{ vars.PREVIEW_URL }}" --baseline .a11y/baseline.json
# 終了コード 2 = 決定論レイヤーの新規 critical のみ
# 終了コード 1 = 判断レイヤーの新規違反(レポートのみ)
- name : Comment findings
if : always()
uses : actions/github-script@v7
with :
script : |
const fs = require('fs');
const body = fs.readFileSync('.a11y/report.md', 'utf8');
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
終了コードを二段に分けたのが肝です。sys.exit(2) はコントラスト比やターゲットサイズが閾値を割った時だけ。Gemini の指摘は sys.exit(1) に落とし、ワークフロー側では continue-on-error で受け止めます。
こうしてから、監査を無効化する PR は一件も出ていません。ビルドが落ちるときは、必ず落ちるだけの根拠があるからです。
GitHub Actions と Gemini API を組み合わせたパイプライン全般の設計は、Gemini API × GitHub Actions で構築する AI コード品質自動監査パイプライン にまとめています。
監査レポートを、読まれる形にする
最後に、決定論レイヤーの実測値と判断レイヤーの指摘を統合し、Markdown レポートに落とします。ここは Gemini が最も素直に力を発揮する工程です。
REPORT_PROMPT = """以下は 1 ページのアクセシビリティ監査結果です。
Markdown のレポートを作成してください。
構成:
1. 概要(3 行以内。新規違反の件数と、最も重い項目のみ)
2. 新規の critical(実測値付き。表形式)
3. 新規の major / minor
4. 人間の確認が必要な項目(uncertain と視覚フラグ)
5. 既知の違反の残数と前回比
規則:
- 実測値(contrast ratio, target size)は与えられた数値をそのまま転記し、丸めないこと
- 推測で数値を補わないこと
- 修正案は該当箇所の CSS / HTML 片で示すこと
対象: {url}
実測結果: {measured}
意味的監査: {semantic}
差分: {diff}
"""
def build_report (url: str , measured: dict , semantic: dict , diff: dict ) -> str :
response = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = REPORT_PROMPT .format(
url = url,
measured = json.dumps(measured, ensure_ascii = False )[: 6000 ],
semantic = json.dumps(semantic, ensure_ascii = False )[: 6000 ],
diff = json.dumps(diff, ensure_ascii = False )[: 3000 ],
),
config = types.GenerateContentConfig( temperature = 0.2 ),
)
return response.text
「実測値は丸めないこと」「推測で数値を補わないこと」。この二行を入れる前は、レポート生成の段階で 7.21 が 7.2 になり、やがて「約 7」になり、最後には出典のない数字が紛れ込んでいました。
生成の最終工程こそ、数値の出所を守る必要があります。
UI・デザイン側の自動化まで含めた全体像は Gemini API × UIデザイン自動化:Figma Make連携からコード生成まで を、フロントエンド開発全般との接続は Gemini × フロントエンド開発:Canvas・API自動化・A/Bテスト量産まで を併せてご覧いただければと思います。
運用して見えてきたこと
このパイプラインを回して分かったのは、AI に任せる範囲を狭めるほど、AI の出力が信用できるようになるという逆説でした。
コントラスト比を計算に戻した瞬間、Gemini が返す指摘の質は目に見えて上がりました。測れないことを問われなくなったモデルは、本来得意な「意味の読み取り」に集中できるようになったのだと思います。
そして何より、フォーカスリングが見えないという指摘を最初に受け取った時、私は自分のサイトを一度もキーボードだけで操作したことがなかったと気づきました。自動化は、手を抜くための道具ではなく、見落としに気づくための道具でした。
次に試していただきたいのは、決定論レイヤーの二つのプローブだけを、お使いのサイトに走らせてみることです。API キーも要りません。それでも、思っていたより多くのことが見えてくるはずです。
私自身、まだこの仕組みを育てている最中で、抑制リストの期限が切れるたびに小さな宿題を突きつけられております。