リンクの整理をエージェントに任せる前に、まずリポジトリの本文リンクを数えておこうと思い立ちました。個人開発の Web サイトで、日英あわせて 2,238 本の MDX が入っています。
最初に書いた 30 行ほどのスクリプトは、日本語版 1,119 本のうち 594 本について「ほかの記事の本文から一度もリンクされていない」と答えました。半分を超えています。
この数字を見て、私は一晩かけて内部リンクを足す算段を始めかけました。手を止めたのは、関連記事の欄が画面に出ていたことを思い出したからです。
数え方を変えると、594 は 6 になりました
本文にある記事へのリンクだけを数えるのは、読者が見る画面の半分しか見ていない状態です。このサイトの記事ページは、本文の下に関連記事を 3 件並べます。
並べ方は RelatedArticles.tsx に書いてあります。同じカテゴリなら 3 点、共有しているタグ 1 つにつき 2 点を足し、点数の高い順に 3 件を出す、という単純な採点です。
この採点を Python に写し、本文リンクと合算して数え直しました。
| 数え方 | 日本語版 | 英語版 |
|---|---|---|
| 本文リンクの入り口がゼロ | 594 本 | 868 本 |
| 関連記事の欄を足してもゼロ(検索に出す記事のみ) | 6 本 | 11 本 |
594 本の「孤立」のほとんどは、画面の上では孤立していませんでした。本当に手当てが要るのは 6 本です。
スクリプトの全文
標準ライブラリだけで動きます。リポジトリの直下で python3 link_audit.py . ja のように実行します。
1 つめに、コードブロックの中のリンク例を数えないことを入れています。記事の中でリンクの書き方を説明していると、例示が本物のリンクとして数えられてしまうからです。
2 つめに、関連記事の再現では、検索に出さない設定の記事を候補の後ろへ回しています。コンポーネントが同じ並べ方をしているためで、ここを省くと数字が大きく変わりました。
#!/usr/bin/env python3
"""MDX サイトの内部リンク監査: 本文リンク・関連記事ウィジェット・ビルドで削られるリンクを数える。
usage: link_audit.py <repo> <ja|en> [--suggest N]"""
import re, sys, collections, pathlib
repo, lang = pathlib.Path(sys.argv[1]), sys.argv[2]
CAP = int(sys.argv[sys.argv.index("--suggest") + 1]) if "--suggest" in sys.argv else 0
PREFIX = "/articles/" if lang == "ja" else "/en/articles/"
LINK = re.compile(r'\]\((/(?:en/)?articles/[^)#\s?]+)|href="(/(?:en/)?articles/[^"#\s?]+)')
FENCE = re.compile(r"```.*?```", re.S)
def tag_block(front): # tags: は [..] 形式と、改行して - "a" と並べる形式の両方がある
t = re.search(r"^tags:(.*?)(?=^\S|\Z)", front, re.S | re.M)
return t.group(1) if t else ""
def load(lang):
arts = {}
for p in (repo / "content/articles" / lang).rglob("*.mdx"):
m = re.match(r"^---\n(.*?)\n---\n", p.read_text(encoding="utf-8"), re.S)
if not m:
continue
fm = dict(l.split(":", 1) for l in m.group(1).splitlines() if ":" in l and l[0] != " ")
body = p.read_text(encoding="utf-8")[m.end():]
arts[f"{p.parent.name}/{p.stem}"] = {
"cat": p.parent.name,
"date": fm.get("date", "").strip().strip('"'),
"hidden": fm.get("noindex", "").strip() == "true", # 検索対象外の印
"tags": set(re.findall(r'"([^"]+)"', tag_block(m.group(1)))),
"body": FENCE.sub("", body), # コード例の中のリンク例は数えない
}
return arts
arts = load(lang)
newest_first = sorted(arts, key=lambda k: arts[k]["date"], reverse=True)
body_in, dead = collections.Counter(), []
out_n = collections.Counter()
for k in arts:
for m in LINK.finditer(arts[k]["body"]):
u = m.group(1) or m.group(2)
if not u.startswith(PREFIX):
continue
t = u[len(PREFIX):].strip("/")
if t == k:
continue
if t in arts:
body_in[t] += 1
out_n[k] += 1
else:
dead.append((k, t)) # ビルドが黙って外すリンク
# 関連記事ウィジェットの再現: 同カテゴリ +3、共有タグ 1 つにつき +2、上位 3 件。
# 同点は newest_first の並びのまま残る(sorted は安定ソート)。
rel_in = collections.Counter()
for k in arts:
a = arts[k]
scored = [(j, (3 if arts[j]["cat"] == a["cat"] else 0) + 2 * len(arts[j]["tags"] & a["tags"]))
for j in newest_first if j != k]
# 検索対象外の記事は同点以下でなく「必ず後ろ」。コンポーネントの並び順と合わせる。
top = sorted((s for s in scored if s[1] > 0), key=lambda s: (arts[s[0]]["hidden"], -s[1]))[:3]
for j, _ in top:
rel_in[j] += 1
n = len(arts)
orph_body = [k for k in arts if body_in[k] == 0]
orph_all = [k for k in orph_body if rel_in[k] == 0 and not arts[k]["hidden"]] # 検索に出す記事だけを対象にする
edges = sum(rel_in.values())
recent = [k for k in arts if arts[k]["date"] >= sorted(a["date"] for a in arts.values())[-79]]
print(f"{lang}: 記事 {n} / 本文リンクの入り口ゼロ {len(orph_body)} / ウィジェット込みでもゼロ(検索に出す記事のみ){len(orph_all)}")
print(f"ウィジェットのリンク {edges} 本のうち、最新 {len(recent)} 本への集中 {sum(rel_in[k] for k in recent) / edges:.1%}"
f"(受け取る記事は {len(rel_in)} 本)")
print(f"ビルドで外されるリンク {len(dead)} 本({len({d[0] for d in dead})} ファイル)")
if CAP: # 孤立記事ごとに、同カテゴリでタグが重なる記事を「リンク元」に提案する(1 記事あたり CAP 本まで)
load_ = collections.Counter(); plan = {}
for o in sorted(orph_all, key=lambda k: arts[k]["date"], reverse=True):
cands = sorted((s for s in arts if s != o and arts[s]["cat"] == arts[o]["cat"] and load_[s] < CAP),
key=lambda s: (-len(arts[s]["tags"] & arts[o]["tags"]), load_[s], s))
if cands and arts[o]["tags"] & arts[cands[0]]["tags"]:
plan[o] = cands[0]; load_[cands[0]] += 1
print(f"提案: {len(plan)}/{len(orph_all)} 本に入り口を作れる(リンク元 {len(load_)} 本・1 本あたり最大 {CAP} 件)")
for o, s in list(plan.items())[:3]:
print(" ", s, "->", o)
if "--fail-over" in sys.argv: # CI 用: 孤立が基準値を超えたら終了コード 1
limit = int(sys.argv[sys.argv.index("--fail-over") + 1])
sys.exit(1 if len(orph_all) > limit else 0)sorted は安定ソートなので、同点のときは newest_first の並び、つまり新しい記事が先に残ります。この性質は、次の節の結果に効いています。