一、断链不只影响体验,它在消耗你的收录
站内断链听起来是个小问题:一个链接点过去 404 而已。但对搜索引擎来说,它不是「小问题」。当一个已经被收录的页面里存在大量指向不存在地址的链接时,爬虫每次来访都要为这些死链付出抓取预算,却拿不到任何有效内容;更麻烦的是,如果死链是通过站内导航、分类页、标签页这些模板位置批量出现的,那么它影响的是全站的页面质量评价。
站内断链的成因通常只有四类,认清了就好办:一是文章迁移或改 slug 之后旧链接没处理;二是手写内链时打错了 URL(多一个斜杠、少了 www、http 与 https 混用);三是从别处复制来的图片外链,对方图床已停服;四是模板里的分类/标签链接在分类被重命名后失效。前两类靠扫描可以发现,第三类要靠抓取资源文件,第四类要看完工后的渲染页面而不是源文件。
这篇讲的是完整闭环:怎么用一台普通 VPS 在半小时内把整站大约几百个页面的断链全部捞出来,怎么分级处理,以及 404 页面该怎么设计才真正对用户和爬虫都有用。
二、扫描策略:以 sitemap 为种子的有限深度爬取
不用装重型工具。判断站内是否有断链,只需要「抓页面 → 提取所有站内链接 → 逐个 HEAD 请求 → 记录非 2xx」这个循环。用 sitemap 当种子,可以保证覆盖所有已收录页面,而且不会像全站爬取那样钻进日历归档、分页的无限组合里。
先确认 sitemap 可用,并统计页面数量:
curl -s "https://your.site/sitemap.xml" | grep -oE '<loc>[^<]+' | wc -l如果站点有分页 sitemap(sitemap.xml 里包含其他 sitemap 索引),需要先展开一层。下面的脚本把这一步和扫描合并,用 Python 标准库实现,无需任何第三方依赖:
import re, time, urllib.request, urllib.parse
from html.parser import HTMLParser
BASE = "https://your.site"
UA = {"User-Agent": "Mozilla/5.0 (compatible; SelfLinkAudit/1.0)"}
def get(url, method="GET"):
req = urllib.request.Request(url, headers=UA, method=method)
try:
r = urllib.request.urlopen(req, timeout=20)
return r.status, r.read() if method == "GET" else b""
except urllib.error.HTTPError as e:
return e.code, b""
except Exception:
return 0, b"" # 0 = 连接失败/超时
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.a, self.img, self.cur = [], [], None
def handle_starttag(self, tag, attrs):
d = dict(attrs)
if tag == "a" and d.get("href"): self.a.append(d["href"])
if tag == "img" and d.get("src"): self.img.append(d["src"])
if tag == "link" and d.get("href"): self.img.append(d["href"])提取出来的链接要做三步归一化,否则报告里全是噪声:一是把相对路径拼成绝对地址;二是把带 ?utm_、?from= 这类追踪参数的去掉参数;三是把 #锚点 去掉。同一篇文章被不同参数引用了二十次,本质是一个链接,不是二十个断链。
另一个关键细节:先 HEAD,失败再 GET。用 HEAD 能把一轮扫描的流量压到原来的一小部分,但有些站点(包括不少 Nginx 配置不当的站点)对 HEAD 返回 405,这时才退回 GET,并且只读状态码不读 body。如果自己站点对 HEAD 返回 405,顺手把 Nginx 的 location 里补上 limit_except GET HEAD 的处理,也能让各种监控工具少报假错。
三、分级:把断链分成必须修、应该修、可以不管
扫完之后报告的条目可能有几十条,全部手工处理不现实。按影响分成三档:
第一档,必须修:从文章正文、导航、分类页、标签页里出现的站内 404/410;返回 5xx 的站内链接;以及所有返回 200 但页面内容是「文章不存在」的软 404。软 404 是最容易被漏掉的一类——状态码是对的,爬虫却认为这是一个有效页面,于是这个「空页面」被收录、被当作低质内容,比硬 404 更有害。
第二档,应该修:正文里的外链 404(尤其是引用较多、可能带来访客的链接)、模板里失效的分类/标签链接。外链失效不会直接伤害你的排名,但会伤害读者信任;模板里的死链则是每页都出现,属于批量问题,修一次收益最大。
第三档,可以不管:扫描器探测留下的无效路径(如 /wp-login.php、/.env)、只在评论里出现的历史链接、已经下线且无搜索流量的页面。这一档的正确处理不是修,而是确认它们返回的是干净的 404 而不是 200。
一个实用判据:看这个链接被多少个页面引用。被 50 个页面引用的死链(通常是模板位置)优先级最高,被 1 个页面引用的正文死链其次。报告里把「引用页面数」排序列出来,处理顺序就自然清楚了。
四、处置方式:301、410 与就地修复怎么选
同样是 404,处理方式不同,对搜索引擎的信号也完全不同:
- 301 永久重定向:适用于「这个内容还在,只是地址变了」。文章改 slug、目录结构变更、分类合并都用它。要点是一次跳到位,不要 A→B→C 连跳,每多一跳就损失一部分权重传递,而且爬虫对链条长度有上限。
- 410 永久删除:适用于「内容永久不存在了」,比如删除的草稿、合并后废弃的标签页。相比 404,410 让搜索引擎更快地从索引里移除,因为它是明确的「别再来了」。
- 就地修复:适用于正文里的手误链接。直接改文章内容,改完重新抓一遍确认。这类收益最高、成本最低,因为它是内容本身的修复。
- 302 临时重定向:只用于「暂时性」场景,比如维护页面。如果你用它来做永久性的地址迁移,权重是不传递的,这是非常常见的错误。
批量 301 的正确做法是维护一张映射表,在 Nginx 里用 map 指令集中管理,而不是给每条链接写一个 rewrite:
map $uri $redirect_to {
default "";
/old-slug/ /new-slug/;
/2023/05/old-title.html /old-title/;
~^/tag/旧标签 /tag/new-tag/;
}
server {
if ($redirect_to != "") {
return 301 $redirect_to;
}
}用 map 的好处是:所有跳转规则集中在一个文件里,一目了然;新增规则只需改一行,不用碰 server 块;而且可以随时导出这张表作为迁移记录。注意不要把 if 用在 location 内部去判断 $uri——Nginx 的 if 是「邪恶的」,在 location 内它会创建隐式的嵌套配置,行为难以预测。放在 server 层配合 map 是安全的写法。
五、404 页面:它其实是站点的第二个首页
很多站点的 404 页面是 Nginx 的默认白页,只有一个大写的「404 Not Found」。这是一个白白浪费的位置。用户从搜索进来、落在 404 页时,他还没有离开你的站点——此时给他一个能点的东西,有很大概率能留住他。
一个好的 404 页面应该包含四件事:
- 明确说明并道歉,一句话就够,不要长篇大论;
- 搜索框,让用户自己找——这是转化率最高的一步,很多人只是记错了 URL 的部分字符;
- 最近文章或热门文章列表,给一个「随便看看」的入口;
- 返回首页的链接,且必须在首屏可见,不能藏在页脚。
技术上必须确认两件事。第一,404 页面要真的返回 404 状态码,而不是 200。这一点必须在服务器层面做:
error_page 404 /404.html;
location = /404.html {
internal;
# 关键:不要在这里 return 200,也不要用 rewrite 到首页
}第二,404 页面要加 noindex,防止被搜索引擎收录成一个正常页面。同时不要用 JS 做「3 秒后自动跳转首页」——这会让爬虫认为你返回的是软 404(跳到首页却返回 404 或反之),判定的混乱比不跳更糟。
六、软 404 的检测:状态码对不代表页面真的存在
程序化判定软 404,最可靠的方法是对比页面特征。取一个确定存在的 URL 和一个确定不存在的 URL,用脚本抓下来对比:
import difflib
def body(url):
return get(url)[1].decode("utf-8", "replace")
real = body(BASE + "/a-known-article.html")
fake = body(BASE + "/this-page-does-not-exist-xyz.html")
ratio = difflib.SequenceMatcher(None, real, fake).ratio()
print("status_fake=", get(BASE + "/this-page-does-not-exist-xyz.html")[0],
"similarity=", round(ratio, 3))如果「不存在的页面」返回 200,且与真实页面相似度超过 0.9,那就是典型的软 404。国内常见的一种实现是:程序内部判断文章不存在时,输出「内容不存在」的一段提示但仍然用 200 状态码;或者 Nginx 里 try_files $uri $uri/ /index.php; 之类配置把不存在的路径全部转到首页。后者尤其危险,因为它会生成无限多个返回 200 的 URL,搜索引擎抓到多少就收录多少,属于典型的站内重复内容事故。
处置方式是在应用层抛出 404,而不是在前端显示提示。Typecho/WordPress 都有现成的 404 机制,问题往往出在 Nginx 的 try_files 或 CDN 的「自定义错误页」设置上。CDN 那边尤其要检查:有些 CDN 会把回源的 404 改写成 200 并返回一个自定义页面,这在新手配置里非常常见。
七、把扫描做成例行检查
断链是会持续产生的:每次发文都可能打错链接,每次改 slug 都可能留下旧地址,外链的合作方随时可能关站。所以它不应该是「一次性项目」,而要有节奏:
- 发文时:文末自查一次,把正文里所有站内链接点一遍。这一步成本几乎为零,能拦掉 80% 的新增断链。
- 每周:跑一次全站扫描,与上周报告做 diff,只看新增项。存量按档位慢慢修,新增必须当周清零。
- 改 slug 或迁移目录时:改之前先导出全站链接清单,改完立即对比,把所有受影响的地址加进 map 映射表。
- 每月:检查外链部分的失效情况,尤其是引用的图片和第三方脚本。
报告最好固化成一个纯文本文件并按日期归档,字段只需四列:状态码、被引用次数、链接地址、最后出现所在页面。这样每周用 diff 就能看出变化,不需要任何仪表盘。
八、上线之后:三条检查命令与一个月的观察窗口
文章发完只是开始。真实环境里的坑,往往在「以为已经做完了」之后才冒出来。下面三条命令是我每次上线新配置后固定要跑的,加起来不到一分钟,但拦下过好几次问题。
第一条,确认线上真实返回的是哪个版本的文件。浏览器缓存、CDN 缓存、opcache、反向代理缓存,四层里任何一层都能让你看到旧内容:
curl -sI "https://your.site/static/app.css?v=$(date +%s)" | grep -Ei 'etag|last-modified|cache-control|age|x-cache'age 大于 0 说明命中的是 CDN 缓存,x-cache: HIT 同理。给 URL 换一个随机查询串可以绕过 CDN 缓存,但不能绕过 opcache 和 Nginx 的 fastcgi_cache,那两层要靠 reload 或清目录。看到 etag 与本地文件的修改时间不匹配,基本可以断定被其中一层挡在了前面。
第二条,确认配置文件真的被加载了,而不是「改了一个没被 include 的文件」。这类错误在没有报错的情况下最难查:
nginx -T 2>/dev/null | grep -c '你的配置片段里的独特字符串'nginx -T 会把主配置和所有被 include 的文件展开后输出,比 nginx -t 有用得多——后者只验证语法,不告诉你哪份文件进了内存。同一个独特字符串如果出现 0 次,说明你改的是个孤儿文件。
第三条,确认进程真的重启了。很多人改完配置只做了 reload,但有些改动(尤其是 fpm 的 pm.max_children、PHP 的 memory_limit、以及环境变量)需要真正重建 worker 进程:
ps -o lstart= -p $(pgrep -o php-fpm) # 主进程启动时间,早于你改动的时间就是没重启
ps -eo pid,lstart,cmd | grep '[p]hp-fpm: pool' | head -3观察窗口建议定在整整一个月。第一周盯错误日志与 5xx 比例,第二周看流量和收录变化,第三四周确认没有「慢速劣化」——很多配置问题不会立刻炸,而是让平均响应时间每周涨一点点。
九、小结:让爬虫别白跑,也别让自己白干
站内有几十上百个死链,通常不会立刻让排名掉下来,但它会缓慢地消耗两样东西:爬虫的抓取预算,和用户的耐心。前者看不见,后者看得见。
实操上的最小闭环是四步:用 sitemap 当种子做一次全站扫描、按引用次数排优先级、用 301 映射表批量处理地址变更、把 200 的软 404 修成真 404。这四步做完,站内链接质量基本就干净了。剩下的日常只需要每周一次增量 diff,把新出现的几条清掉——断链治理的成本,应该永远停留在「每周十分钟」这个量级,一旦变成大工程,说明流程漏了环节。
写完这些,回头看其实没有一条是「高级技巧」。真正让站点稳定运行的,是几条朴素的原则:默认值是有理由的,改之前先知道它在防什么;所有变更都要有回滚路径,且回滚路径要事先验证过而不是写完就放着;任何一个数字都不该凭感觉设定,要么来自压测,要么来自线上观测。
另外一点更重要的:不要同时改两个变量。一次只动一处,观察 24 小时,再动下一处。出问题时能立刻知道是谁的责任,这比任何监控工具都有效。站点的可靠性不是靠某个神奇的配置项堆出来的,而是靠每一次改动的可解释性攒出来的。
