一个「非 200 就算死链」的脚本,把我的站点地图砍掉了三分之一
早年我给自己的小站写过一个小脚本,思路极其朴素:把 sitemap 里所有 URL 拿出来,逐个 curl -o /dev/null -w '%{http_code}',只要不是 200 就记成死链。跑完之后我对着报告删了四十多个链接,心里还挺得意。两个月后再看 Search Console,那四十多个页面的索引全没了,而且不是简单的消失——它们变成了「已提交但未编入索引」,因为我把它们从 sitemap 里拿掉了,却没做 301,爬虫再来的时候看到的是 404,于是把这些页面上积累的权重也一起扔了。
问题出在「非 200 就算死链」这个判定上。真实世界里,一个链接返回的东西远不止 200 和 404:301、302、304、403、429、503、以及 200 但内容是「文章不存在」的软 404。每一种的处置方式完全不同,混在一起处理,就会造成上面那种自伤。这篇文章讲的就是怎么把死链这件事分层分类,然后按类型分别处置。这套方法我用了好几年,现在基本能做到「修该修的、留该留的、不再误杀」。
第一步:先把链接清单拿全,而不是只看现有的死链报告
要修死链,首先得知道有哪些链接指向你。来源至少三处,缺一不可:
# 来源 1:sitemap(你自己认为的页面全集)
curl -s https://www.example.com/sitemap.xml | grep -o '<loc>[^<]*</loc>' \
| sed 's/<loc>//; s|</loc>||' > /tmp/urls_sitemap.txt
# 来源 2:服务器访问日志(真实被请求过的路径,含外部链来的)
awk '{print $7}' /var/log/nginx/access.log \
| sed 's/?.*//' | sort | uniq -c | sort -rn > /tmp/urls_log.txt
# 来源 3:页面内链(站内所有 a href,这是你能自己控制的)
# 用 wget 递归抓一遍再提取,注意加延迟避免打自己
wget -r -l 2 --spider -nd -o /tmp/wget.log https://www.example.com/三个来源覆盖的东西不一样。sitemap 是「我认为存在的页面」,日志是「实际被访问的页面」,站内链接是「我能改的链接」。死链的来源往往不在你删掉的页面,而在别人引用了你的旧 URL,或者你改过伪静态规则。所以用日志找死链比用 sitemap 找有效得多——日志里那些被请求了但返回 404 的路径,才是真实存在的死链。
# 直接从日志里提取所有 404 的路径,按请求次数排序
awk '$9==404 {print $7}' /var/log/nginx/access.log \
| sed 's/?.*//' | sort | uniq -c | sort -rn | head -50这一条命令就是死链排查的主力。按次数排序的意义在于:被请求 300 次的死链接值得修(可能是外部有大量引用,或者你自己的某个页面还在链它),只被请求 1 次的多半是扫描器乱扫的路径,可以直接忽略。
第二步:把「坏链接」分成五类,这是整篇文章的核心
拿到一批非 200 的路径之后,绝不能统一处理。按下面五类分,每一类的处置动作完全不同:
| 类型 | 状态码特征 | 最可能的成因 | 处置 |
|---|---|---|---|
| A. 真死链 | 404 / 410 | 文章删了、页面改名了 | 301 到最相关的新页面,或 410 并移除入口 |
| B. 重定向链 | 301 → 301 → 200 | 改过多次地址,老链接层层跳转 | 直接 301 到最终地址,压平链条 |
| C. 软 404 | 200 但内容为空/提示不存在 | 伪静态写错、参数丢页 | 改成真 404 或补内容 |
| D. 临时失败 | 403 / 429 / 503 | 限流、UA 拦截、后端忙 | 不要判定为死链,改抓取策略 |
| E. 正常非 200 | 304 | 条件请求命中缓存 | 什么都不用做,这是好事 |
其中 D 类和 E 类是最容易被误判的。304 说明「内容没变,用你本地的」,它恰恰是缓存生效的标志,很多站长用脚本扫出几百个 304,以为网站坏了。curl 默认不带 If-Modified-Since 和 If-None-Match,所以正常扫应该拿到 200;如果你拿到 304,说明你在抓的时候带了条件头,或者是中间的 CDN 返回的。这时候正确的反应是检查脚本,而不是检查网站。
403 和 429 也同理。很多站对爬虫做了 UA 过滤,你用脚本抓会被拦成 403。这种情况下的正确做法是给脚本加上和普通浏览器一致的 UA,并加上延迟:
# 带正常 UA 和延迟地扫,避免被自己的防盗链/限流拦住
UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'
while read -r u; do
code=$(curl -s -o /dev/null -w '%{http_code}' -A "$UA" --max-time 10 "$u")
final=$(curl -s -o /dev/null -w '%{url_effective}' -A "$UA" -L --max-time 15 "$u")
echo -e "$code\t$u\t$final"
sleep 0.5
done < /tmp/urls_sitemap.txt > /tmp/scan.tsv这段脚本一次性拿到三个信息:直接状态码、是否经过跳转之后可达的最终地址、以及最终地址是什么。有了这三列,上面五类就能用 awk 直接分开。别一篇一篇手动点,五百个链接手动点要一整天,而且人会疲劳、会漏。
第三步:A 类真死链——用 301 还是 410,判据是什么
这是最多人纠结的地方。给一条死链配 301,你的意思是「这个页面搬家了,去这里」;配 410,你的意思是「这个页面永久没了」。两者的差别不只是语义,还会影响爬虫的行为和链接权重的去向。
我的判断标准是三句话:
- 有明确等价的新页面 → 301 到它。比如文章从
/archives/123.html改成了/archives/wordpress-migration.html,内容还是那篇,这就是 301 的标准场景。 - 没有等价页面,但同主题有聚合页 → 301 到聚合页。比如某篇细分教程删了,但同标签下还有五篇相关文章,301 到标签页比 404 好,比 301 到首页更是好得多——301 到首页会被搜索引擎视为「软 404」处理,权重基本传不过去。
- 内容彻底不合适存在 → 410。比如测试页、临时活动页、涉及撤稿内容的页面。410 会让搜索引擎更快地从索引里移除,比 404 更干净。
这里有个反直觉的点:不要把死链 301 到首页。很多人图省事在 Nginx 里写一条 error_page 404 =301 /;,所有 404 全部跳到首页。这在搜索引擎看来是典型的 soft 404,不仅权重传不过去,还可能被判定为作弊手法。而且用户体验极差——点一个失效链接,莫名其妙回到首页,不知道发生了什么。宁可老老实实返回 404 页面,给一句「这篇文章可能已删除,看看这些相关的」加上几个推荐链接。
# Nginx 里更合理的 404 处理:保留 404 状态码,页面内给引导
error_page 404 /404.html;
location = /404.html {
internal;
}
# 千万不要写:error_page 404 =301 /;
# 单条精确的 301(在 server 块里,一行一条,按需增删)
location = /archives/123.html {
return 301 /archives/new-slug.html;
}
location = /old-category/ {
return 301 /category/new-name/;
}用 location =(精确匹配)而不是正则,好处是可控——你能一条一条数清楚自己配了多少条 301,正则写多了容易互相遮蔽,改一条不知道影响了哪些。当 301 数量超过五十条时,就该考虑用 map 统一管理,比一堆 location 清爽:
# 放在 http 块里(通常是 conf.d/redirect.conf)
map $request_uri $new_uri {
default "";
/archives/123.html /archives/new-slug.html;
/archives/456.html /category/linux/;
/old-feed.xml /feed.xml;
}
server {
# 放在 server 块里,位置要在 location / 之前生效(return 优先级高于 location)
if ($new_uri != "") {
return 301 $new_uri;
}
}map 的好处是集中、可 diff、可批量生成。缺点是 if 在 Nginx 里名声不好,但这里用的是 return(唯一在 if 里安全的指令),而且是精确匹配字符串,不会有意外。这是我个人最推荐的死链治理方式。
第四步:B 类重定向链——跳两次比跳一次慢多少
重定向链是指 A → B → C,最终才对。每多一跳,用户和爬虫就多一次完整的请求往返。在移动网络下,一次跨域重定向的代价大约是 200 到 500 毫秒,而且很多浏览器的预加载会被打断。三条链下来,用户感觉「点了卡一下才动」。
# 检测是否存在多跳:输出每一跳的状态码和地址
curl -sIL -o /dev/null -w '%{num_redirects}\t%{url_effective}\n' https://www.example.com/old-path
# 更详细:打印每次跳转
curl -sIL https://www.example.com/old-path 2>&1 | grep -E '^HTTP|^location'
# 批量找出所有超过 1 跳的 URL
awk -F'\t' '$1>1' /tmp/scan.tsv | head -20压平链条的做法很简单:直接 301 到最终地址。上面那个 map 里,把中间的 B 换成 C 就行。原则是:任何时候新增一条 301,都要指到最终地址,而不是「当时的」新地址。这条看似废话,但它正是链条产生的根源——三年前你把 /123.html 指到 /linux/,两年前你改了分类名成了 /tech/linux/,但没有更新那条老规则,于是就有了两跳。养成习惯:每次改 URL 结构,回头把旧规则全部检查一遍。
第五步:C 类软 404——状态码 200,但页面其实是空的
软 404 是这五类里最伤的一种,因为搜索引擎会持续抓取、持续索引一个没有价值的页面,占用你的抓取预算。它的检测比 404 难,因为从状态码上看不出来。
怎么找?两个判据:
# 判据 1:页面内容极短(返回体小于 2KB 且没有正文)
while read -r u; do
n=$(curl -s -A "$UA" --max-time 10 "$u" | wc -c)
[ "$n" -lt 2000 ] && echo "$n $u"
done < /tmp/urls_sitemap.txt
# 判据 2:页面里出现了「不存在」「无结果」「Not Found」但状态码是 200
curl -s -A "$UA" "https://www.example.com/search?q=不存在的东西" | grep -c '无结果'Typecho 和 WordPress 都有一个通病:访问一个不存在的文章 ID 时,有时会返回 200 加一个「内容不存在」的模板页,而不是 404。这在伪静态规则写错时尤其常见——比如 rewrite 把所有路径都丢给 index.php,由 PHP 判断,但 PHP 判断失败时忘了 http_response_code(404)。
# 检查你的站点对不存在路径的响应(这是最实用的单条测试)
curl -sI -A "$UA" "https://www.example.com/archives/999999999.html" | head -1
# 正确:HTTP/1.1 404 Not Found
# 错误:HTTP/1.1 200 OK ← 软 404,要修修法在程序里而不是 Nginx 里,但如果你用的是现成程序不方便改代码,可以在 Nginx 层做一个兜底:判断返回体的特征(这里不推荐,脆弱),或者干脆检查程序版本、升级到已经修过这个问题的版本。不管哪种,先测出来是第一步——我建议每个站长都测一次那条 999999999.html,三十秒的事,能暴露一个长期的抓取预算黑洞。
第六步:站内死链——这个你应该自己修完
前面几类都是「别人链来的」或者「搜索引擎记着的」死链,修不修影响权重的传递。而站内死链是你自己的问题,用户在页面上点一个链接点出 404,直接走了。这类必须修完,没有取舍空间。
# 抓取全站,找出所有返回 4xx 的站内链接
# wget 的 --spider 模式只发 HEAD,速度快、不下载内容
wget --spider -r -l 3 -nd --no-check-certificate \
-e robots=off -o /tmp/wget.log https://www.example.com/
grep -i 'broken link\|404' /tmp/wget.log | awk '{print $3}' | sort -u找到之后在数据库里批量替换掉。Typecho 的文章正文存在 typecho_contents 表,改之前先备份,先 SELECT 数一下影响面:
# 先看有多少篇文章包含这个坏链接
mysql -uroot -p zz1984 -e "SELECT COUNT(*) FROM typecho_contents
WHERE text LIKE '%/archives/123.html%';"
# 确认后替换(把旧链接换成 301 目标,或直接删掉链接保留文字)
mysql -uroot -p zz1984 -e "UPDATE typecho_contents
SET text = REPLACE(text,'/archives/123.html','/archives/new-slug.html')
WHERE text LIKE '%/archives/123.html%';"有个细节值得注意:替换链接时优先保留锚文本,不要整句删掉。比如「参见这篇讲 Nginx 压缩的文章」,如果目标页没了,最佳做法是把链接指到最相关的新页面,其次是去掉 a 标签保留文字(这样至少上下文还在)。最差的做法是把整句话删了,那会破坏上下文,读者读到这里会觉得断裂。
第七步:做一次,然后让它自己跑
死链是会持续产生的——你改一次 URL 结构、删一篇文章、换一次主题,就可能出现新的死链。所以这件事必须自动化,否则三个月后又回到原点。最简单的做法是一个每周跑一次的脚本,只报告、不自动改:
#!/bin/bash
# /usr/local/bin/deadlink-weekly.sh
UA='Mozilla/5.0 (compatible; LinkCheck/1.0)'
OUT=/var/log/linkcheck-$(date +%F).txt
: > "$OUT"
# 1) 从日志里提取上周的 404
awk '$9==404 {print $7}' /var/log/nginx/access.log.1 \
| sed 's/?.*//' | sort | uniq -c | sort -rn | head -50 \
>> "$OUT"
# 2) 抽查 sitemap 里每个 URL 的状态码(限速)
curl -s https://www.example.com/sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed 's/<loc>//; s|</loc>||' \
| while read -r u; do
code=$(curl -s -o /dev/null -w '%{http_code}' -A "$UA" --max-time 10 "$u")
[ "$code" != "200" ] && echo -e "$code\t$u" >> "$OUT"
sleep 0.3
done
# 3) 只报告,不动作;有内容就留一份,空了删掉
[ -s "$OUT" ] && echo "see $OUT" || rm -f "$OUT"这个脚本有三个设计上的取舍值得说。第一,只报告不改:自动改 301 是危险的,一个正则写错可能把全站重定向到一个页面,这种事在半夜无人值守时发生,第二天就是灾难。第二,限速:sleep 0.3 看起来慢,但五百个链接也就两分半,而你如果不限速,自己把自己打成 429 是常有的事,扫描结果全变成假的。第三,空文件就删:没有死链的一周不留文件,免得日志目录里堆满无用的空文件,让人失去查看的意愿。
最后说一下开头那个坑的教训。当时我的脚本把 304、403、503 全算成死链,又把带参数的 URL 当成死链(因为带参数返回了 302 到不带参数的版本),结果一大批正常页面被我从 sitemap 里删了。现在的我会在动手之前先跑一遍分类统计:
# 拿到扫描结果后,先看分布,再决定动作
awk -F'\t' '{print $1}' /tmp/scan.tsv | sort | uniq -c | sort -rn
# 期望的输出大致是:绝大多数 200,少量 301,极少量 404
# 如果 404 占了 30% 以上,先怀疑自己的扫描方式(UA、限速、参数)
# 只有在确认扫描本身没问题之后,才开始按五类处置一句话总结:死链治理的难点不在修,而在分类。把 404、301 链、软 404、临时失败、304 分清楚,该修的修、该留的留,你的索引和权重会稳很多;分不清就一律动手,往往修掉的比毁掉的少。