記事の変換スクリプトを読み直していて、リンク切れを黙って直している箇所に気づきました。
存在しない記事へのリンクを見つけると、リンクだけを外して文字は残す処理です。404 を出さないための配慮で、挙動としては正しいものでした。ただ、その処理が 何本のリンクを、どの記事から外しているのか を、私は一度も数えていませんでした。
数えてみると、日本語版 913 本のうち 160 本の記事に、合計 267 本の切れたリンクがありました。そのときに書いた監査スクリプトと、数えたあとに「機械で直さない」と決めた理由を、書き残します。個人開発のサイトで、記事が増えるほど静かに溜まっていく種類の問題だと感じています。
変換スクリプトは何を剥がしているのか
該当の処理は、正規表現でマークダウンのリンクを拾い、リンク先の MDX が日英どちらにも存在しなければ、リンク記法だけを本文から消します。
const pattern = /\[([^\]]*)\]\(\/(?:(?:ja|en)\/)?articles\/([^)#\s]+)\)/g;読み返して分かったことが三つあります。
- 対象は、角括弧のリンクテキストに丸括弧の
/articles/…が続く、マークダウンのリンクだけです。HTML のhref="…"は拾いません(ただし、今回数えた範囲ではhref形式の切れは 0 件でした) - 判定は「日英どちらかに存在すれば OK」です。片方の言語にしかない記事へのリンクは通ります
- コードブロックの中も区別しません
三つ目は、手元で確かめました。次のような小さな Node スクリプトです。
// strip_probe.mjs — ビルドと同じ正規表現を、コードブロック入りの文書に当てる
const pattern = /\[([^\]]*)\]\(\/(?:(?:ja|en)\/)?articles\/([^)#\s]+)\)/g;
const link = "[設定ガイド](" + "/articles/claude-code/no-such-article)"; // 記事内で直接書くと、この記事自体が剥がされる
const fence = "`".repeat(3);
const doc = `本文の ${link} です。\n\n${fence}md\n関連: ${link}\n${fence}\n`;
let out = doc;
for (const m of doc.matchAll(pattern)) out = out.split(m[0]).join(m[1]);
console.log(out);実行すると、本文のリンクも、コードブロックの中の見本も、同じように「設定ガイド」という文字だけになりました。リンク記法の書き方を解説する記事を書いたとき、サンプルが読者の画面でリンクのない文字列に変わっている可能性があります。
実際、今回の集計でも、コードブロックの中で剥がされている記法が日本語版に 4 本、英語版に 5 本ありました。
剥がされているリンクを一覧にする
直す前に、まず見えるようにしたいと考えました。ビルドと まったく同じ正規表現 で数える、読み取り専用のスクリプトです。正規表現を少しでも変えると、ビルドが剥がす数と食い違うので、コピーして使います。
#!/usr/bin/env python3
"""内部リンク監査: ビルドが黙って剥がしているリンクを一覧にする(読み取り専用)。"""
import difflib, glob, re, sys
from collections import Counter, defaultdict
LANG = sys.argv[1] if len(sys.argv) > 1 else "ja"
MAX = int(sys.argv[sys.argv.index("--max") + 1]) if "--max" in sys.argv else None
ROOT = f"content/articles/{LANG}"
LINK = re.compile(r"\[([^\]]*)\]\(/(?:(?:ja|en)/)?articles/([^)#\s]+)\)") # ビルド側の正規表現と同一
FENCE = re.compile(r"```.*?```", re.S)
exists, by_cat = set(), defaultdict(list)
for p in glob.glob(f"{ROOT}/*/*.mdx"):
cat, slug = p.split("/")[-2], p.split("/")[-1][:-4]
exists.add(f"{cat}/{slug}"); by_cat[cat].append(slug)
all_slugs = [s for v in by_cat.values() for s in v]
cited, citing_files, in_fence = Counter(), defaultdict(set), []
for p in sorted(glob.glob(f"{ROOT}/*/*.mdx")):
text = open(p, encoding="utf-8").read()
fenced = [(m.start(), m.end()) for m in FENCE.finditer(text)]
for m in LINK.finditer(text):
if m.group(2) in exists:
continue
cited[m.group(2)] += 1
citing_files[m.group(2)].add(p)
if any(a <= m.start() < b for a, b in fenced):
in_fence.append((p, m.group(0)))
files = set().union(*citing_files.values()) if citing_files else set()
print(f"[{LANG}] 記事 {len(exists)} 本 / 切れ先 {len(cited)} 種 / 切れリンク {sum(cited.values())} 本 / 該当記事 {len(files)} 本")
print(f"コードブロック内の切れリンク記法: {len(in_fence)} 本")
rows = []
for target, n in cited.items():
slug = target.split("/")[-1]
near = difflib.get_close_matches(slug, all_slugs, n=1, cutoff=0.0)[0]
rows.append((difflib.SequenceMatcher(None, slug, near).ratio(), target, n, near))
rows.sort(reverse=True)
for th in (0.9, 0.8, 0.7):
print(f"類似度 {th} 以上の候補: {sum(1 for r in rows if r[0] >= th)} 件")
print("--- 類似度の高い順(機械では直さない。目視用)")
for r, t, n, near in rows[:5]:
print(f"{r:.2f} {t} -> {near}")
print("--- 引用が多い切れ先(書く価値の目安)")
for t, n in cited.most_common(3):
print(f"{n:>2} 本から {t} ({len(citing_files[t])} 記事)")
if MAX is not None and sum(cited.values()) > MAX:
print(f"NG: 切れリンクが基準 {MAX} 本を超えました")
sys.exit(1)python3 -I link_audit_mdx.py ja で日本語版、en で英語版を数えます。リポジトリの直下で実行します。