跳转至

ISSUE-0088:勾选「跳过无邮箱商家」导致地图抓取 100% 不入库

症状

用户在设置页勾选「跳过无邮箱商家」后运行任意地图抓取任务,一条数据都存不下来。 无报错、无日志、无异常提示 — 表现得像网络问题或反爬拦截。

根因

过滤器在邮箱尚未产生的阶段判断邮箱字段。

src/utils/jsstore/search-data.ts:257 从 item 解构 emails

const { googleId, website, phone, domain, rating, commentCount, category, emails } = item;

:263-266 据此过滤:

if (saveTag && advParms.includes('deleteNoEmail')) {
  if (!Array.isArray(emails) || emails.length === 0) saveTag = false;
}

addSearchData 的三个调用点传入的对象全都没有 emails

调用点 实参来源 有 emails 吗
src/entrypoints/background/task-manager.ts:201-210 手工构造 {googleId, website, name, scrape_status}
src/entrypoints/background/batch-controller.ts:424-445 parsePage(resData, keyword)
src/entrypoints/background/batch-controller.ts:590-605 parsePage(r.text, job.keyword)

src/utils/parse-data.ts 全文 emails 零命中;src/types/data.ts:8-55TaskData 也无该字段。

Array.isArray(undefined) === false ⇒ 条件恒真 ⇒ saveTagfalse ⇒ 无一行进入写入数组(:316-319)。

邮箱实际由后续的网站抓取阶段产生,经 scraper-executor.tsupdateByQuery 直接写库, 根本不经过 addSearchData。过滤器装错了阶段。

链条闭合性(对抗审查补充)

  • 全仓 addSearchData 仅此 3 个调用点
  • MapTaskData 唯一插入路径是 search-data.ts:322insertMany,无旁路导入 / 云端下拉直写
  • parse 与 addSearchData 之间不存在任何补 emails 的 enrichengine-manager.ts:222 的 emails 回写发生在插入之后)

UI 入口:src/sections/settings/view/settings-view.tsx:1027 复选框 value=deleteNoEmail

修复方向

deleteNoEmail 的语义属于「网站抓取完成之后」,不属于「地图搜索结果落库」。

  1. 把该判断移到 scraper-executor.ts 写回 emails 之后执行
  2. 过渡期至少在 addSearchData 加运行时检测:deleteNoEmail 生效且整批 emails 全为 undefined 时输出告警日志

🔴 不许用「禁掉这个选项」了事 — 见 no-mutilation-fix

回归判据

  • 勾选该选项跑地图任务 → 有数据入库
  • 网站抓取后确实无邮箱的行 → 被跳过或删除
  • 单元测试覆盖:见下方「回归测试」(Phase 0 起本仓库已接入 vitest,pnpm test 挂进 pnpm verify

发现过程

2026-08-30 多 agent 对抗审查发现,经 terra(openai) 与 dshf(deepseek) 跨厂商双路独立复现确认。

最终修复(R4 · 2026-08-31)

经四轮返工 + 六家外部模型并行给方案,最终语义由 Tony 拍板定案。

判定机制

shouldDropForNoEmail(advParms, item, emailFinalized) —— 第 3 参由调用点显式声明 「这一行的邮箱结果定论了没有」。用函数签名而非载荷标记:漏传是编译错误, 不是运行时才发现的静默错误(方案来自 deepseek-v4-pro)。

判定内还有一道反伪造硬化:声称定论却不带真实 emails 数组,一律保守按未定论处理。 ⇒ 载荷与标志必须成对,只传 true 不带数组会被拦下。

八个调用点的定论取值

位置 emailFinalized 会不会删
engine-manager 无官网(!t.website false 不删
engine-manager 域名黑名单命中 false 不删
engine-manager batchDedupeByUrl 复用 srcEmails.length > 0 源行真有邮箱才算定论
scraper-executor 纯社媒直链 false 不删(链接已存入社媒字段)
scraper-executor 同 URL 复用 srcEmails.length > 0 同上
scraper-executor tab 全量抓取 true 真实抓完,空也是定论
scraper-executor pipeline success true 同上
scraper-executor pipeline skip reason === 'no-contact' 只有真读完页面才算

🔴 只有「官网抓取完整跑完但没找到邮箱」会被删。 判的只是邮箱 —— 有电话或社媒也照样算「无邮箱」。

无官网不删的理由(Tony 2026-08-31 拍板):已有独立的 deleteNoUrl 选项表达 「不要无官网商家」,两个正交选项不该坍缩成一个;且误删不可逆、保留可人工筛。 纯社媒直链同理。

删除前回读守卫(方案来自 glm-5.3)

即使判定说「确认没有」,仍会回读数据库核实:行里真存着邮箱的一律不删, 也不用空数组覆盖它已有的邮箱;回读失败则保守保留。配 sys-log delete-no-email-guard-kept / delete-no-email-verify-failed / delete-no-email-remove-failed

这是中心化兜底 —— 任何调用方把语义传错,最后这道都救得回来。 本功能连炸两轮,这层保险是必要的。

四轮返工的病史(留作判据)

病症 谁发现
R1 两条漏网路径:无官网 / 黑名单不进 processWebsiteScrape terra + dshf
R2 语义伪造:把「未知」和「确认为空」压成同一个 emails: [] ⇒ 黑名单商家被永久删除 terra + dshf
R3 机制转换时走样:清掉载荷标记却没补 emails: []no-contacttrue 被硬化闸打死,注释说会删、代码永不删 terra + dshf 同时指向同一行
R4 文档三处描述停在旧语义,与代码相反 dshf

🔴 元教训:R2 R3 R4 的病根是同一个 —— 同一件事被两个不一致的来源表达 (值与语义、载荷与标志、代码与文档)。修的时候每次都只统一了其中一对。

回归测试

7 个文件 42 个用例。其中 tests/finalize-guard-behavior.test.ts行为级 (真跑 finalizeScrapedRowsBatch + mock 数据库),覆盖:守卫拦截、一批里精确分流、 回读失败保守保留、删除失败留痕抛出、未定论时连回读都不发生、R3 接缝回归。

⚠️ 其余测试多为源码文本断言 —— 能防重构改名,防不住逻辑走样(R3 接缝就是它没抓到的)。 写这类断言时锚点必须选函数定义或写库调用,不能用裸字符串: R3 和 R4 各犯过一次(匹配到 import 行 / appendPageLog 的日志字段),断言在验空气。