# 聊天时间范围抓取 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 聊天抓取支持时间范围(全部/近1/2/3月/自定义起止),到范围起点即停翻页 —— 时间范围同时是风控减压手段。 **Architecture:** spec `docs/superpowers/specs/2026-08-25-chat-time-range-design.md`(7轮审查+真机实测,含附录A实现基准伪代码、附录B实测记录)。改动全部在扩展侧(popup + content.js),后端/推送零改动。判定逻辑以 `scripts/verify-chat-range.mjs` 纯函数为基准实现,content.js/popup.js 照搬(IIFE 架构无法被 vitest import,沿用 verify-risk-detect.mjs 复刻模式)。 **Tech Stack:** Chrome MV3 扩展(原生 JS)、vitest(fake-indexeddb)、node 复刻验证脚本。 --- ## ⛔ 风控红线(全程有效,用户明确要求) 1. **不得改动任何现有安全层参数**:`randSleep(2000,5000)` 翻页间隔、`waitUntilWorkTime`、`postForm` 的 RISK 检测、`RISK_KEYWORDS`/`SKIP_KEYWORDS`。 2. **真机验证只用小规模**:手动验证(Task 8)一律 `limit=10`,单次验证 ≤ 10 个询盘,两次验证之间间隔 ≥ 2 分钟。 3. **不新增任何 API 端点调用**:只改现有 `querySummary`/`getOpMessages` 的参数(timeStamp)与本地逻辑。 4. Task 1-7 全部是本地代码/纯逻辑验证,**零真实请求**。 ## File Structure | 文件 | 职责 | |---|---| | `scripts/verify-chat-range.mjs`(新) | 判定逻辑基准实现 + 用例锁定(纯函数,零请求) | | `popup.html` | 时间范围下拉/日期框/置灰 UI | | `popup.js` | computeChatRange 计算、校验、start 传参 | | `content.js` | state 字段、fetchChatByApi 主路径、水位结构、DOM 回退 | | `docs/.../spec`(已完成) | 依据,不再改 | **同步义务**:verify 脚本中的纯函数(`minusMonths/computeChatRange/inChatRange/parseWatermark/shouldWriteWatermark/probeFailKind/toMs`)与 popup.js/content.js 内联版本必须逐字一致 —— 改任一侧必须同步另一侧(文件头注明)。 --- ### Task 1: verify-chat-range.mjs — 判定逻辑基准(TDD) **Files:** - Create: `scripts/verify-chat-range.mjs` - [ ] **Step 1: 写用例 + 桩函数(预期红)** ```js // 验证聊天时间范围判定逻辑(纯函数基准实现)。 // content.js/popup.js 为 IIFE/非 module,无法被 vitest import —— 本文件是判定逻辑的 // 基准实现+用例锁定;实现侧照搬函数体。⚠️ 改任一侧必须同步另一侧。 let pass = 0, fail = 0; const ok = (name, cond) => { console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); cond ? pass++ : fail++; }; // ---- 基准实现(实现照搬区)---- function minusMonths(base, n) { throw new Error('TODO'); } function computeChatRange(mode, sinceStr, untilStr) { throw new Error('TODO'); } function inChatRange(sendTime, sinceMs, untilMs) { throw new Error('TODO'); } function parseWatermark(v) { throw new Error('TODO'); } function shouldWriteWatermark(oldWm, candId, candSendTime) { throw new Error('TODO'); } function probeFailKind(json) { throw new Error('TODO'); } // ---- 用例 ---- ok('3-31 − 1月 = 2-28(月末钳制,setMonth 溢出防护)', (() => { const r = minusMonths(new Date(2026, 2, 31, 15, 30), 1); return r.getMonth() === 1 && r.getDate() === 28 && r.getHours() === 15; })()); ok('8-25 − 1月 = 7-25(普通日期)', (() => { const r = minusMonths(new Date(2026, 7, 25), 1); return r.getMonth() === 6 && r.getDate() === 25; })()); ok('12-31 − 2月 = 10-31(同年跨月)', (() => { const r = minusMonths(new Date(2026, 11, 31), 2); return r.getMonth() === 9 && r.getDate() === 31; })()); ok('1-31 − 2月 = 上年 11-30(跨年+钳制)', (() => { const r = minusMonths(new Date(2026, 0, 31), 2); return r.getFullYear() === 2025 && r.getMonth() === 10 && r.getDate() === 30; })()); ok('闰年:2028-03-31 − 1月 = 2028-02-29', (() => { const r = minusMonths(new Date(2028, 2, 31), 1); return r.getMonth() === 1 && r.getDate() === 29; })()); ok('all → 双 null(完全同现状)', computeChatRange('all').since === null && computeChatRange('all').until === null); ok('1m → since 在 25~32 天前,until 不限', (() => { const r = computeChatRange('1m'); const age = Date.now() - Date.parse(r.since); return r.until === null && age > 25 * 86400e3 && age < 32 * 86400e3; })()); ok('custom 本地时区(since=本地0点,非 UTC)', (() => { const r = computeChatRange('custom', '2026-08-25', '2026-08-25'); const s = new Date(r.since); return s.getMonth() === 7 && s.getDate() === 25 && s.getHours() === 0 && s.getMinutes() === 0; })()); ok('custom until=止日 23:59:59.999 本地(含全天)', (() => { const r = computeChatRange('custom', '2026-08-01', '2026-08-25'); const u = new Date(r.until); return u.getDate() === 25 && u.getHours() === 23 && u.getMinutes() === 59 && u.getMilliseconds() === 999; })()); ok('custom 起>止 → error', !!computeChatRange('custom', '2026-08-10', '2026-08-01').error); ok('custom 起为空 → error', !!computeChatRange('custom', '', '2026-08-01').error); ok('custom 止>今天 → error', !!computeChatRange('custom', '2026-01-01', '2099-01-01').error); const SINCE = Date.parse('2026-07-25T00:00:00'), UNTIL = Date.parse('2026-08-25T23:59:59'); ok('since 当天消息收(含边界)', inChatRange(SINCE, SINCE, null)); ok('since 前一天不收', !inChatRange(SINCE - 86400e3, SINCE, null)); ok('until 末毫秒收(含边界)', inChatRange(UNTIL, null, UNTIL)); ok('until 次日不收', !inChatRange(UNTIL + 1, null, UNTIL)); ok('sendTime=null 防御性保留', inChatRange(null, SINCE, UNTIL)); ok('范围不限(null)全收', inChatRange(1, null, null)); ok('新格式水位解析', (() => { const w = parseWatermark('{"id":"a1","sendTime":1787626659384}'); return w.id === 'a1' && w.sendTime === 1787626659384; })()); ok('旧格式(纯string)→ legacy,sendTime 未知', (() => { const w = parseWatermark('abc123'); return w.id === 'abc123' && w.sendTime === null; })()); ok('只前进:候选6-30 不覆盖 7-28', !shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-06-30'))); ok('只前进:候选8-25 覆盖 7-28', shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-08-25'))); ok('legacy 允许覆盖一次(升级)', shouldWriteWatermark({ id: 'o', sendTime: null }, 'n', Date.parse('2026-06-30'))); ok('无旧水位 → 写入', shouldWriteWatermark(null, 'n', 1)); ok('候选缺 sendTime → 不写', !shouldWriteWatermark({ id: 'o', sendTime: 5 }, 'n', null)); ok('probe 正常(200+非空) → null', probeFailKind({ code: '200', data: { list: [{}] } }) === null); ok('probe code≠200 → fail 降级', probeFailKind({ code: '500' }) === 'fail'); ok('probe data=null → fail 降级', probeFailKind({ code: '200', data: null }) === 'fail'); ok('probe 非对象 → fail 降级', probeFailKind('ok') === 'fail'); ok('probe 空 list → empty 降级(实测形态)', probeFailKind({ code: '200', data: { list: [] } }) === 'empty'); ok('probe list 缺失 → empty 降级', probeFailKind({ code: '200', data: {} }) === 'empty'); // toMs:parse 容错(spec §5:非法 → null 降级不限) function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; } ok('toMs 非法/空 → null,合法 → 毫秒', toMs('garbage') === null && toMs(null) === null && toMs('2026-08-25T00:00:00Z') === Date.parse('2026-08-25T00:00:00Z')); // until 游标 = untilMs+1:附录B实测语义=返回 sendTime < cursor,故恰好覆盖 <= untilMs 全部、不漏不重 ok('until 游标 +1ms 恰好覆盖含止日末毫秒', (() => { const untilMs = Date.parse('2026-06-30T23:59:59.999'); const cursor = untilMs + 1; return (untilMs < cursor) && !(untilMs + 1 < cursor); })()); console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); ``` - [ ] **Step 2: 跑,确认红** Run: `node scripts/verify-chat-range.mjs` Expected: 脚本在**第一个用例即崩溃**(stack trace: `Error: TODO`)—— 桩函数的 throw 发生在用例 IIFE 内,无法逐行 FAIL;崩溃 + exit 1 即为有效红灯 - [ ] **Step 3: 填基准实现(替换 6 个桩函数)** ```js // 自然月回退:直接 setMonth(-n) 月末溢出(3-31−1月→"2-31"→3-02),先退月初再钳制天数 function minusMonths(base, n) { const d = new Date(base.getFullYear(), base.getMonth(), 1, base.getHours(), base.getMinutes(), base.getSeconds(), base.getMilliseconds()); d.setMonth(d.getMonth() - n); const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); d.setDate(Math.min(base.getDate(), lastDay)); return d; } // popup 范围计算:mode=all|1m|2m|3m|custom → {since,until}(ISO|null) 或 {error} function computeChatRange(mode, sinceStr, untilStr) { if (mode === 'all') return { since: null, until: null }; if (mode === '1m' || mode === '2m' || mode === '3m') { return { since: minusMonths(new Date(), Number(mode[0])).toISOString(), until: null }; } if (mode === 'custom') { if (!sinceStr || !untilStr) return { error: '请填写自定义起止日期' }; const s = new Date(sinceStr + 'T00:00:00.000'); // ⚠️ 必须拼时间:裸 date-only 按 UTC 解析差 8h const u = new Date(untilStr + 'T23:59:59.999'); if (isNaN(s.getTime()) || isNaN(u.getTime())) return { error: '日期格式错误' }; if (s.getTime() > u.getTime()) return { error: '起始日期不能晚于截止日期' }; const uDay = new Date(untilStr + 'T00:00:00.000'); const t0 = new Date(); t0.setHours(0, 0, 0, 0); if (uDay.getTime() > t0.getTime()) return { error: '截止日期不能晚于今天' }; return { since: s.toISOString(), until: u.toISOString() }; } return { error: '未知时间范围模式' }; } // 范围过滤(null sendTime 防御性保留 —— 宁多勿漏) function inChatRange(sendTime, sinceMs, untilMs) { if (sendTime == null) return true; if (sinceMs != null && sendTime < sinceMs) return false; if (untilMs != null && sendTime > untilMs) return false; return true; } // 水位读取兼容:旧=纯 string(sendTime 未知) / 新={id,sendTime} function parseWatermark(v) { if (v == null || v === '') return null; try { const o = JSON.parse(v); if (o && typeof o === 'object' && o.id != null) return { id: String(o.id), sendTime: Number(o.sendTime) || null }; } catch (e) { /* 旧格式继续 */ } return { id: String(v), sendTime: null }; } // 水位只前进写入判定 function shouldWriteWatermark(oldWm, candId, candSendTime) { if (candId == null || candSendTime == null) return false; if (oldWm == null) return true; if (oldWm.sendTime == null) return true; // legacy 升级,允许覆盖一次 return candSendTime > oldWm.sendTime; } // probe 轮断言分类:'fail'(非对象/code≠200/data=null) | 'empty'(空list) | null(正常)。 // non-JSON/HTTP/关键词由 postForm 抛 RISK,不经此函数(真风控照抛,不降级) function probeFailKind(json) { if (!json || typeof json !== 'object') return 'fail'; if (json.code !== undefined && Number(json.code) !== 200) return 'fail'; if (json.data == null) return 'fail'; const list = json.data && json.data.list; if (!Array.isArray(list) || list.length === 0) return 'empty'; return null; } ``` - [ ] **Step 4: 跑,确认全绿(36 passed)** Run: `node scripts/verify-chat-range.mjs` Expected: `36 passed, 0 failed`,exit 0 - [ ] **Step 5: Commit** ```bash git add scripts/verify-chat-range.mjs git commit -m "feat(ext): chat time-range predicate baseline + verification cases" ``` --- ### Task 2: popup UI + 传参 **Files:** - Modify: `popup.html`(options 区,chatLimitInput 行附近) - Modify: `popup.js`(元素引用、syncChatModeUI、start 计算) - [ ] **Step 1: popup.html 加控件** 在"每询盘聊天上限"label 行后追加(chatLimit label 内加 hint span): ```html ``` - [ ] **Step 2: popup.js 加引用 + 模式切换 + 计算传参** 元素引用区(文件顶部 getElementById 区)追加: ```js const chatTimeRangeSelect = document.getElementById('chatTimeRange'); const chatCustomDates = document.getElementById('chatCustomDates'); const chatSinceDate = document.getElementById('chatSinceDate'); const chatUntilDate = document.getElementById('chatUntilDate'); const chatLimitHint = document.getElementById('chatLimitHint'); ``` (在 push-config 区之前)追加模式切换 + 基准函数照搬(注明同步义务,函数体与 verify-chat-range.mjs **逐字一致**): ```js // ==== 聊天时间范围(spec 2026-08-25 §4.1)==== // minusMonths/computeChatRange 与 scripts/verify-chat-range.mjs 基准实现逐字同步 function minusMonths(base, n) { /* 同 verify-chat-range.mjs */ } function computeChatRange(mode, sinceStr, untilStr) { /* 同 verify-chat-range.mjs */ } function syncChatModeUI() { const mode = chatTimeRangeSelect.value; const custom = mode === 'custom'; chatCustomDates.style.display = custom ? 'block' : 'none'; if (custom && !chatUntilDate.value) { const t = new Date(); chatUntilDate.value = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`; } const timeMode = mode !== 'all'; document.getElementById('chatLimitInput').disabled = timeMode; // D1: 时间替代条数 chatLimitHint.textContent = timeMode ? '(时间模式下忽略)' : ''; } chatTimeRangeSelect.addEventListener('change', syncChatModeUI); syncChatModeUI(); ``` btnStart click 里,`send('start', {...})` **之前**插入校验,并改传参: ```js const range = computeChatRange(chatTimeRangeSelect.value, chatSinceDate.value, chatUntilDate.value); if (range.error) { addLog(range.error, 'error'); statusText.textContent = '就绪'; updateButtons('idle'); return; } ``` `send('start', {...})` 的参数改为: ```js await send('start', { mode: modeSelect.value, dedup: optDedup.checked, chat: optChat.checked, limit: parseInt(limitSelect.value, 10), chatLimit: chatTimeRangeSelect.value === 'all' ? (parseInt(document.getElementById('chatLimitInput').value, 10) || 0) : 0, chatSince: range.since, chatUntil: range.until, }); ``` - [ ] **Step 3: 语法自检** Run: `node --check popup.js 2>&1 || echo "popup.js 非 module,--check 对 ESM 误报可忽略;改用浏览器加载验证"` 手动:重载扩展 → 打开 popup → 切下拉:custom 显示日期框且止=今天;时间模式下条数框置灰;切回 all 恢复。(零请求) - [ ] **Step 4: Commit** ```bash git add popup.html popup.js git commit -m "feat(ext): chat time-range picker in popup (all/1m/2m/3m/custom)" ``` --- ### Task 3: content.js — state 字段 + 派生 **Files:** - Modify: `content.js`(start 分支 state 重建对象;fetchChatByApi/loadAllChatHistory 入口) - [ ] **Step 1: start 分支重建对象加两字段** `case 'start':` 的 `state = {...}` 对象中,`chatLimit: msg.chatLimit ?? 0,` 行后加: ```js chatSince: msg.chatSince ?? null, chatUntil: msg.chatUntil ?? null, // 时间范围(spec §4.2);显式列入重建清单 ``` - [ ] **Step 2: 文件顶部(IIFE 内、RISK_KEYWORDS 之前)加范围工具(照搬 verify + 派生)** ```js // ==== 聊天时间范围工具(与 scripts/verify-chat-range.mjs 基准逐字同步)==== function inChatRange(sendTime, sinceMs, untilMs) { /* 同 verify-chat-range.mjs */ } function parseWatermark(v) { /* 同 verify-chat-range.mjs */ } function shouldWriteWatermark(oldWm, candId, candSendTime) { /* 同 verify-chat-range.mjs */ } function probeFailKind(json) { /* 同 verify-chat-range.mjs */ } function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; } // 同 verify-chat-range.mjs 照搬区(parse 失败→null 降级不限,spec §5) ``` - [ ] **Step 3: fetchChatByApi 与 loadAllChatHistory 入口各派生一次** ```js const sinceMs = toMs(state.chatSince), untilMs = toMs(state.chatUntil); ``` - [ ] **Step 4: 手动自检(零请求)** 重载扩展 → popup"开始抓取"选"全部" → 行为与现状一致(state 字段为 null 短路所有新逻辑);SW console 无报错。 - [ ] **Step 5: Commit** ```bash git add content.js git commit -m "feat(ext): chatSince/chatUntil state plumbed through start" ``` --- ### Task 4: content.js — 水位存储升级(只前进) **Files:** - Modify: `content.js`(`getLastTopMsgId`/`setLastTopMsgId` 改造为 `getWatermark`/`trySetWatermark`) - [ ] **Step 1: 替换两个函数** 删除 `getLastTopMsgId`/`setLastTopMsgId`,替换为(键名 `lastTopMsg_` 不变,clear 分支无需改): ```js // 增量水位(新格式 {id,sendTime};只前进不倒退,spec §4.5) async function getWatermark(inquiryId) { if (!inquiryId) return null; try { const r = await chrome.storage.local.get(['lastTopMsg_' + inquiryId]); return parseWatermark(r && r['lastTopMsg_' + inquiryId]); } catch (e) { return null; } } async function trySetWatermark(inquiryId, candId, candSendTime) { if (!inquiryId) return; const oldWm = await getWatermark(inquiryId); if (!shouldWriteWatermark(oldWm, candId, candSendTime)) return; try { await chrome.storage.local.set({ ['lastTopMsg_' + inquiryId]: JSON.stringify({ id: candId, sendTime: candSendTime }) }); } catch (e) { /* 持久化失败不致命 */ } } ``` 注:调用点改造在 Task 5(fetchChatByApi 内),本 Task 仅换函数定义会暂留旧调用 → **Task 4 与 Task 5 合并提交**(见 Task 5 Step 5)。 --- ### Task 5: content.js — fetchChatByApi 主路径(spec 附录 A 落地) **Files:** - Modify: `content.js`(`// 2) 循环 getOpMessages` 起 至 `return { messages: ... }` 止的整段) - [ ] **Step 1: 替换翻页整段(对照附录 A 伪代码逐行落地)** ```js // 增量水位(新格式;旧 string 兼容读取) const lastTopWm = await getWatermark(inquiryId); const lastTopId = lastTopWm ? lastTopWm.id : ''; if (lastTopId) log(`聊天 API 增量模式: 上次水位 ${lastTopId}(sendTime=${lastTopWm && lastTopWm.sendTime}),遇此停止翻页`); if (sinceMs != null || untilMs != null) { log(`聊天范围: ${sinceMs != null ? new Date(sinceMs).toLocaleString() : '最早'} ~ ${untilMs != null ? new Date(untilMs).toLocaleString() : '至今'}`); } // 2) 循环 getOpMessages(spec 附录A:跳游标/probe/过滤/顺序/停止/水位) const jump = untilMs != null && !state.cursorJumpUnsupported; let cursor = jump ? untilMs + 1 : Date.now(); // +1ms:实测语义=严格小于,恰好覆盖 <= untilMs(附录B) let probeRound = jump; const raw = []; const MAX_ROUNDS = state.chatLimit > 0 ? Math.ceil(state.chatLimit / 20) : 2000; let candId = null, candSendTime = null; // 候选水位:逐轮取最大 sendTime(spec §4.5) let stopReason = '轮数上限'; for (let i = 0; i < MAX_ROUNDS; i++) { if (state.phase === 'paused') return null; if (!await waitUntilWorkTime('聊天翻页')) return null; if (state.phase === 'paused') return null; const json = await postForm( `https://onetalk.alibaba.com/message/getOpMessages.htm?ctoken=${encodeURIComponent(ctoken)}&_tb_token_=${encodeURIComponent(tbToken)}`, { params: JSON.stringify({ scene: '', timeSlide: { timeStamp: cursor, forward: false, pageSize: 20 }, secOwnerAccountId, secTradeId, secTargetAccountId, openRealTimeTranslation: true, targetAliId }), _csrf: xsrf } ); if (probeRound) { // 首轮探测:仅降级集走 fallback(真风控照抛,spec §4.3) probeRound = false; const kind = probeFailKind(json); if (kind) { state.cursorJumpUnsupported = false; // 先不设标志,由降级重试结果决定(实测精化) log(`until 跳游标首轮 ${kind === 'empty' ? '空 list' : '结构异常'},降级 cursor=now 重试`); cursor = Date.now(); await randSleep(2000, 5000); // 重试首轮非空才实锤"不支持"(真空时重试也空,不设标志,下个询盘继续尝试) // —— 实现方式:本标志在下一轮循环体内设置(见下) state._probeDowngraded = true; continue; } } else { if (!json || typeof json !== 'object') throw new Error('RISK:结构异常(非对象)'); if (json.code !== undefined && Number(json.code) !== 200) throw new Error('RISK:结构异常(code=' + json.code + ')'); if (json.data == null) throw new Error('RISK:结构异常(data=null)'); } const list = (json.data && json.data.list) || []; if (!list.length) { // 降级重试后的空 list = 真空(正常到顶);且若 _probeDowngraded 未实锤则清试探态 if (state._probeDowngraded) { state._probeDowngraded = false; /* 重试也空 → 真空,不设 unsupported */ } stopReason = '到顶(空列表)'; break; } if (state._probeDowngraded) { // 降级重试首批非空 → 跳游标确实漏数据,实锤不支持 state._probeDowngraded = false; state.cursorJumpUnsupported = true; saveState(); } for (const m of list) { // 候选水位逐轮 max(未过滤口径,spec §4.5) if (m.sendTime != null && (candSendTime == null || m.sendTime > candSendTime)) { candId = String(m.messageId); candSendTime = m.sendTime; } } let hitLastTop = false; for (const m of list) { // 顺序:水位命中查未过滤 list,再范围过滤(spec §4.3.2) if (lastTopId && String(m.messageId) === lastTopId) { hitLastTop = true; break; } if (inChatRange(m.sendTime, sinceMs, untilMs)) raw.push(m); } let minT = Infinity; for (const m of list) if (m.sendTime != null && m.sendTime < minT) minT = m.sendTime; // null 跳过(防游标跳1970) if (minT !== Infinity) cursor = minT; if (raw.length % 400 < 20) log(`聊天 API 拉取中: ${raw.length} 条`); if (hitLastTop) { stopReason = '增量命中'; break; } if (sinceMs != null && cursor < sinceMs) { stopReason = '时间边界'; break; } if (list.length < 20) { stopReason = '到顶'; break; } await randSleep(2000, 5000); } log(`聊天 API 完成: ${raw.length} 条(停:${stopReason})`, 'success'); await trySetWatermark(inquiryId, candId, candSendTime); // 只前进写入(spec §4.5) ``` 同时删除原 `newestMsgIdThisRound` 相关整段与旧 `setLastTopMsgId` 调用;`lastTopMsgId` 读取段(原 `const lastTopMsgId = await getLastTopMsgId(...)`)由开头 `getWatermark` 段替代。**其后的 `return { messages: raw.map(...), meta };` 原样保留,不在替换范围内。** - [ ] **Step 2: 同步义务检查** 对照 `scripts/verify-chat-range.mjs`:`inChatRange/parseWatermark/shouldWriteWatermark/probeFailKind` 函数体逐字一致(Task 3 已内联)。 - [ ] **Step 3: 逻辑回归(零请求)** Run: `node scripts/verify-chat-range.mjs && node scripts/verify-risk-detect.mjs && npx vitest run` Expected: 36 passed / 10 passed / 16 passed,全部 exit 0 - [ ] **Step 4: 语法自检(浏览器加载)** 重载扩展,SW console 无报错;popup"开始抓取(全部, limit=10)"跑一轮 —— 行为与现状一致(all → sinceMs/untilMs null,新分支全短路)。 - [ ] **Step 5: Commit(Task 4+5 合并)** ```bash git add content.js git commit -m "feat(ext): time-bounded chat paging - cursor jump + probe fallback + forward-only watermark" ``` --- ### Task 6: content.js — DOM 回退路径 **Files:** - Modify: `content.js`(`loadAllChatHistory`) - [ ] **Step 1: 全量分支加时间探测停滚** `loadAllChatHistory` 全量分支(无 lastDomTime 时)滚动循环内,`await randSleep(2000, 6000)` 之后、计数逻辑之前加: ```js // DOM 时间边界:顶部向下找第一个带时间 span 的 wrapper(时间戳稀疏渲染),早于 since → 停滚(spec §4.4) if (sinceMs != null) { let oldestT = null; for (const mw of document.querySelectorAll('.message-item-wrapper')) { const te = mw.querySelector('.item-base-info'); let hit = false; if (te) for (const sp of te.querySelectorAll('span')) { const m = sp.innerText.match(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/); if (m) { oldestT = new Date(m[1] + 'T' + m[2]).getTime(); hit = true; break; } } if (hit) break; } if (oldestT != null && oldestT < sinceMs) { log('DOM 时间边界,停止滚动'); break; } } ``` - [ ] **Step 2: 提取后过滤(全量/增量两分支)** 全量分支尾部(水位 maxT 计算保持用**未过滤** `all`): ```js const all = extractDomChatMessages(); // ... maxT 用 all 计算并存 lastDomTime(不变,水位=见到的最新)... const filtered = all.filter(m => inChatRange(m.sendTime ? Date.parse(m.sendTime) : null, sinceMs, untilMs)); state._incDomMessages = filtered; state._incDomFiltered = (sinceMs != null || untilMs != null); // 空数组也具决定性(见下) ``` **配套改消费点**(scrapeDetailData 内,原 `incMsgs && incMsgs.length` 处)—— 否则空 `filtered` 会穿透 `incMsgs.length` 保护、回退到**未过滤**的 `extractDomChatMessages()`,把范围外可见消息泄漏出去(违背 §4.4"0 条降级"): ```js const incMsgs = state._incDomMessages || null; const incFiltered = !!state._incDomFiltered; // 空数组 + 过滤标记 → 空也有效(0条降级) state._incDomMessages = null; state._incDomFiltered = false; // ...(apiMsgs 判定不变)... if (!msgs) msgs = (incMsgs && (incMsgs.length || incFiltered)) ? incMsgs : null; ``` 增量分支: ```js const inc = all.filter(m => m.sendTime && m.sendTime > lastDomTime) .filter(m => inChatRange(Date.parse(m.sendTime), sinceMs, untilMs)); ``` - [ ] **Step 3: 回归 + Commit** Run: `node scripts/verify-chat-range.mjs && npx vitest run` ```bash git add content.js git commit -m "feat(ext): DOM fallback respects chat time range (probe-stop + filter)" ``` --- ### Task 7: 全量回归(零请求) - [ ] **Step 1: 三套验证全绿** Run: `node scripts/verify-chat-range.mjs && node scripts/verify-risk-detect.mjs && npx vitest run` Expected: 36 / 10 / 16 全 passed,exit 0 - [ ] **Step 2: 对照 spec 附录 A 逐行走查一遍 fetchChatByApi 实际代码(人工,15 分钟)** 锚点清单:初始游标+1 / probe 只首轮 / PROBE 降级集不含 non-JSON / 水位命中未过滤 / 过滤 null guard / minT 跳 null / 停止优先级(增量>时间>到顶)/ stopReason 日志 / trySetWatermark 只前进 / randSleep 与 waitUntilWorkTime 原样。 --- ### Task 8: 手动验证(⚠️ 风控纪律:limit=10、间隔≥2分钟、不批量) - [ ] **Step 1: 回归基线** — 选"全部"+limit=10,跑一轮,行为与旧版一致(注:all 模式**无**"聊天范围"日志,仅范围模式输出)。 - [ ] **Step 2: 近1月** — 选"近 1 个月"+limit=10(挑含老客户的窗口),日志出现 `停:时间边界`,单询盘条数明显少于全量。两次运行间隔 ≥2 分钟。 - [ ] **Step 3: 校验** — 选 custom,起>止 → popup 报错不发;止选明天 → 报错。(零请求) - [ ] **Step 4: until 跳游标复测(附录B复测)** — custom 起=2个月前、止=1个月前 + limit=10:每个询盘**首轮** SW Network 里 `getOpMessages` 请求体 timeStamp ≈ 止日+1ms(注:无成功标志,后续询盘首轮**仍会**以止日起跳 —— 这是设计行为,不是重复试探 bug);首轮返回正常 → 本询盘后续轮次从 minT 正常翻页、无"降级 cursor=now"日志;若出现降级且降级重试首批非空 → 后续询盘直接 now 起跳(标志生效)。 - [ ] **Step 5: 增量** — 同询盘重复 Step 2,第二次 `停:增量命中` 且推送无重复(后端 count 不变)。 - [ ] **Step 6: 验证结果记录回 spec 附录 B 复测栏(如行为有出入,STOP 并回到 systematic-debugging)** - [ ] **Step 7: 范围存活验证** — 范围模式任务中途暂停→恢复、扩展重载后:抓取沿用同一范围(state 持久化链路),日志仍显示同一"聊天范围"。 - [ ] **Step 8: 水位升级确认** — 首轮范围抓取后旧水位(string)升级为 JSON,下轮增量正常命中(停:增量命中)。 - [ ] **Step 9: 上线前开关** — 全部验证完成后把 `WORK_TIME_ENABLED` 改回 `true`(生产作息限制;当前为用户测试态 false)。