ISSUE-0088:勾选「跳过无邮箱商家」导致地图抓取 100% 不入库¶
症状¶
用户在设置页勾选「跳过无邮箱商家」后运行任意地图抓取任务,一条数据都存不下来。 无报错、无日志、无异常提示 — 表现得像网络问题或反爬拦截。
根因¶
过滤器在邮箱尚未产生的阶段判断邮箱字段。
src/utils/jsstore/search-data.ts:257 从 item 解构 emails:
: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-55 的 TaskData 也无该字段。
⇒ Array.isArray(undefined) === false ⇒ 条件恒真 ⇒ saveTag 恒 false ⇒ 无一行进入写入数组(:316-319)。
邮箱实际由后续的网站抓取阶段产生,经 scraper-executor.ts 的 updateByQuery 直接写库,
根本不经过 addSearchData。过滤器装错了阶段。
链条闭合性(对抗审查补充)¶
- 全仓
addSearchData仅此 3 个调用点 MapTaskData唯一插入路径是search-data.ts:322的insertMany,无旁路导入 / 云端下拉直写- parse 与 addSearchData 之间不存在任何补 emails 的 enrich
(
engine-manager.ts:222的 emails 回写发生在插入之后)
UI 入口:src/sections/settings/view/settings-view.tsx:1027 复选框 value=deleteNoEmail。
修复方向¶
deleteNoEmail 的语义属于「网站抓取完成之后」,不属于「地图搜索结果落库」。
- 把该判断移到
scraper-executor.ts写回 emails 之后执行 - 过渡期至少在
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-contact 传 true 被硬化闸打死,注释说会删、代码永不删 |
terra + dshf 同时指向同一行 |
| R4 | 文档三处描述停在旧语义,与代码相反 | dshf |
🔴 元教训:R2 R3 R4 的病根是同一个 —— 同一件事被两个不一致的来源表达 (值与语义、载荷与标志、代码与文档)。修的时候每次都只统一了其中一对。
回归测试¶
7 个文件 42 个用例。其中 tests/finalize-guard-behavior.test.ts 是行为级
(真跑 finalizeScrapedRowsBatch + mock 数据库),覆盖:守卫拦截、一批里精确分流、
回读失败保守保留、删除失败留痕抛出、未定论时连回读都不发生、R3 接缝回归。
⚠️ 其余测试多为源码文本断言 —— 能防重构改名,防不住逻辑走样(R3 接缝就是它没抓到的)。
写这类断言时锚点必须选函数定义或写库调用,不能用裸字符串:
R3 和 R4 各犯过一次(匹配到 import 行 / appendPageLog 的日志字段),断言在验空气。