关于 internal-linking
internal-linking 是面向网站内容团队的内链优化技能,分析页面间链接关系,并直接编辑内容和路由,将信息页的流量与链接权重导向营收页面。它还要求生成修改前后的链接报告。
主要功能
- 01构建站内链接图并识别孤立页面
- 02按流量与主题匹配来源页和目标页
- 03在正文中添加描述性链接
- 04为内容页添加产品引导模块
- 05检查枢纽页面、面包屑与点击深度
- 06生成内链修改前后报告
适用场景
- 01把博客访问引导至定价和工具页面
- 02改善缺少上下文入链的营收页面
- 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。
链接位置和锚文本规则
-
正文上方 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-4 个能带来收入的链接,每个目标页面最多 1 个内联链接。 同一页面中再添加一个指向同一 URL 的内联链接没有任何作用。只匹配 1 个目标页面的文章就添加 1 个内联链接;切勿为了凑够 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。 -
在整个网站上变换锚文本。 每个目标页面至少要有 5 种不同的锚文本(如果链接到该页面的文章少于 5 篇,则每篇文章使用一种;提示框的 CTA 文本也计入),并且全站完全相同的锚文本重复次数不得超过 3 次。20 个完全相同的 2 词锚文本看起来像是刻意生成的,而且只向 Google 传递了单一、狭窄的信号,无法涵盖完整主题。
-
链接保持为 dofollow。 切勿给内部链接添加
rel="nofollow";这会丢弃你正在传递的链接权重。 -
尽量少改。 如果现有短语合适,就直接为其添加链接。如果没有合适的短语,就新增 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 步:验证并报告
- 重新运行
scripts/internal-link-graph.mjs,输出到seo/internal-link-graph.json。完成的标准是orphanMoneyPages为[],或者报告中列出了每个仍然存在的孤立页面及其原因。 - 如果仓库有构建和链接检查工具,请运行它们。交付前修复所有失效链接。
- 在
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/* 页面,而不是首页。
输出
每次运行都必须包含:
scripts/internal-link-graph.mjs和seo/internal-link-graph.json(运行前和运行后)。- 对 SOURCE 文章进行原地修改:添加情境化链接,并为每篇文章添加 1 个提示卡。
- 新建或修复的中心页/索引页和面包屑。
seo/internal-links-report.md,包含更新前/后的对照表及仍然存在的孤立页面。
不要
- 将导航栏或页脚链接计为情境化入站链接
- 仅在文章末尾添加“相关阅读”链接区块来解决问题
- 在全站范围内重复使用同一个精确锚文本超过 3 次
- 为内部链接添加
nofollow - 将文章链接到其主题未涉及的商业页面
- 不修改文件,只提交建议