mcpField
I
internal-linking

internal-linking

github.com/illyism营销与增长1.2k166 次安装3 天前更新
代码仓库

关于 internal-linking

internal-linking 是面向网站内容团队的内链优化技能,分析页面间链接关系,并直接编辑内容和路由,将信息页的流量与链接权重导向营收页面。它还要求生成修改前后的链接报告。

主要功能

  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 的阈值。全站页脚链接是一个重复使用的模板链接,不具备正文链接所承载的主题背景。

在编辑任何内容之前,将每个目标页面的“编辑前”计数快照保存到 seo/internal-links-report.md,并将编辑前的图谱保留为 seo/internal-link-graph.before.json。

步骤 2:注入上下文相关的锚文本

将每篇来源文章与 1-3 个主题相关的目标页面匹配。例如,what is DMARC 匹配 /tools/dmarc-checker 和 /use-cases/email-deliverability,但不匹配 /vs/competitor-crm。

链接位置和锚文本规则
  1. 正文上方 40% 以内至少新增 1 个能带来收入的链接(按 frontmatter 之后的字符偏移量计算)。读者会随着滚动逐渐流失,因此靠近顶部的链接点击率更高,也比底部的“相关阅读”区块更容易先被爬虫发现。检查每篇编辑过的文章:

    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 的内联链接没有任何作用。只匹配 1 个目标页面的文章就添加 1 个内联链接;切勿为了凑够 2 个而添加不相关的目标页面。步骤 3 的提示框可以指向文章已通过内联链接提及的目标页面,因为它是为了吸引点击而放置的转化区块,不是第二个链接权重入口。

  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 篇,则每篇文章使用一种;提示框的 CTA 文本也计入),并且全站完全相同的锚文本重复次数不得超过 3 次。20 个完全相同的 2 词锚文本看起来像是刻意生成的,而且只向 Google 传递了单一、狭窄的信号,无法涵盖完整主题。

  5. 链接保持为 dofollow。 切勿给内部链接添加 rel="nofollow";这会丢弃你正在传递的链接权重。

  6. 尽量少改。 如果现有短语合适,就直接为其添加链接。如果没有合适的短语,就新增 1 个独立来看也有用的句子。切勿重写段落。

选择新锚文本之前,请检查各目标页面当前的锚文本用法:

rg -o --no-filename '\[([^\]]+)\]\(/tools/dmarc-checker[^)]*\)' content | sort | uniq -c | sort -rn
Diff 示例
 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/,并在 seo/kb/pages-index.md 中添加对应页面及其证据 ID。原因:引用基准数据的提示卡会构成新的依赖页面,而 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 个子页面时,跳过中心页,将面包屑终点设为首页,并在报告中注明“在第 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)

下一步

内部路由理顺后,指向中心页和商业页面的外部链接就会流经整个集群,而不会只停留在某篇文章上。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
  • 将文章链接到其主题未涉及的商业页面
  • 不修改文件,只提交建议

评论

支持 Markdown