做企业文档库时,"内容过期"是比"内容缺失"更难处理的一类问题。缺失能被查出,过期看起来完全正常------页面在、文字通顺、参数齐全,只是说的是三年前的情况。
判断单页是否过期,常见的做法是看一个时间戳。这里的问题是三类时间戳互相矛盾的情况相当普遍:
· 服务器的 Last-Modified 来自文件系统的 mtime,运维批量迁移一次目录就全部刷新成同一天;
· 正文里写的"更新日期"由编辑手工维护,长期不动的页面它反而更可信;
· 版本字段(v2.3、2025Q4)只在少数站点存在,但一旦存在,它的语义精度高于前两者。
单独用哪一路都会错。本文给一套三路交叉的实现,输出四态判定:fresh、suspect、stale、unknown。
一、三路信号的采集
python
import re
from dataclasses import dataclass
from datetime import date, datetime
DATE_PAT = re.compile(
r"(?:更新(?:日期|时间)|最后修改|发布日期|Version|版本)\s*[::]?\s*"
r"(\d{4})[-/.年](\d{1,2})[-/.月]?(\d{1,2})?"
)
VER_PAT = re.compile(r"\b(?:v|V)?(\d{1,2})\.(\d{1,2})\b|\b(20\d{2})\s*(Q[1-4])\b")
@dataclass
class Signals:
http_modified: date | None
body_date: date | None
version: tuple | None
collected_on: date

正文日期的提取要限定关键词上下文。裸日期在正文里出现的次数太多,"2019 年交付了第一批设备"这种句子会被误抓成更新日期。加了前缀约束之后,误抓率明显下降。
一个必须处理的例外:页面底部版权行 © 2026。它在多数模板里年年刷新,若被当成更新日期,整站所有页面都会被判为"刚刚更新"。做法是把 © 后四年内的年份列入黑名单,同时把位于页面末尾 15% 区域内的裸日期降权。
python
def parse_body_date(html: str) -> date | None:
best = None
for m in DATE_PAT.finditer(html):
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3) or 1)
try:
cand = date(y, mo, d)
except ValueError:
continue
if cand > date.today(): # 未来日期视为无效
continue
if best is None or cand > best:
best = cand
return best
二、冲突消解:按信号强度定优先级
三路信号的可靠性排序,在实践中是稳定的:版本字段 > 正文日期 > HTTP 时间。理由是语义具体度递减------版本字段只能描述这份文档自己,HTTP 时间描述的是文件而非内容。

消解规则写成一张表比写成代码分支更易维护:
| 情形 | HTTP | 正文 | 版本 | 判定 |
|---|---|---|---|---|
| 三路一致 | 近 | 近 | 近 | fresh |
| HTTP 新、正文旧 | 新 | 旧 | --- | stale(迁移导致 mtime 刷新) |
| 正文新、HTTP 旧 | 旧 | 新 | --- | fresh(缓存头没配,正文可信) |
| 仅 HTTP | 有 | 无 | 无 | unknown(不做过期断言) |
| 版本落后于同系列 | --- | --- | 旧 | suspect |
对应的实现,规则表放进 SQLite,判定函数只做查表:
python
RULES = [
(("new", "old", None), "stale", "http_refreshed"),
(("old", "new", None), "fresh", "cache_header_missing"),
((None, None, None), "unknown", "no_signal"),
]
def decide(sig: Signals, horizon_days: int = 180) -> tuple:
buckets = {
"http": bucket(sig.http_modified, horizon_days),
"body": bucket(sig.body_date, horizon_days),
}
for cond, verdict, reason in RULES:
if matches(cond, buckets, sig.version):
return verdict, reason
return ("fresh" if buckets["body"] == "new" else "suspect"), "default"
def bucket(d: date | None, horizon: int) -> str | None:
if d is None:
return None
return "new" if (date.today() - d).days <= horizon else "old"

horizon_days 取 180 天,是内容型站点的常用窗口;产品参数页建议压到 90 天,新闻类可放宽到 365。这个参数不要写成常量,随页面类型走配置表。
三、同系列版本比较
版本字段的价值在于能跨页比较。同一产品系列里,A 页面写 v3.1、B 页面写 v2.4,说明至少有一处过期。
python
def version_key(v: tuple) -> tuple:
# ("semver", 3, 1) 或 ("quarter", 2025, 4)
if v is None:
return (0, 0, 0)
if v[0] == "semver":
return (1, v[1], v[2])
return (2, v[1], v[2])
比较要先归组:跨系列的版本号不能放一起排 。v3.1 属于输送线系列、v2.4 属于提升机系列,两个版本号谁新谁旧毫无意义。归组键用页面所属栏目加产品名前缀,从 URL 路径取,取不到则跳过该页,不要猜。
四、输出与复核
判定结果落地成三列一张表:page_id、verdict、reason。reason 那一列是这份实现里真正有价值的部分------stale 只告诉你有问题,http_refreshed 才告诉你要去查是不是目录迁移过。
建议每轮跑完做一次抽样复核,抽的不是 stale(那批本来就会被处理),而是 unknown 里占比最高的那种页面。unknown 表示三路信号全缺,通常是模板变更让正文日期的写法变了。这类漂移不会报错,只会安静地把全站的判定质量拉低,隔一两个月扫一次 unknown 比例就能发现。
四路信号、一张规则表、一个归组键,这套实现里没有需要模型参与的部分。这也是它能在文档量大的场景里长期跑的原因:判定可解释,出错可回溯,改一行配置就能调整窗口长度。
(本文由一支长期做企业文档解析与检索工程的技术团队整理,欢迎同行交流指正。)