サイト全体の SEO/LLMO 品質を自動監査する — advertools × LLM 採点パイプラインの実装
記事が数十本を超えると、サイト品質の維持は人手のチェックでは回りません。sitemap からの一括抽出(advertools)、テンプレート分類、Claude API による E-E-A-T ルーブリック採点、週次差分アラートまで——サイト全体を毎週自動で監査するパイプラインを動くコードで示します。
by Akisame
サイト全体の品質、最後にいつ「全ページ」確認しただろうか。答えが思い出せないなら、それが普通です——記事が数十本を超えた時点で、人手の定期チェックは構造的に回らなくなります。解は監査をパイプラインにして、人は差分だけを見ることです。メタや内部リンクの機械的チェックだけでなく、採点基準(ルーブリック)を LLM に渡せば E-E-A-T のような定性評価まで自動化の枠に入ります。この記事では、sitemap からの一括抽出(advertools)→ テンプレート分類 → Claude API によるバッチ採点 → 週次差分アラート、という4段のパイプラインを動くコードで示します。抽出部は自サイトに対して実際に実行して通したものです。
なぜ「全ページ監査」が必要か — 品質は書いた後に劣化する
1本ずつ丁寧に書いていれば品質は保てる、というのは公開時点の話でしかありません。コンテンツの品質は書いた後に、書き手が何もしなくても劣化します。劣化のルートは少なくとも4つあります。
- 情報の陳腐化。参照した仕様・価格・ツールのバージョンが変わる。書いた瞬間に正しかった記述が、半年後には誤情報になる
- リンク切れ。外部リンク先の削除・移転。内部リンクもリライトやスラッグ変更で静かに壊れる
- テンプレート変更の副作用。サイト側のレイアウトやメタ生成ロジックを変えると、全ページの出力が一斉に変わる。1ページだけ見て「直った」と思っても、別のテンプレート群で壊れていることがある
- カニバリゼーション。記事が増えるほど、同じ検索意図を複数記事が食い合う。これは1本ずつ見ていても絶対に気づけない。「面」で見て初めて分かる
つまり「1本を良くするケア」と「サイト全体を監視する仕組み」は別の仕事です。前者は書くたびにやります。後者は書いた本数に比例して重くなるので、人手でやる限り必ずどこかで破綻します。10本なら全部読み直せます。50本を毎週読み直す人はいません。
破綻の兆候は分かりやすいものです。「たぶん大丈夫なはず」という言葉が増えることです。監査パイプラインの目的は、この「はず」を毎週の機械チェックに置き換えて、人間の注意を前週から変化した箇所だけに集中させることにあります。
抽出 — sitemap → advertools で構造データ化
最初の段は、サイトの全公開ページを構造化データ(DataFrame)にすることです。Python の SEO ライブラリ advertools が sitemap 取得とクロールの両方を持っていて、この用途にちょうど合います。
import advertools as adv
import pandas as pd
SITE = "https://example.com" # 自分のサイトに置き換える
# 1. sitemap → URL リスト
sitemap = adv.sitemap_to_df(f"{SITE}/sitemap.xml")
urls = sitemap["loc"].tolist()
# 2. クロール(リストモード = sitemap の URL だけ・リンクを辿らない)
adv.crawl(
urls,
"crawl.jsonl",
follow_links=False,
custom_settings={
"DOWNLOAD_DELAY": 1, # 1リクエスト/秒
"CONCURRENT_REQUESTS": 2,
"ROBOTSTXT_OBEY": True,
"USER_AGENT": "site-audit-bot (self-audit; owner)",
"LOG_LEVEL": "ERROR",
},
)
df = pd.read_json("crawl.jsonl", lines=True)自サイトの sitemap に対して実行すると、クロール出力は1ページあたり約100列(手元の実測で97列)になります。タイトル・meta description・見出し(h1〜h6)・OGP・JSON-LD・全リンク・レスポンスサイズ・ステータスまで、監査に必要な構造情報はほぼこの1回のクロールで揃います。複数値のフィールド(h2 やリンク一覧)は @@ 区切りの文字列で入るので、分割して数えます。
audit = pd.DataFrame({
"url": df["url"],
"status": df["status"],
"title_len": df["title"].str.len(),
"desc_len": df["meta_desc"].fillna("").str.len(),
"h2_count": df["h2"].fillna("").str.split("@@").str.len(),
"internal_links": df["links_url"].fillna("").apply(
lambda s: sum(SITE in u for u in s.split("@@"))
),
})ここまでで「ステータス異常・タイトル過長・description 欠落/過短・内部リンク不足」のようなルールで書ける検査は単なる DataFrame のフィルタになります。
flags = audit[
(audit["status"] != 200)
| (audit["title_len"] > 60)
| (audit["desc_len"] < 50) | (audit["desc_len"] > 320)
| (audit["internal_links"] < 3)
]分類 — テンプレートグループで「原因の層」を分ける
抽出の次に採点へ行きたくなりますが、間に1段挟みます。URL パターンでページをテンプレートグループに分類する段です。
def template_group(url: str) -> str:
path = url.replace(SITE, "")
if path.startswith("/insights/"):
return "insights_article"
if path.startswith("/lab/"):
return "lab_article"
if path in ("/insights", "/lab"):
return "hub"
return "static"
audit["template"] = audit["url"].apply(template_group)
audit.groupby("template").agg(
pages=("url", "count"),
desc_len_med=("desc_len", "median"),
int_links_med=("internal_links", "median"),
)なぜこれが必要かというと、同じ「description が短い」という検知でも、原因の層が違うと修正コストが桁で違うからです。
- テンプレ起因: 同じテンプレートグループのページが束になって同じ問題を出す。原因はテンプレート(メタ生成ロジック・レイアウト)側にあり、1箇所直せば全ページ直る
- コンテンツ起因: 特定の記事だけが問題を出す。原因は本文側にあり、1本ずつ直すしかない
自サイトの実測でも、記事ページ群は基準を通過する一方で、ルール検査の検知は記事以外のテンプレートグループ(ハブ・静的ページ)に束になって出ました。これはテンプレ起因の典型パターンで、直すのはテンプレート1箇所です。もしこの分類なしにページ単位のリストだけ見ていたら、同じ1つの原因を複数ページ分の作業として見積もっていたことになります。
グループ集計は逸脱の検知にも効きます。同じテンプレートなら構造は似るはずなので、グループ内の中央値から大きく外れるページは「そのページだけ何かが起きている」候補になります。
採点 — Claude API で E-E-A-T ルーブリックを回す
ルールで書ける検査はここまでで終わり。残るのは「この記事に一次性はあるか」「主張と根拠が対応しているか」という定性評価で、従来は人が読むしかなかった領域です。ここを LLM に渡します。ポイントは、印象で聞かない——採点基準(ルーブリック)を固定して渡すことです。
使っているルーブリックの汎用版(100点満点)はこうです。
| 評価軸 | 配点 | 見るもの |
|---|---|---|
| 経験・一次性 | 25 | 実体験・自前の測定・一次データが本文にあるか |
| 専門性・正確性 | 25 | 主張と根拠の対応、技術的な誤りの有無 |
| 検証可能性 | 20 | 出典明記、再現できる手順・コード |
| 独自性・情報増分 | 15 | 他の上位記事に無い情報を足しているか |
| 構造・可読性 | 15 | 冒頭で答えているか、見出しが論理構造か |
これに加えて AIライティングスコア(0〜100・高いほど「AI が書き流したっぽい」)を別軸で採ります。汎用的な言い回しの比率、具体的な固有名詞・数値の欠如、どの記事にでも書ける段落の割合、といった観点です。実務での合格ラインは運用しているサイト群の経験則で、E-E-A-T 側が70前後以上、AIライティングスコアが50前後以下というレンジに置いています(サイトの性質で調整する前提の目安であって、普遍的な閾値ではありません)。なお、この2軸が「AI で書いたか」ではなく「価値があるか」を測っている理由——Google が罰するのは手段ではなく価値の不在だという話は、対の記事「「AIが書いた記事」をGoogleは嫌うのか」で実測つきで書きました。
Batches API でのバッチ採点
毎週数十〜百本を採点するので、リアルタイム応答は不要です。この形には Claude の Batches API が合います——非同期処理と引き換えにトークン費用が50%割引になり、多くのバッチは1時間以内に完了します。実装はこうなります。
import anthropic
client = anthropic.Anthropic()
RUBRIC_SYSTEM = """あなたはコンテンツ品質の監査者です。
渡された記事を、以下のルーブリックで採点してください。
(…上のルーブリック全文と、各軸の判定基準の具体例をここに固定で書く…)
各スコアには、根拠となる本文の箇所を短く引用すること。"""
SCORE_SCHEMA = {
"type": "object",
"properties": {
"experience": {"type": "integer"},
"expertise": {"type": "integer"},
"verifiability": {"type": "integer"},
"originality": {"type": "integer"},
"structure": {"type": "integer"},
"eeat_total": {"type": "integer"},
"ai_writing_score": {"type": "integer"},
"evidence": {"type": "array", "items": {"type": "string"}},
},
"required": ["experience", "expertise", "verifiability",
"originality", "structure", "eeat_total",
"ai_writing_score", "evidence"],
"additionalProperties": False,
}
requests = [
{
"custom_id": row["slug"], # 結果の突き合わせキー
"params": {
"model": "claude-opus-4-8",
"max_tokens": 2000,
"system": RUBRIC_SYSTEM,
"messages": [{"role": "user", "content": row["body_text"]}],
"output_config": {
"format": {"type": "json_schema", "schema": SCORE_SCHEMA}
},
},
}
for _, row in articles.iterrows()
]
batch = client.messages.batches.create(requests=requests)結果の取得はポーリングで、結果は順不同で返るので必ず custom_id で突き合わせます(位置で対応づけると静かにズレます)。
import json, time
while True:
status = client.messages.batches.retrieve(batch.id)
if status.processing_status == "ended":
break
time.sleep(60)
scores = {}
for result in client.messages.batches.results(batch.id):
if result.result.type == "succeeded":
scores[result.custom_id] = json.loads(
result.result.message.content[0].text
)コスト試算(2026年7月7日時点の公式価格)
前提を「1記事あたり入力 約1万トークン(本文+ルーブリック)・出力 約1千トークン、週90本」と置くと、公式のモデル価格(per MTok)と Batches 割引でこうなります。
| モデル | 入力/出力 価格 | 1本あたり(Batches 適用後) | 週90本 |
|---|---|---|---|
Claude Opus 4.8(claude-opus-4-8) | 25 | 約 $0.04 | 約 $3.4 |
Claude Sonnet 5(claude-sonnet-5) | 15(2026-08-31 まで 10) | 約 $0.015(導入価格時) | 約 $1.4 |
Claude Haiku 4.5(claude-haiku-4-5) | 5 | 約 $0.008 | 約 $0.7 |
つまり最上位モデルで全記事を毎週採点しても月十数ドルの世界です。「サイト全体の定性監査」が人件費の問題からコーヒー代の問題に変わります。これがこのパイプラインの経済的な核心だと思います。
LLM 採点の限界 — 「差分」だけを信じる
正直に書いておくと、採点者としての LLM もぶれます。同じ記事を2回採点させれば数点は動きますし、モデルを替えれば分布ごと動きます。だから運用原則はこうなります。
- 絶対値を信じない。差分を信じる。「この記事は73点だ」ではなく「先週から8点下がった」だけを意思決定に使う
- そのために採点条件を固定する——同一ルーブリック・同一モデル・同一プロンプト。モデルを乗り換えたら全記事を再採点してベースラインを引き直す
- スコアには必ず根拠の引用(evidence)を出力させる。引用が本文に実在するかは機械で検証でき、採点の暴走を検知するガードになる
運用 — 週次差分アラートと直す順序
パイプラインの出口は「今週のスコア一覧」ではありません。それを毎週人が眺めるなら人手チェックに逆戻りです。出口は前週との差分だけを通知するアラートにします(うちのコンテンツ運用の他の仕組みと同じ「変化検知」思想です)。
prev = pd.read_csv("scores_2026-06-30.csv")
curr = pd.read_csv("scores_2026-07-07.csv")
m = curr.merge(prev, on="slug", suffixes=("", "_prev"), how="outer",
indicator=True)
alerts = pd.concat([
m[m["_merge"] == "left_only"], # 新規ページ
m[m["_merge"] == "right_only"], # 消えたページ
m[(m["_merge"] == "both")
& ((m["eeat_total"] - m["eeat_total_prev"]).abs() >= 5)], # スコア変動
m[(m["_merge"] == "both") & (m["title"] != m["title_prev"])], # 構造変化
])構造データ側(タイトル・description・内部リンク数・ステータス)の差分も同じ形で取ります。こちらは意図しないテンプレ変更やリンク切れの検知器として、スコア差分より速く効きます。
アラートが出たときの直す順序にも原則を置いています。
- テンプレ起因を先に。1修正で複数ページ直る。レバレッジが最大
- 次に流入の多いページ。同じ1本の修正でも、読まれているページの改善が先
- 最後にクラスタ単位。単独の記事より、トピッククラスタごとまとめてリライトするほうが内部リンクと文脈を同時に直せる
どのページに流入が集まっているかは計測基盤側の仕事で、AI 面まで含めた計測は「AI Overview / 生成検索からの流入を計測する実装」に書きました。監査(本記事)・計測・調査(AI 検索の API モニタリング)の3つが揃うと、「面」の運用が一通り仕組みになります。
一次観察 — 「AIっぽくない記事」ほど引用される
最後に、このパイプラインを回していて出てきた観察をひとつ。E-E-A-T スコアと AIライティングスコアを全記事で並べると、AIライティングスコアが低い(=汎用的な書き流しが少なく、独自データが濃い)記事ほど、AI 検索・LLM に引用されやすい傾向があります。厳密な因果検証ではなく複数サイト運用での傾向観察(レンジも粗い)ですが、方向は一貫しています。
考えてみれば当然で、LLM が回答を組むときに引用する価値があるのは「その記事にしか無い情報」——実測値・一次データ・固有の手順——であって、どこにでも書いてある一般論を引用する理由がありません。生成検索に引用される条件を戦略側から整理した「LLM時代のSEO」の主張と、監査データ側から見える景色が一致する形です。
つまりこの監査パイプラインは、検索エンジン向けの守り(劣化検知)と、AI 検索向けの攻め(引用されやすさの計測)を同じ1本のコードで兼ねています。品質は「書いたときに確保するもの」から「毎週測って維持するもの」へ。数十本を超えたサイトを一人で運用するなら、先にこの仕組みを作るのが結局いちばん安く済みます。