588 lines
28 KiB
Markdown
588 lines
28 KiB
Markdown
# 聊天时间范围抓取 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)。
|