28 KiB
聊天时间范围抓取 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 复刻验证脚本。
⛔ 风控红线(全程有效,用户明确要求)
- 不得改动任何现有安全层参数:
randSleep(2000,5000)翻页间隔、waitUntilWorkTime、postForm的 RISK 检测、RISK_KEYWORDS/SKIP_KEYWORDS。 - 真机验证只用小规模:手动验证(Task 8)一律
limit=10,单次验证 ≤ 10 个询盘,两次验证之间间隔 ≥ 2 分钟。 - 不新增任何 API 端点调用:只改现有
querySummary/getOpMessages的参数(timeStamp)与本地逻辑。 - 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: 写用例 + 桩函数(预期红)
// 验证聊天时间范围判定逻辑(纯函数基准实现)。
// 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 个桩函数)
// 自然月回退:直接 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
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):
<label>每询盘聊天上限(0=全量;测试可设小值加快): <input type="number" id="chatLimitInput" value="0" min="0" style="width:64px;"> 条 <span id="chatLimitHint" style="color:#999;"></span></label>
<label>聊天时间范围:
<select id="chatTimeRange" style="width:120px;">
<option value="all" selected>全部(不限)</option>
<option value="1m">近 1 个月</option>
<option value="2m">近 2 个月</option>
<option value="3m">近 3 个月</option>
<option value="custom">自定义日期…</option>
</select>
</label>
<div id="chatCustomDates" style="display:none;margin:4px 0;">
<input type="date" id="chatSinceDate"> ~ <input type="date" id="chatUntilDate">
</div>
- Step 2: popup.js 加引用 + 模式切换 + 计算传参
元素引用区(文件顶部 getElementById 区)追加:
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 逐字一致):
// ==== 聊天时间范围(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', {...}) 之前插入校验,并改传参:
const range = computeChatRange(chatTimeRangeSelect.value, chatSinceDate.value, chatUntilDate.value);
if (range.error) { addLog(range.error, 'error'); statusText.textContent = '就绪'; updateButtons('idle'); return; }
send('start', {...}) 的参数改为:
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
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, 行后加:
chatSince: msg.chatSince ?? null, chatUntil: msg.chatUntil ?? null, // 时间范围(spec §4.2);显式列入重建清单
- Step 2: 文件顶部(IIFE 内、RISK_KEYWORDS 之前)加范围工具(照搬 verify + 派生)
// ==== 聊天时间范围工具(与 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 入口各派生一次
const sinceMs = toMs(state.chatSince), untilMs = toMs(state.chatUntil);
- Step 4: 手动自检(零请求)
重载扩展 → popup"开始抓取"选"全部" → 行为与现状一致(state 字段为 null 短路所有新逻辑);SW console 无报错。
- Step 5: Commit
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 分支无需改):
// 增量水位(新格式 {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 伪代码逐行落地)
// 增量水位(新格式;旧 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 合并)
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) 之后、计数逻辑之前加:
// 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):
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 条降级"):
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;
增量分支:
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
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)。