今朝、Cowork のスケジュールタスクがプロンプトキャッシュの TTL を 5 分から 1 時間へ延ばす設計、という題材で記事を書き始めようとしていました。手が滑って公開していたら、半年前に出した claude-api-prompt-cache-5m-1h-two-tier-ttl-design とほぼ同じ内容が、別 URL でもう一本増えていたはずです。
無人で毎日記事を生成していて一番こわいのは、エラーで止まることではありません。エラーは止まればログに残り、翌日気づけます。本当にこわいのは、止まらずに「数日前と中身がほぼ重なる記事」を淡々と公開し続けることです。一本ずつ見れば破綻していないので、人間がレビューしない限り気づけません。そして Google から見れば、これは薄い記事を量産しているサイトの典型的な挙動です。
私はこの半年、4 サイトを無人のスケジュールタスクで回してきて、この「静かな重複」が検索評価をじわじわ削る最大の要因だと感じています。今日はその対策として、記事を公開する前に slug の近さと当日ログを照合し、重複しそうなら公開を止めるゲートの作り方を、実際に動いているコードでお伝えします。
なぜ「件数チェック」だけでは重複を防げないのか
多くの自動投稿パイプラインは、push 直前に日本語版と英語版の件数一致を確認します。これは 404 を防ぐためには必須ですが、重複検出には何の役にも立ちません。件数が揃っていても、中身が数日前と重なっていれば、それは「正しく数えられた重複記事」が一本増えるだけです。
タイトルの完全一致チェックも役に立ちません。無人タスクは毎回少しずつ違う言い回しでタイトルを作るので、「プロンプトキャッシュの TTL 設計」と「キャッシュ有効期限を延ばすコスト設計」は文字列としては一致せず、すり抜けます。
重複を捉えるには、表記ではなく「その記事がどの概念について書いているか」を比較する必要があります。そして幸い、私たちは概念を要約した短い文字列をすでに持っています。slug です。slug はハイフン区切りの英単語列で、記事の主題語がそのまま並んでいます。これをトークンの集合として比較すれば、表記ゆれに強い重複判定ができます。
slug をトークン集合にして Jaccard 類似度で測る
考え方はシンプルです。候補記事の slug と既存記事の slug をそれぞれハイフンで分割し、単語の集合にします。二つの集合がどれだけ重なっているかを Jaccard 係数(積集合のサイズ ÷ 和集合のサイズ)で測り、しきい値を超えたら「同一概念の疑いあり」と判定します。
#!/usr/bin/env python3
"""dup_gate.py — 候補 slug が既存記事と概念的に重複していないか検査する。
使い方:
python3 dup_gate.py <repo> <category> <candidate-slug>
終了コード:
0 重複なし(公開してよい)
1 重複の疑いあり(角度を変えるか加筆昇格に切り替える)
"""
import sys
from pathlib import Path
# slug を主題語の集合に変換する。ノイズ語は主題ではないので落とす。
STOPWORDS = {
"claude", "api", "sdk", "cli", "guide", "the", "a", "to", "for",
"with", "and", "of", "in", "on", "how", "your", "cowork",
}
def slug_tokens(slug: str) -> set:
parts = [p for p in slug.lower().split("-") if p]
return {p for p in parts if p not in STOPWORDS and len(p) > 1}
def jaccard(a: set, b: set) -> float:
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
def main():
repo, category, candidate = sys.argv[1], sys.argv[2], sys.argv[3]
cand = slug_tokens(candidate)
ja_dir = Path(repo) / "content" / "articles" / "ja" / category
hits = []
for mdx in ja_dir.glob("*.mdx"):
existing = mdx.stem
if existing == candidate:
continue
score = jaccard(cand, slug_tokens(existing))
if score >= 0.5:
hits.append((score, existing))
hits.sort(reverse=True)
if hits:
print(f"❌ 重複の疑い: {candidate}")
for score, existing in hits[:5]:
print(f" {score:.2f} {existing}")
sys.exit(1)
print(f"✅ 重複なし: {candidate}")
sys.exit(0)
if __name__ == "__main__":
main()このスクリプトを今朝の例で動かすと、候補 claude-api-prompt-cache-ttl-5m-to-1h-refresh-design に対して、既存の claude-api-prompt-cache-5m-1h-two-tier-ttl-design が 0.62 で引っかかります。共通トークンは prompt, cache, 5m, 1h, ttl, design の 6 語で、和集合は 10 語前後。表記は違うのに、概念は明確に重なっていることが数値で出ます。