mcpField
I
internal-linking

internal-linking

github.com/illyismマーケティング・グロース1.2k166 回インストール3 日前に更新
リポジトリ

internal-linking について

internal-linkingは、Webコンテンツチーム向けの内部リンク最適化スキルです。ページ間の関係を図示し、情報ページから収益ページへ流入とリンク評価を導くようにコンテンツとルートを編集し、変更前後のリンク数も報告します。

主な機能

  1. 01内部リンクを図示して孤立ページを特定
  2. 02流入と話題に基づくリンク元・先の選定
  3. 03本文への説明的なリンク追加
  4. 04コンテンツページへの製品コールアウト追加
  5. 05ハブ、パンくず、クリック深度の確認
  6. 06変更前後のリンク数をレポート

活用シーン

  1. 01ブログ流入を価格ページやツールページへ誘導
  2. 02文脈に沿った被リンクが少ない収益ページの改善
  3. 03サイト構造の確認と内部リンク変更の追跡

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/illyism/seo-skills --skill internal-linking

インストール後、Claude Code で/internal-linkingを入力して読み込まれたか確認します。

同梱ファイルscripts/internal-link-graph.mjs

詳細

内部リンク

情報提供記事がすでに獲得しているトラフィックとリンクエクイティを、収益につながるページへ誘導します。成果物は、編集済みの MDX ファイルとルートファイル、および編集前後の被リンク表です。編集を行わずに「おすすめのリンク」一覧だけを返すのは、失敗した実行です。

多くの SaaS ブログがトラフィックを獲得しても収益がゼロである理由

私が監査する典型的な SaaS サイトでは、how to format JSON や what is DMARC のような情報提供記事が、オーガニック訪問の約 80%と外部被リンクの大半を集めています。一方、コンバージョンにつながる /pricing、/use-cases/agencies、/vs/competitor、/tools/dmarc-checker などのページは、ホームページから 4 クリック先にあり、フッターリンクもそれぞれ 1 つだけです。

Google は内部リンクを通じてランキングシグナルを伝え、訪問者は記事を読んでいる最中に表示されるリンクをたどります。ブログ本文から収益につながるページへリンクしていなければ、そうしたページはリンクエクイティもクリックも得られません。この問題は、新しいコンテンツも新しい被リンクも追加せずに解決できます。

入力

REPO:        framework and content paths (app/, pages/, content/, src/)
TRAFFIC:     GSC export (pages, clicks, last 90 days) OR Ahrefs top pages OR none
MONEY PAGES: /pricing, /use-cases/*, /vs/*, /tools/*, /features/*, /integrations/* (confirm with the user)
SITE URL:    production origin, to normalize absolute links

ステップ 1:内部リンクグラフを作成する

このスキルフォルダーから scripts/internal-link-graph.mjs をリポジトリの scripts/ にコピーし(ソース全体は以下を参照)、リポジトリのルートから実行します。リポジトリ内でファイルを URL に対応付ける方法が異なる場合は、routeOf() を調整してください。

#!/usr/bin/env node
// Usage: SITE=https://deployfast.dev node scripts/internal-link-graph.mjs > seo/internal-link-graph.json
// Before editing, write to seo/internal-link-graph.before.json instead, so the after run does not overwrite it.
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, sep } from 'node:path'

const ROOTS = ['app', 'pages', 'content', 'src', 'components']
const EXT = /\.(mdx?|[jt]sx?|astro)$/
// Files whose links appear on every page (nav, footer, layouts). Counted as sitewide, not contextual.
const CHROME = /(layout\.[jt]sx?$|(nav|header|footer|sidebar)[^/]*\.[jt]sx?$)/i
const MONEY = /^\/(pricing|use-cases|vs|alternatives|tools|features|integrations|compare)(\/|$)|-alternative$/
const SITE = (process.env.SITE ?? '').replace(/\/+$/, '')

const walk = (dir, out = []) => {
  let names
  try { names = readdirSync(dir) } catch { return out }
  for (const n of names) {
    if (n === 'node_modules' || n.startsWith('.')) continue
    const p = join(dir, n)
    if (statSync(p).isDirectory()) walk(p, out)
    else if (EXT.test(n)) out.push(p)
  }
  return out
}
const norm = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p)

function routeOf(file) {
  const f = file.split(sep).join('/')
  let m
  if ((m = f.match(/^(?:src\/)?content\/(.+)\.mdx?$/))) return norm('/' + m[1].replace(/(^|\/)index$/, ''))
  if ((m = f.match(/^(?:src\/)?app\/(?:(.*)\/)?page\.[jt]sx?$/))) {
    const r = (m[1] ?? '').replace(/\([^)]*\)\/?/g, '')
    return r.includes('[') ? null : norm('/' + r) // dynamic templates are covered by their content files
  }
  if ((m = f.match(/^(?:src\/)?pages\/(.+)\.(?:[jt]sx?|astro|mdx?)$/))) {
    if (/^(_|api\/)/.test(m[1]) || m[1].includes('[')) return null
    return norm('/' + m[1].replace(/(^|\/)index$/, ''))
  }
  return null
}

const pages = new Map(), inbound = new Map(), sitewide = new Set()
for (const file of [...new Set(ROOTS.flatMap((r) => walk(r)))]) {
  let text = readFileSync(file, 'utf8')
  if (SITE) text = text.split(SITE).join('')
  const links = new Set()
  for (const re of [/\]\((\/[^)\s"'#?]*)/g, /href=\{?\s*["'`](\/[^"'`#?\s]*)/g])
    for (const m of text.matchAll(re)) if (!m[1].includes('${')) links.add(norm(m[1])) // skip `/blog/${slug}` template literals
  if (CHROME.test(file)) { links.forEach((l) => sitewide.add(l)); continue }
  const route = routeOf(file)
  if (!route) continue
  pages.set(route, file)
  for (const l of links) if (l !== route) (inbound.get(l) ?? inbound.set(l, new Set()).get(l)).add(route)
}

const rows = [...pages].map(([route, file]) => ({
  route, file,
  contextualInbound: inbound.get(route)?.size ?? 0,
  linkedFrom: [...(inbound.get(route) ?? [])],
  inNavOrFooter: sitewide.has(route),
  money: MONEY.test(route),
})).sort((a, b) => a.contextualInbound - b.contextualInbound)

console.log(JSON.stringify({
  generatedAt: new Date().toISOString(),
  orphanMoneyPages: rows.filter((r) => r.money && r.contextualInbound < 3).map((r) => r.route),
  pages: rows,
}, null, 2))

収益につながるページがデータファイルから生成される場合(たとえば、data/use-cases.ts から /use-cases/[slug] を生成する場合)は、それらのルートを手動でグラフに追加してください。このスクリプトが検出できるのは、ファイルに対応するページのみです。

すべてのページを分類する
Page
├── money = true
│   ├── contextualInbound < 3     → ORPHAN TARGET (fix first)
│   ├── contextualInbound 3-9     → UNDERLINKED TARGET
│   └── contextualInbound >= 10   → OK, skip
└── money = false (blog, docs, guides)
    ├── Top 20 by GSC clicks (90 days) or Ahrefs referring domains → SOURCE
    ├── No traffic data available → top 20 by contextualInbound from the graph → SOURCE
    └── Everything else → leave alone this run

フッターリンクとナビゲーションリンクは、< 3 のしきい値には含めません。サイト全体で使われるフッターリンクは、テンプレート上のリンクが 1 つ繰り返されているだけで、本文リンクが持つトピック上の文脈はありません。

編集前に、すべてのターゲットについて「編集前」の数を記録し、seo/internal-links-report.md に保存してください。また、編集前のグラフは seo/internal-link-graph.before.json として保持してください。

ステップ 2:文脈に合ったアンカーを追加する

各 SOURCE 記事を、その記事ですでに取り上げているトピックに合った 1〜3 個のターゲットと対応付けます。what is DMARC は /tools/dmarc-checker と /use-cases/email-deliverability に合いますが、/vs/competitor-crm には合いません。

配置とアンカーのルール
  1. 本文の上部 40%以内に、収益につながる新しいリンクを少なくとも 1 つ配置します(フロントマター以降の文字オフセットで計測します)。読み進めるほど読者は離脱し、上部にあるリンクほどクリックされやすく、「関連記事」のように下部に置かれたブロックよりもクローラーに早く見つけられます。編集した記事ごとに確認してください。

    node -e 'const fs=require("fs");for(const f of process.argv.slice(1)){const b=fs.readFileSync(f,"utf8").replace(/^---\n[\s\S]*?\n---\n/,"");const i=b.search(/(\]\(|href=["\x27{`]+)\/(pricing|use-cases|vs|alternatives|tools|features|integrations|compare)\b/);console.log(i<0?"NONE":Math.round(i/b.length*100)+"%",i>=0&&i/b.length<=0.4?"PASS":"FAIL",f)}' content/blog/*.mdx
    

    コードブロックも長さに含まれるため、YAML のサンプルが長い記事では、そのサンプルより前にリンクを配置する必要があります。

  2. **記事ごとに収益につながる新しいリンクを 2〜4 個追加し、同じターゲットへのインラインリンクは記事ごとに最大 1 個にします。**同じページから同じ URL への 2 つ目のインラインリンクを追加しても効果はありません。合致するターゲットが 1 つだけの記事には、インラインリンクを 1 つ追加します。2 つにするために無関係なターゲットを追加してはいけません。ステップ 3 のコールアウトは、クリックを促すために配置するコンバージョンブロックであり、リンクエクイティを伝える 2 つ目のリンクではないため、記事内ですでにインラインリンクを張っているターゲットを指してもかまいません。

  3. **アンカーは、ターゲットページの機能を示す説明的な 3〜7 語にします。**例:check your DMARC record in seconds、invoicing for freelance developers、compare deployfast.dev with Heroku pricing。click here、this tool、learn more は使用しないでください。

  4. **サイト全体でアンカーに変化をつけます。**各ターゲットには、少なくとも 5 種類の異なるアンカーを設定します(リンク元のページが 5 ページ未満の場合は、リンク元のページごとに 1 種類。コールアウトの CTA テキストも含みます)。サイト全体で、まったく同じアンカーを 3 回を超えて再利用しないでください。同一の 2 語アンカーを 20 回も使うと、不自然に見え、トピック全体を伝えるのではなく、Google に狭いシグナルしか伝わりません。

  5. **リンクは dofollow のままにします。**内部リンクに rel="nofollow" を追加してはいけません。誘導しているリンクエクイティが失われます。

  6. **編集は最小限にします。**既存のフレーズが適切であれば、そこにリンクを設定してください。適切なフレーズがなければ、それだけで役立つ新しい文を 1 文追加します。段落を書き換えてはいけません。

新しいアンカーを選ぶ前に、ターゲットごとの現在のアンカー使用状況を確認してください。

rg -o --no-filename '\[([^\]]+)\]\(/tools/dmarc-checker[^)]*\)' content | sort | uniq -c | sort -rn
差分の例
 DMARC tells receiving servers what to do with mail that fails SPF or DKIM.
-Most domains publish a record once and never look at it again.
+Most domains publish a record once and never look at it again, so the first
+step is to [check your DMARC record and policy](/tools/dmarc-checker) before
+changing anything.

ステップ 3:文脈に沿った製品のコールアウト

SOURCE の投稿ごとにインラインのコールアウトを 1 つ追加し(2,500 語を超える投稿には 2 つ)、文脈をあらかじめ入力した状態で、読者を該当するツールまたはユースケースのページへ直接誘導します。

What does the post's topic map to?
├── An interactive tool (/tools/*) that accepts input
│   └── Callout with a prefilled query param: ?domain=, ?url=, ?topic=, ?json=
├── A use-case page (/use-cases/[slug])
│   └── Callout naming the persona and the outcome, linking to that slug
├── A comparison (/vs/*) because the post mentions a competitor
│   └── Callout with the 1-line verdict from the /vs page
└── Nothing specific
    └── Link /pricing with the plan that fits the post's reader. Never a generic "Sign up".

問題を説明するセクションの直後、手順ごとの解決方法の前にコールアウトを配置します。読者が近道を求めるのはそのタイミングです。

コールアウト内の数値(価格、上限、ベンチマーク)は、該当する情報がある場合は seo/kb/brand-ssot.md または seo/kb/proof/ から取得し、その proof ID とともにページを seo/kb/pages-index.md に追加します。理由:ベンチマークを引用するコールアウトは新たな依存ページであり、route-fact が更新できるのは、把握しているページだけだからです。

リポジトリにコールアウト用のコンポーネントがない場合は作成し、MDX コンポーネントマップに登録します。

// components/product-callout.tsx
import Link from 'next/link'

export function ProductCallout({ title, description, href, cta }: {
  title: string; description: string; href: string; cta: string
}) {
  return (
    <aside className="not-prose my-8 rounded-xl border p-5">
      <p className="font-semibold">{title}</p>
      <p className="mt-1 text-sm text-muted-foreground">{description}</p>
      <Link href={href} className="mt-3 inline-block font-medium underline">{cta}</Link>
    </aside>
  )
}

投稿内での使用例:

<ProductCallout
  title="Check your DMARC record now"
  description="Paste your domain to see your DMARC policy, SPF, and DKIM status."
  href="/tools/dmarc-checker?domain=example.com"
  cta="Run the DMARC check"
/>

このコンポーネントは Link を通じて実際の <a href> をレンダリングするため、コールアウトはクロール可能な内部リンクとして扱われます。onClick によるナビゲーションでコールアウトを作成しないでください。クローラーはそれを無視します。

ステップ 4:ハブ、横断リンク、パンくずリストの確認

For every money page and every SOURCE post:
├── /vs/*     → links to /vs index + 2 sibling comparisons + /pricing
├── /tools/*  → links to /tools index + 2-3 related tools + 1 blog post explaining the concept
├── /use-cases/* → links to /pricing + the 1-2 tools or features that persona uses
├── /blog/*   → links to its category or hub page + 1-2 posts in the same cluster
└── All       → BreadcrumbList JSON-LD + visible breadcrumb matching the URL path

すべての収益ページは、ホームページからのクリック深度が 3 以下でなければなりません。3 クリックを超える深さにあるページは、クロール頻度が下がり、ホームページの評価も受け継ぎにくくなります。

不足しているインデックスページ(/vs、/tools、/use-cases)は、セクション内の子ページが 2 つ以上になった時点で作成して補います。ページのないハブは、パンくずリストと横断リンクの両方を機能させません。子ページが 1 つの場合はハブを作らず、パンくずリストの末尾を Home にして、レポートに「2 つ目の比較ページを作成する際に /vs を作成」と記載します。理由:項目が 1 つだけのハブは薄いページであり、グラフの MONEY パターンでは /vs 自体も収益ページとしてカウントされ、独自に 3 つの被リンクが必要になるためです。

ステップ 5:検証とレポート

  1. scripts/internal-link-graph.mjs を再実行し、結果を seo/internal-link-graph.json に出力します。完了条件は orphanMoneyPages が [] であること、または残っている孤立ページすべてについて、その理由をレポートに記載することです。
  2. リポジトリにビルドおよびリンクチェッカーがある場合は実行します。引き継ぐ前に、壊れたリンクをすべて修正します。
  3. 変更後の件数を変更前の件数の横に seo/internal-links-report.md へ記載します。
# Internal links report
Run: 2026-11-12 | Sources edited: 14 | Links added: 41 | Callouts added: 14

| Target | Before (contextual) | After | Anchors used | Source posts |
| --- | --- | --- | --- | --- |
| /tools/dmarc-checker | 1 | 9 | 7 distinct | what-is-dmarc, spf-vs-dkim, ... |
| /use-cases/agencies | 0 | 5 | 5 distinct | ... |
| /vs/heroku | 2 | 6 | 6 distinct | ... |

Remaining orphans: /use-cases/nonprofits (no matching source topic; needs a post)

次の施策

内部リンクの経路が整ったら、ハブや収益ページに向けた外部リンクの効果が、1 つの投稿で途切れることなくクラスター全体に行き渡ります。LinkDR(https://linkdr.com/SKILL.md)は DR 40~60 以上の文脈に沿ったリンクを設置します。リンク先にはホームページではなく、/tools/* のハブや /use-cases/* のページを指定します。

出力

各実行の最後に、以下をすべて提出します。

  1. scripts/internal-link-graph.mjs と seo/internal-link-graph.json(実行前と実行後)。
  2. SOURCE の投稿を直接編集したもの:文脈に沿ったリンクと、投稿ごとに 1 つのコールアウト。
  3. 新規作成または修正したハブ/インデックスページとパンくずリスト。
  4. 実行前後の表と残っている孤立ページを記載した seo/internal-links-report.md。

禁止事項

  • ナビゲーションやフッターのリンクを、文脈に沿った被リンクとして数える
  • 投稿末尾の「関連する記事」ブロックにリンクを詰め込むことだけで修正を済ませる
  • サイト全体で同一のアンカーテキストを 3 回を超えて再利用する
  • 内部リンクに nofollow を追加する
  • 投稿で扱っていないトピックの収益ページへリンクする
  • ファイルを編集せず、提案だけを返す

コメント