3.7秒。筆者のサイトでは、記事1本を公開する前に7項目の機械検査が走り、この時間で判定が出る。WCAG のコントラスト比、SVG 図解の文字はみ出し、ローカルパスのユーザー名漏えい、リンク切れ、AI 臭。どれか1つでも落ちれば公開は止まる。この記事では、AI に記事を書かせている個人サイトが公開前検査を「人が覚えておく作業」から「通らないと公開できないゲート」に変えた設計と実測、そしてゲート自体が死んでいた事故までをそのまま書く。
目次
きっかけは「忘れた」が続いたこと
ゲートを作る前、品質チェックはバラバラの手動実行だった。AI 臭の検査スクリプト、SVG はみ出しの検査、リンクの実 HTTP 確認、法令チェックリスト。どれも道具としては存在していたのに、公開のたびに全部を思い出せるとは限らない。実際、筆者の作業記録には「SVG 検査を忘れたまま公開」「法令ゲートを通さず予約」が日付つきで残っている。
決定打は 2026年8月11日だった。予約公開待ちの記事を読み返していて、コード例のディレクトリパスに実際の Windows ユーザー名が2箇所そのまま残っているのを見つけた。ほかの記事では Username と me と <user> の3種類の伏せ字が混在していた。個人名の露出は取り返しがつかないうえ、伏せ字の表記ゆれは「どれが正しいか」を毎回考えさせる。この日、許容表記を小文字の username ただ1つに決めて、違反したら公開が止まる検査を書いた。
道具はそろっていた。無かったのは、忘れても止まる通り道だ。
設計は2層 — 機械が確実に判定できるものだけ止める
検査は BLOCKING と ADVISORY の2層に分けた。駅の改札と同じ考え方で、機械が確実に判定できるもの(切符の有効性)だけを改札にして、判断が要るもの(大きな荷物の持ち込み)は目視に回す。全部を改札にすると誤判定で正当な客まで止まり、全部を目視にすると見落としが戻ってくる。
| 層 | 挙動 | 入れる基準 |
|---|---|---|
| BLOCKING | 1件でも FAIL なら exit 2 で公開を保留。全 PASS なら黙って通す | 機械が確実に判定できる(誤検出しない) |
| ADVISORY | 該当項目だけ報告して公開は進む | 最終判断に人の目視が要る |
「全部 PASS なら黙って通す」も意図的な設計だ。毎回サインオフを要求する案も考えたが、止まる摩擦が常態化すると確認が形骸化する。静かに通り、落ちたときだけ声を上げる。
検査の中身と実出力
検査関数は8本で、BLOCKING 7項目と ADVISORY 最大7項目を出す。
| 検査 | 層 | 何を見るか |
|---|---|---|
| legal_hard | BLOCKING | アフィリエイトの PR 表記漏れ、rel="sponsored" 欠落、権利表記の残存 |
| local_path_username | BLOCKING | ローカルパスのユーザー名が username 以外 |
| ai_smell | BLOCKING | 禁止語・禁止記号・記事量(2,000字未満 / 20,000字超) |
| humanizer | BLOCKING | 文書構造の AI 痕跡(定型の締め・サインポスティング等) |
| svg_overflow | BLOCKING | SVG 図解の文字が viewBox からはみ出していないか(実ブラウザで bbox 実測) |
| contrast | BLOCKING | 本番 CSS 環境でのコントラスト比(WCAG 2.2 達成基準 1.4.3 の AA 基準。算出スタイルを実測) |
| reachability | BLOCKING | 画像と外部リンクの実 HTTP 到達性 |
| trademark / legal_manual / citation_structure ほか | ADVISORY | 商標語の目視確認、依存ライセンスの一次確認リマインド、引用されやすい構造か |
検査の一覧だけ見ると設計の産物に見えるが、実際は逆で、1本ずつが過去の事故の再発防止として生えてきた。対応関係を並べるとこうなる。
| 起きた事故 | 生えた検査 |
|---|---|
| SVG 図解の文字はみ出しを見落として公開(2026年7月) | svg_overflow |
| 法令チェックリストを通さず予約(2026年7月) | legal_hard / legal_manual |
| 公開後にリンク切れへ気づく | reachability |
| 予約記事に実ユーザー名のパスが残存(2026年8月11日) | local_path_username |
| 禁止語が残ったまま公開・書き出しの型が13記事中11本で同一 | ai_smell / humanizer |
| コントラスト比 1.09:1 の配色をそのまま公開(2026年7月) | contrast |
到達性の検査には設計判断が1つ入っている。リンク先が 403・429・503 か接続エラーを返したときだけ、「確認できなかった」として ADVISORY 側に落とす。403 は bot 対策の門前払いで、ブラウザなら見えるサイト(実測では claude.ai がこれ)を「切れている」と誤判定してしまう。429 と 503 は相手が生きていて今は捌けないだけだ。それ以外の 200 でない応答はすべて FAIL にする。切れている証拠の典型が 404 と 410 になる。「切れている」と「確認できなかった」を混ぜると、誤検出が増えてゲートの信用が下がる。
実行は1コマンドで、出力はこうなる。
$ python scripts/prepublish_check.py news/drafts/2026-W35/anthropic-python-sdk-v1-httpx2.html --type=trend
[check] news/drafts/2026-W35/anthropic-python-sdk-v1-httpx2.html
--- BLOCKING ---
✓ legal_hard: 法令(機械): OK
✓ local_path_username: ローカルパスのユーザー名マスク: OK
✓ ai_smell: AI臭・記事量(check_ai_smell exit=0): ERROR 0 件 / WARN 3 件
✓ humanizer: AI臭(構造・humanizer): ERROR 0件 / WARN 0件
– svg_overflow: SVG図解なし(検査svg=0)
✓ contrast: コントラスト比 NG 0件 / 検査 348要素・スキップ 0要素
✓ reachability: 画像・外部リンク到達性: OK
--- ADVISORY ---
⚠ legal_manual: 法令(手動確認が必要)
⚠ trademark: 商標語あり(引用/nominative・ロゴ写り込みを目視)
⚠ ai_smell_warn: AI臭 WARN あり(目視): ERROR 0 件 / WARN 3 件
⚠ contrast_snapshot_stale: CSS環境スナップショットの注意
✓ citation_structure: 引用構造(AI向け): 指摘 0件
[done] BLOCKING: all pass / ADVISORY: 要確認あり
(各 ADVISORY にぶら下がる詳細行は紙面の都合で省略した)
コントラスト検査は少し変わった作りをしている。記事の HTML 断片だけでは色が決まらないので、本番サイトの CSS 環境をスナップショットとして保存しておき、そこへ記事を差し込んで Playwright で算出スタイルを実測する。上の出力にある contrast_snapshot_stale という ADVISORY は、そのスナップショットの鮮度切れを知らせるもので、詳細行には「8日前のスナップショットで検査(親テーマ/プラグイン/テーマオプションの変更が反映されていない可能性)」と出ていた。テーマ CSS を変えたのに古い環境で検査していた、という穴を自分で報告してくる。
実測 — 1本3.7秒、支配項は外部リンクの HTTP
2026年8月26日の実測で、上の記事1本(348要素のコントラスト検査と Playwright 起動を含む)は 3.7秒だった。同じ週の3本をまとめて通すと 40秒。内訳の計測はしていないが、Playwright の起動を含む1本が3.7秒で終わることから、差の大半は外部リンクの応答待ちとみている。公開頻度が週3本なら、検査コストは実質ゼロと考えていい。
手持ちの記事ソース109本(執筆中の本記事などは除く)に、テキスト系の2検査(AI 臭の語彙・構造)を一括で回した結果も載せておく。ERROR は両方とも0件、WARN は語彙系が72件・構造系が309件だった。ERROR 0件は偶然ではない。語彙系は設計当初から BLOCKING で、構造系は後述するとおり全記事で ERROR 0 を実測してから BLOCKING に昇格させた。0件が維持されていること自体がゲートの稼働証明になる。
「検査が動く」と「検査が検出できる」は別物
ゲートを作ったら、壊れた入力を食わせて赤になることまで確かめる。テストの世界でミューテーションテストと呼ばれる考え方で、検査対象をわざと壊して検査が落ちるかを見る。
公開済み記事のコピーに、実ユーザー名入りのパスを1行だけ注入して通してみた(誌面では username に置き換えている。実験では実名を使った)。
$ python scripts/prepublish_check.py gate-demo-fail.html --type=trend
--- BLOCKING ---
✗ local_path_username: ローカルパスのユーザー名マスク: 未マスク 1件
[done] BLOCKING: FAILあり / ADVISORY: 要確認あり
$ echo $?
2
exit 2 で公開が止まる。ここまでは期待どおり。
次に、パスの途中へ HTML タグを挟んでみた。Users フォルダの直後を <span> で囲って実名を書く形で、ブラウザの表示上は元のパスと同じに見える。これはゲートを素通りした(exit 0)。検査は HTML のテキストを正規表現で見ているので、タグを挟まれると「パスの続き」に見えなくなる。
この素通り自体は、検査を導入した日に独立レビューへかけて洗い出されていた既知の限界で、そのとき「直さず、明文化して目視でカバーする」と判断していた。今回のミューテーションは、その限界がいまも実在すること(exit 0 で通ること)を自分の手で確かめ直した形になる。パスの途中でタグを挟む書き方をそもそも記事の執筆規約で禁じ、画像内の文字・WSL 形式(/mnt/c/Users/ 以下)・数値実体参照などの変種と一緒に「機械ゲートが見ない領域」の一覧にして、そこだけ目視で確認する。検出パターンを追いかけて検査を複雑にすると、今度は誤検出の危険が増える。
ミューテーションで確かめる前のゲートは、動いているように見えるだけの飾りかもしれない。
ゲートは自分も死ぬ — 一番危険なのは「実行されずに素通り」
運用を始めて10日ほどの間に、ゲート自身が原因の事故を2回踏んだ。Playwright を使う2検査(コントラストと SVG はみ出し)は、Python の playwright パッケージが入った環境でしか動かない。依存が欠けた環境で実行するとこうなる。
Traceback (most recent call last):
File "scripts/check_contrast.py", line 27, in <module>
from playwright.sync_api import sync_playwright
ModuleNotFoundError: No module named 'playwright'
これは「検査が FAIL した」のではない。「検査が存在しなかった」のであり、呼び出し側が exit code を見ていなければ、公開フローは何事もなく進む。いまの集約ゲートは、検査を実行できなかったときも FAIL に倒す(fail-closed)実装にしてあるので、この素通りを踏むのは単体スクリプトを手で呼ぶ経路だけだ。実際 2026年7月31日、依存を手で並べる運用の抜けで検査系テスト8件が ImportError のまま「環境依存の問題」として放置されかけた。怖いのはテストが赤くなることより、BLOCKING ゲート2本が動かないまま公開が素通りすることだった。
バージョン不整合でも死ぬ。2026年8月3日には、バージョン無指定の playwright が新版に解決された結果、新版が要求するブラウザビルド(build 1234)が手元に無く(1228 まで)、ゲート2本がまとめて落ちた記録が残っている。この2つの事故から、対策は3点に集約した。
# requirements-dev.txt(要旨。実ファイルの経緯コメントは複数行で書かれている)
pytest
requests
playwright==1.62.0 # ブラウザビルド不整合でゲート2本が落ちた事故によりピン留め
...
ブラウザ実測に使っている Playwright(Python 版)は Apache License 2.0 で、サイトの検査用途に使ううえでの制約はない(配布元の LICENSE 原文を 2026-08-26 に確認)。
①依存は requirements-dev.txt に一元化し、手で --with を並べない。②Playwright はバージョンをピン留めし、上げるときはブラウザ実体の導入とセットで行う。③実行は必ず同じラッパ(uv + requirements 指定)経由にする。この記事の執筆時点でも、素の python 環境には playwright 1.42.0 が入っていてピン留めの 1.62.0 と食い違っている。どの Python で走ったかで結果が変わる状態は残っているので、「ラッパ以外で実行しない」を運用ルールにしている。なお 1.62.0 は執筆時点で最新版のため、当時のビルド不整合そのものは今の環境では再現できない。ここは記録に基づく記述だ。
誤検出する検査は、ゲートごと死ぬ
検査を増やすときの唯一にして最大の制約がこれだ。正しい記述に鳴る警告が混ざると、書き手は警告そのものを読み飛ばすようになり、本物の検出まで一緒に無視される。狼少年の警告は、狼が来た日に誰も動かせない。
だから構造系の AI 臭検査を BLOCKING に昇格させるときは、先に公開済みの全記事へ回して ERROR 0件を実測してからにした。1本でも既存記事が誤って落ちる検査は、その時点で BLOCKING の資格がない。誤検出の疑いが残る項目は ADVISORY に置いて、目視の補助に留める。
誤検出の実例が、ちょうどこの記事だ。下書きをゲートに通したら legal_hard が FAIL した。本文の表に書いた rel="sponsored" という文字列を、検査がアフィリエイト痕跡として拾ったのが原因で、検査対象の記事が検査自身の説明文を含むという衝突だった。誌面では文字参照に置き換えて回避している。検査の説明を書くだけで鳴る検出器は、この先も似た記事のたびに鳴る。そういう芽を見つけたら、検出条件を狭めるか例外を明文化するかを、その場で決めて記録する。
検査の追加は回帰テストで縛ってある。判定境界を変える変更が入ると、過去に誤爆した実例を再現するテストが落ちる。
自分のサイトに移植するなら
仕組み自体はどの CMS でも成立する。最小構成は3点で足りる。
# publish の入口に置く骨子。各検査は exit code で語る
import subprocess, sys
CHECKS = [
["python", "scripts/check_local_path.py"], # 自作の検査たち
["python", "scripts/check_links.py"],
]
def gate(article_path):
failed = []
for cmd in CHECKS:
r = subprocess.run([*cmd, article_path])
if r.returncode != 0:
failed.append(cmd[-1])
if failed:
sys.exit(f"公開中止: {failed}") # 落ちたら公開処理に進ませない
gate(sys.argv[1])
①検査は exit code で成否を返す独立スクリプトにする。②公開スクリプトの入口で必ず呼び、非ゼロなら公開処理へ進ませない。③作ったら壊れた入力を食わせて、落ちることを一度確かめる。この3点目を飛ばすと、動いているように見える飾りが通り道に置かれるだけになる。
検査項目そのものは、サイトごとの事故の歴史から生えてくる。筆者の7項目も、元をたどれば全部が実際にやらかした失敗の再発防止だ。最初から7本作る必要はなく、1回事故るたびに1本足すのでいい。
まとめ
公開前チェックは、人が覚えておく作業のままでは必ず抜ける。機械が確実に判定できる検査だけを公開経路の BLOCKING ゲートに埋め込み、判断が要るものは ADVISORY として報告に留める。これから始めるなら、最初の1本は「自分が直近でやらかした事故」の検査にするのを勧める。仮想の事故に備えた検査は判定基準が曖昧になって誤検出の温床になり、実際に踏んだ事故の検査は入力も期待値も現物があるから、壊れた入力を食わせる確認まで最短で書ける。
公開の工程そのものをこれから作る人は「アプリを公開する — バイブコーディング入門」を、検証を人とAIでどう分担するかは「/verify と /code-review が自動で走らなくなった」もあわせてどうぞ。