Files
alibaba/docs/superpowers/plans/2026-08-25-chat-time-range.md
T
2026-08-27 16:12:37 +08:00

588 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 聊天时间范围抓取 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
<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 区)追加:
```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)。