ISSUE-0076:域名状态机被页级信号污染¶
现象(用户截图 v0.10.114)¶
3 个被错判的网站,手动浏览器打开都能访问:
| 网站 | 标记状态 | 实际可访问? | UI tooltip |
|---|---|---|---|
tingeyorthodontics.com |
dead | ✅ | "此网站此前多次失败" |
jbjcortho.com/gustavo-garcia-md/ |
dead | ✅(root 能开) | 同上 |
moleskidental.com |
cold | ✅ | "此域名近期反复失败" |
域名状态机统计(导出日志):2939 dead + 37 cold,绝大多数是误判。
根因 — 3 个独立设计错误¶
错误 1:ok-empty 升级 cold 是「页级 → 站级」信号污染¶
domain-state.ts:225 原代码:
if (outcome === 'ok-empty' && stat.state !== 'cold') {
// 5 次 ok-empty → cold
setState(stat, 'cold');
}
反例 — moleskidental.com:
- recent[5 entries] 全部 method=fetch outcome=ok-empty
- 时间跨度 1.8 天(每次 25 分钟 ~ 1 天间隔,明显是 5 个不同子页)
- ContactPool 通过 url-hash 已做页级去重 → 5 次必然是 5 个不同 URL
- 每个子页没找到 contact 是页级问题(contact 可能在首页 / /contact 页)
- 但整域名被升级 cold → 同域其他子页(含 root)后续全跳过
用户洞察:「无联系方式不代表是死站,可能就是没有;一个页面没 contact 不代表整站没」— 完全正确。
错误 2:单次 dead 直接降级,无视历史 ok 记录¶
domain-state.ts:175 原代码:
反例 — jbjcortho.com:
- recent[0]: {method: fetch, outcome: ok-contact} ← 首页 fetch 成功拿到 5 contacts
- recent[1]: {method: head, outcome: dead} ← 某子页 HEAD 失败
- 结果:整站 state=dead(30 天 TTL)
- 后续 gustavo-garcia-md/ / dr-hightower-2/ 等子页全部跳过
- 首页能采的网站,被一次子页 HEAD 失败打死了
错误 3:404 不试 root URL¶
probe.ts:29 原代码:
反例:商家在地图给的 website 是 http://jbjcortho.com/gustavo-garcia-md/。
- 该医生页早就 404 / 改路径
- root jbjcortho.com 仍正常运行
- 应该自动 fallback 试 root(用户朴素预期)
- 旧代码直接 dead 返回 → 配合错误 2 整站降级
修复 (v0.10.116)¶
#1 probe.ts:404 → root URL fallback¶
const directResult = await probeSingleUrl(url, timeoutMs);
if (directResult.kind === 'dead' && (directResult.reason === '404' || directResult.reason === '410')) {
const rootUrl = getRootUrl(url);
if (rootUrl) {
const rootResult = await probeSingleUrl(rootUrl, timeoutMs);
if (rootResult.kind !== 'dead') {
return { ...rootResult, finalUrl: rootUrl }; // ← root 活,用 root
}
}
}
return directResult;
probe.finalUrl 改为 root URL 后,pipeline fetchUrl = probe.finalUrl || url 自动用 root;ContactPool 按 root URL hash 缓存;v0.10.114 batchDedupeByUrl 让同 root 多商家共享 contact。
#2 domain-state.ts:dead 加历史感知¶
if (outcome === 'dead') {
const hasOkHistory = stat.recent.some(e => e.outcome === 'ok-contact' || e.outcome === 'ok-empty');
if (hasOkHistory) {
const recentDead = stat.recent.slice(-3); // DEAD_CONSECUTIVE_AFTER_OK
if (recentDead.length >= 3 && recentDead.every(e => e.outcome === 'dead')) {
setState(stat, 'dead'); // 连续 3 次 dead 才升级
}
return;
}
setState(stat, 'dead'); // 没有 ok 历史时维持原逻辑
}
#3 domain-state.ts:砍 ok-empty → cold¶
直接删除该规则。contact 提取失败是页级信号,通过 ContactPool url-hash 缓存已经做了页级去重,不需要域名级升级。
#4 启动迁移 revivePollutedDomains()¶
engine 启动跑一次:
- 所有 cold 域名 → 复活为 unknown(cold 规则废除)
- 所有 dead 域名 + recent 含 ok-* 历史 → 复活为 unknown
- 累计统计 (fetchTotal/fetchOk) 保留
#5 设置面板「复活误判」按钮¶
domain-state-panel.tsx:用户可手动触发 revivePollutedDomains(),弹 toast 显示「已复活 N / M」。
#6 UI 文案修正¶
mstage-classify.ts humanizeMstageDetail:
- mstage:no-contact → "此页面没找到邮箱/电话/社媒(不代表整站没有;可能首页/contact 页才有)"
- mstage:dead → "网站完全无法访问(DNS / 超时;首页也失败)"
- mstage:domain-cold → "v0.10.116 起此规则已废除,下次会自动重试"
通用模式(写进新 rule)¶
信号维度纪律 — 页级信号不能升级到站级状态:
| 信号 | 维度 | 该影响 |
|---|---|---|
| HEAD/fetch 网络层失败 | 可能站级 | 累积证据后升 dead(要历史感知) |
| 404/410(明确状态码) | 页级 | 仅当 root 也失败才站级 |
| antibot 拦截 | 通常站级 | 升 antibot-soft/hard |
| ok-empty (contact 没找到) | 页级 | 不该升级(ContactPool url-hash 已缓存) |
| ok-contact | 站级正向 | 累积升 friendly |
预期效果¶
用户 2939 dead + 37 cold 域名: - 37 cold 全部复活 - 2939 dead 中含 ok 历史的大部分复活(估计 60%~80%) - jbjcortho 等案例:root 拿到 contact + 同 root 子页 pool-hit - 同 root 多商家通过 batchDedupeByUrl 自动复用
教训¶
- 状态机的每条转换规则都要问"这是页级还是站级" — 错位升级 = 隐性 bug
- 数据看似"5 次都失败 = 该跳",但实际可能是"5 个不同子页"(用户问"5 次是同 URL 还是不同子页"是关键洞察)
- 降级要历史感知 — 已 ok 过的不能因为一次失败就降级 30 天
- fallback 链要完整 — 404 不该是终点,试一次 root 几乎 0 成本