跳转至

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 原代码:

if (outcome === 'dead') {
  setState(stat, 'dead');   // ← 立即降级,30 天 TTL
}

反例 — 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 原代码:

if (status === 404 || status === 410) return { kind: 'dead', reason: String(status) };

反例:商家在地图给的 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 成本