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

28 KiB
Raw Blame History

聊天时间范围抓取 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: 写用例 + 桩函数(预期红)

// 验证聊天时间范围判定逻辑(纯函数基准实现)。
// 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)。