站内断链排查与404页面设计:一次清扫

一、断链不只影响体验,它在消耗你的收录

站内断链听起来是个小问题:一个链接点过去 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 页面应该包含四件事:

  1. 明确说明并道歉,一句话就够,不要长篇大论;
  2. 搜索框,让用户自己找——这是转化率最高的一步,很多人只是记错了 URL 的部分字符;
  3. 最近文章或热门文章列表,给一个「随便看看」的入口;
  4. 返回首页的链接,且必须在首屏可见,不能藏在页脚。

技术上必须确认两件事。第一,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 小时,再动下一处。出问题时能立刻知道是谁的责任,这比任何监控工具都有效。站点的可靠性不是靠某个神奇的配置项堆出来的,而是靠每一次改动的可解释性攒出来的。

Last modification:October 30th, 2026 at 12:26 pm

Leave a Comment