Lab 系の 4 サイトを並行運用していると、記事数が増えるほど厄介になる作業が一つあります。MDX 本文に書いた内部リンクが、実在する記事を指しているかどうかの確認です。Claude Lab と Gemini Lab だけで合わせて 1,700 本近く、4 サイト合計では 3,000 本を超える記事があり、Markdown 本文の中に ~ 形式のリンクが散在しています。ビルド時にいくつかの仕組みで弾くことはできますが、それでも拾い切れない日があります。
この内部リンクの整合性チェックを、2 ヶ月ほど Gemini 2.5 Flash に任せてみました。期待した精度には届かない場面もありましたが、運用に組み込むと「自分では絶対に気づかない種類の不整合」を拾ってくれるという発見がありました。今日はその実装と、運用してみて見えてきた所感をまとめます。
なぜ Gemini に任せたか
最初は単純な grep スクリプトで充分だと思っていました。コンテンツディレクトリから /articles/(category)/(slug) 形式の文字列を全部抽出し、対応する MDX ファイルが実在するかを確認すれば終わりです。実際、それで 95% のケースは弾けます。
ところが残りの 5% に、機械的なファイル存在チェックだけでは見抜けないものが混ざります。例を 3 つ挙げます。
- リンク先のスラッグは実在するが、書いている文脈とまったく関連しない記事を指している
- アンカーテキストが「Gemini Code Assist の導入」なのに、リンク先は「Gemini for Workspace の請求設定」になっている
- 同じトピックを扱った記事が 3 本ある中で、最新版ではない古い記事に飛ばしている
このあたりは「文脈を読む」必要があり、grep の領分を越えます。両家の祖父が宮大工だったこともあって、組み上げたものが何十年も残るかどうかは細部の仕舞いで決まる、という感覚が体に残っています。リンクの精度は読者の信頼に直結するので、ここに時間を使うのは惜しくないと判断しました。
構成
実装は単純です。記事ごとにリンクを抽出し、リンク先記事の冒頭と現在記事の前後文脈を一緒に Gemini に渡し、関連度を JSON で返してもらいます。
import google.generativeai as genai
import re, pathlib, json
MODEL = "gemini-2.5-flash"
LINK_RE = re.compile(r"\[([^\]]+)\]\(/articles/([^/]+)/([^)]+)\)")
def collect_links(article_path: pathlib.Path):
text = article_path.read_text(encoding="utf-8")
return [
(m.group(1), m.group(2), m.group(3), text)
for m in LINK_RE.finditer(text)
]
def review_link(anchor, category, slug, body, target_excerpt):
prompt = f"""
あなたは技術ブログのエディタです。次のリンクの妥当性を JSON で返してください。
アンカーテキスト: {anchor}
元記事の前後200字: {body[:200]}
リンク先記事の冒頭400字: {target_excerpt[:400]}
評価軸:
- relevance: 0.0〜1.0 の数値(文脈関連性)
- anchor_match: true/false(アンカーがリンク先の内容と一致するか)
- replacement_hint: 不一致なら何に置き換えるべきかの示唆(文字列、なければ空)
JSON だけを返してください。
"""
model = genai.GenerativeModel(
MODEL,
generation_config={"response_mime_type": "application/json"},
)
resp = model.generate_content(prompt)
return json.loads(resp.text)response_mime_type="application/json" を有効にしておくと、後段の json.loads で詰まることがほとんどなくなります。最初は gemini-2.5-pro で全リンクを回していましたが、判定の質と単価のバランスを見ると Flash のほうがこの用途には合いました。Pro に再投票させるのは relevance が 0.3〜0.7 のグレーゾーンに入った 5% 程度に絞り、明確な不一致は Flash で即決しています。
判定結果は YAML ログとして書き出し、サイトごとの _link_review/YYYY-MM-DD.yml に貯めています。同じ URL を翌日にもう一度回すような無駄を避けるため、過去 7 日間に判定済みのリンクはスキップする仕掛けを足しました。
2ヶ月運用して見えたこと
毎晩 02:30 JST にバッチで走らせ、当日 push された記事の内部リンクだけを対象にしています。4 サイト合算で 1 日あたりおよそ 60〜80 リンク。token 単価で見るとひと月 1,000 円前後で済んでいます。個人開発の運用費としては許容範囲です。
明確な誤判定もありました。Gemini は同義語に厳しすぎる傾向があり、「Function Calling」と「ツール呼び出し」を別物として扱ってしまう日があります。閾値を緩めると今度はノイズが増える。このバランス調整は今も手で詰めている最中です。
一方で、想定以上の収穫もありました。たとえば「公式ドキュメントへの外部リンクが古いバージョンの URL を指している」というケースを、内部リンクのチェック中に副次的に拾ってくれたことがあります。プロンプトには「内部リンクの妥当性を見て」としか書いていないのですが、リンク先の冒頭を読み込んだ際に「このページは古いと注釈が出ています」と Gemini が返してきました。これは grep ではまず気づけません。
もう一つ、自分の記事の癖を Gemini に客観視してもらえた点も収穫でした。「同じ記事で『個人開発』『個人事業』『インディーデベロッパー』を混在させている」「アンカーテキストにスラッグそのまま使っている箇所がある」といった指摘が、レビュー結果のメタコメントとして時折出てきます。指示していないのに拾ってくれる、というのは Pro よりも Flash の方が頻度が高かったように感じます。
自分の運用に取り入れるなら
同じ仕組みを試したい場合、いきなり全記事に流すのではなく、まず直近 1 ヶ月の記事に絞ることをお勧めします。古い記事までスコープを広げると、過去の自分が書いた文体や用語の揺れに引っ張られて、Gemini の判定が安定しません。
もう一つ大事だと感じたのは、判定結果を「自動修正」ではなく「レビュー候補リスト」として書き出すことです。テキストの差し替えは最終的に人の目で確認した方が安全で、特に技術用語の精度を保ちたいブログでは差し戻しの判断は自分でやる方がしっくり来ます。私は朝のコーヒーを淹れている数分で前夜のレビュー候補リストに目を通し、明らかな不一致だけその場で修正する運用に落ち着きました。
コスト管理の面では、gemini-2.5-flash-lite も一度試しました。単価は確かに安いのですが、文脈関連性の判定がブレやすく、relevance のスコアが 0.5 付近に偏る傾向が出ました。リンク整合性チェックのように「白黒つけたい」用途では、Flash で固定したほうが結果的に運用が楽でした。
次に取り組むこと
次は同じパイプラインを Crashlytics のクラッシュレポート分類に流用しようと考えています。Claude in Chrome で取得した日次レポートを Gemini に投げて、影響範囲と緊急度を分類させる仕組みです。2014 年から個人でアプリを出してきて、何度かクラッシュ対応が後手に回った苦い経験があるので、ここは自動化の優先度が高い領域だと感じています。
リンク整合性チェックは目立たない作業ですが、地味に効きます。読者がリンクをクリックして「期待した内容と違う」と感じる回数が少しでも減れば、それは結果的に検索エンジンが評価する滞在時間にも返ってくるはずです。同じような運用をされている方の参考になれば嬉しいです。最後までお読みいただきありがとうございました。