◉ANTIGRAVITY LABEN
記事一覧/Tips & 活用術
✦ Tips & 活用術/2026-10-23中級

本文リンクの入り口がない記事は 594 本ありました。関連記事の欄まで数え直すと、本当に孤立していたのは 6 本でした

MDX の本文リンクだけを数えると半数が孤立に見えました。関連記事の採点式を写して数え直し、ビルドで外されるリンクも拾う Python スクリプトの全文です。

内部リンクMDXNext.js7Python24個人開発117サイト運用

✦ プレミアム記事

リンクの整理をエージェントに任せる前に、まずリポジトリの本文リンクを数えておこうと思い立ちました。個人開発の 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 の並び、つまり新しい記事が先に残ります。この性質は、次の節の結果に効いています。

✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦自分の MDX サイトで、入り口のない記事を 2 秒足らずで洗い出し、本当に手当てが要る記事だけに絞れるようになります
✦関連記事の欄が一部の記事へリンクを集めていないかを、コンポーネントの採点式を写した数行で確かめられるようになります
✦ビルドが黙って外すリンクを公開前に拾い、書いたつもりのリンクが画面に出ていない事故を前もって防げるようになります
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

✦ Tips & 活用術2026-10-19
URL を書き換えたら、あとから読み込む画像だけ 404 になりました。replaceState と相対パスの確かめ方
一覧から個別表示へ切り替えるとき history.replaceState で URL を書き換えたところ、まだ読み込んでいない画像だけが 404 になりました。loading=lazy も同じ挙動だった点、確認用の最小ページとスクリプト、直し方三種の比較までを書き残します。
✦ Tips & 活用術2026-10-17
検索の管理画面に「ページにリダイレクトがあります」と出たとき、直すものと放っておくものを分けた基準
Search Console の「ページにリダイレクトがあります」を、自分のサイトで仕分けた記録です。http→https・www・末尾スラッシュ・言語の振り分けは想定内、チェーン・ループ・転送先のエラーや noindex は要対応という線引きと、転送を一つずつ辿って分類する Python スクリプト、素の要求では見えない言語依存の転送を二通りの要求で見つける方法まで書き残します。
✦ Tips & 活用術2026-10-15
画像が壊れて見えたのは、確かめ方のせいでした。iframe 検証を疑う順番と、実ページの currentSrc
別タブの iframe に実ページを差し込んで画像を点検し、「読み込まれていない」と誤診した失敗の記録です。非表示の枠では描画幅 0 のまま naturalWidth が 0 になり、遅延読み込みは未開始のまま残ります。手元で再現した数値と、実ページで currentSrc と描画幅を見る検査スクリプト、置換を正規表現で機械的に行ったときの落とし穴まで書き残します。