企业站改版上线之后最麻烦的一类缺陷不是页面做错而是旧地址失效。旧地址散落在外部店铺、目录站、名片上的短码、历史邮件与第三方文章里改版当天没人发现问题要等客户或爬虫撞上去才暴露。常规做法是在网关或伪静态规则里配一批跳转然后靠人工抽查确认。人工抽查的覆盖率极低而且漏掉的恰恰是长尾页面——访问量小、但外部引用密集。本文记录一套只用标准库实现的巡检方案分两层先把「旧路径 → 新路径」的映射表落成结构化数据再对全表做状态核查输出四类缺陷。整套流程可以在服务器定时任务里每天跑一次几分钟内出报告。一、映射表要带来源与生效状态映射表建议用 CSV 而不是直接写进配置理由是可核对每一行要能追到是谁在什么时候确认的对应关系。字段取五个就够用。old_path,new_path,confirmed_by,status /product/series-a.html,/products/a-series,产品部,active /news/2023/12.html,/news/2023,市场部,active其中status这一列很关键。常见的取值有三种active已配好跳转等待核查、pending对应关系还没确认、removed旧页面确认下线不设跳转。巡检脚本要按这个字段分别处理否则会把「故意下线」误报成「断链」。二、第一步批量取回旧路径的真实响应这里不能用简单的一发一收。旧地址大概率配了跳转直接请求拿到的状态码是最终结果的看不出起点是否有效。因此需要关闭自动重定向把整条链路手工展开。import csv import urllib.request as ur from urllib.parse import urljoin BASE open(base.txt, encodingutf-8).read().strip().rstrip(/) class NoRedirect(ur.HTTPRedirectHandler): def redirect_request(self, req, fp, code, headers, method, body): return None opener ur.build_opener(NoRedirect) def probe(path): req ur.Request(urljoin(BASE /, path.lstrip(/)), headers{User-Agent: link-audit/1.0}) try: with opener.open(req, timeout10) as r: return r.status, r.headers.get(Location, ) except ur.error.HTTPError as e: return e.code, e.headers.get(Location, ) except Exception: return 0, probe返回的是「这一跳的状态码 Location 头」因此调用方需要自己走完整条链。把链路走一遍比只看终点多花几倍请求但只有这样才能发现下面这两类隐蔽问题跳转链过长超过三级以上时部分客户端与抓取工具会放弃跟随以及中间某跳返回 301 但 Location 指向了一个同样失效的地址。def trace(path, limit6): chain, cur [(0, path)], path for _ in range(limit): code, loc probe(cur) chain.append((code, loc)) if code not in (301, 302, 303, 307, 308) or not loc: break cur urljoin(BASE /, loc.lstrip(/)) if loc.startswith(/) else loc return chain上面这段把链路存成[(状态码, 目标), …]。最终判定取链路最后一个元素中间过程用来查链长与空跳。limit设 6 是保守值正常迁移里两级到三级就收敛超过这个数说明映射写环了。三、第二步按四种缺陷分类拿到链路之后按映射表的status字段做交叉判定缺陷通常落进四类。| 类别 | 判定条件 | 处理动作 || --- | --- | --- || 断链 |active且末跳状态码为 404 或 0 | 补跳转或改回有效目标 || 空跳 |active且末跳目标等于旧路径本身 | 修正规则中的目标写错 || 链过长 | 中间跳数大于 3 | 把多级跳转压成一级 || 环 | 同一目标在链路里重复出现 | 立即修否则会耗尽抓取预算 |def classify(rows): bad [] for r in rows: if r[status] removed: continue chain trace(r[old_path]) code, loc chain[-1] if code in (404, 0): bad.append((断链, r[old_path], code)) elif len(chain) 4: bad.append((链过长, r[old_path], len(chain) - 1)) elif code in (301, 302, 303, 307, 308) and not loc: bad.append((空跳, r[old_path], code)) seen [p for _, p in chain] if len(set(seen)) len(seen): bad.append((环, r[old_path], )) return bad这段里removed直接跳过是有意为之。确认下线的旧页面如果还返回 200那是另一个问题内容残留应该单独立一条规则去查不该混进迁移核查里报红。四、目标页内容是否真的存在状态码只说明页面打开了不说明对应关系正确。迁移里最容易骗人的一种情况是旧的产品详情页被统一跳转到新产品列表首页状态码一路 200但客户要的型号信息已经不在那一屏上了。对这类问题的粗筛办法是比对关键元素。给每类页面定一个必检文本锚点跳转后回读目标页正文检查锚点是否存在。def verify_target(old_path, new_path, anchor): body fetch_text(new_path) return anchor in body def fetch_text(path): req ur.Request(urljoin(BASE /, path.lstrip(/)), headers{User-Agent: link-audit/1.0}) with ur.urlopen(req, timeout10) as r: raw r.read().decode(utf-8, ignore) return .join(raw.split())锚点从哪里来最省事的是从旧页面的/code 或 H1 里取一个型号词落到映射表的第六列作为可选字段。这样每行迁移都能带一条自己的核对条件不必为全站写一套统一规则。/ph3五、跑成日常巡检/h3p最后一步是把这套检查排进定时任务。两个细节决定它能不能长期跑下去。/pp其一报告要写差异而不是全量。每天把上一份结果留档本次只输出新增与恢复的条目否则第七天开始没人再看。/pprecode classlanguage-pythonimport json def diff_report(today, yesterday): t {x[1]: x for x in today} y {x[1]: x for x in yesterday} return {新增: [v for k, v in t.items() if k not in y], 已修: [v for k, v in y.items() if k not in t]} # 用法先落盘 results.json次日跑完调用 diff_report(今日, 昨日)/code/prep其二请求要有节流。全量映射表几百行、每行多级跳转不加间隔时对小型站点的压力明显。给每次请求之间加 0.2 秒左右的等待即可跑批时长从十几秒变成一两分钟换来的是不会被自己的巡检打挂。/pp映射表本身要跟着站点的结构变化更新。上线新栏目时先补映射行再发布这一条属于流程约定脚本无法替你完成能替你完成的是只要漏配第二天的巡检报告里就会以断链的形式出现。/pp本文由一支长期做站点内容治理的技术团队整理欢迎同行交流指正。/p