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

232 lines
19 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.
# 聊天记录按时间范围抓取 设计文档
- 日期: 2026-08-25
- 状态: 待评审
- 关联: `d:\alibb\` Chrome 扩展(content.js / popup.html / popup.js)
- 前置讨论: 吸收三轮审查 —— ①自评 2 漏洞(§4.5 水位倒退、§4.3 until 跳游标未实测);②subagent 评审 4 建议;③深度复查 6 项(P1×3:空 list 静默 0 条 / fallback 与 RISK 抛出次序冲突 / date-only UTC 解析陷阱;P2×2:未来止日、水位检测顺序;优化:停止原因日志)及复查细化 2 项(§4.5 更新条件简化、§4.3 任务级降级标志)
## 1. 概述
### 1.1 目标
给聊天记录抓取增加**时间范围**控制:近 1 个月 / 2 个月 / 3 个月 / 自定义起止日期。选定范围后,翻页**到范围起点即停**(不只是抓完再滤),从而大幅减少对 `getOpMessages` 的请求量 —— 时间范围同时是**风控减压手段**。
### 1.2 动机
现有唯一限制是条数(`chatLimit`,换算成翻页轮数上限)。条数与"我要最近 N 个月"的真实意图不匹配;且全量翻页对老客户动辄几百轮请求(每轮 2-5s 慢速),风控暴露面大。
## 2. 已确认决策(用户拍板)
| # | 决策点 | 结论 |
|---|---|---|
| D1 | 时间范围 vs 条数 | **时间替代条数**:选时间模式后条数输入忽略、置灰 |
| D2 | 与增量机制关系 | **增量仍然生效**:增量命中 / 时间边界到,任一先到先停 |
| D3 | UI 形态 | 下拉:全部(默认,同现状)/ 近1月 / 近2月 / 近3月 / 自定义;自定义显示起止日期框 |
| D4 | 抓取路径 | **API 慢速 + 时间边界停**(否决 DOM 滚动为主 / CDP,理由见 §3) |
## 3. 方案选型(为什么是 API + 时间边界)
- **DOM 滚动为主被否决**:① DOM 滚动触发的底层请求仍是 `getOpMessages`(页面自己发),并非"不调 API";② 驱动事件是合成 `WheelEvent`,`isTrusted=false` 是**阿里已确认检测的指纹**(现有代码注释记载:折叠面板只接受真实点击);③ 慢 10 倍以上、图片懒加载丢数据、平台期误判截断。
- **CDP(chrome.debugger)可信事件被否决**:常驻"正在调试此浏览器"提示条、实现复杂、速度仍慢。
- **后端过滤被否决**:不省请求、白传数据、旧数据管不了。
- **API 慢速路径实测战绩**:7-15 全量 4576 询盘 + 68058 条消息零风控触发。
## 4. 详细设计
### 4.1 UI(popup.html / popup.js)
options 区"每询盘聊天上限"行下方新增一行 **聊天时间范围**:
```html
<select id="chatTimeRange">
<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>
<!-- custom 时显示 -->
<input type="date" id="chatSinceDate"> ~ <input type="date" id="chatUntilDate">
```
- 选 `custom` → 显示起止框,**止默认今天**;
- 选任何时间模式(非 all)→ `chatLimitInput` 置灰 + 旁注"(时间模式下忽略)";切回 all → 恢复可用。两模式互斥。
- popup.js 在 start 时计算并传参(**本地时区**):
- `all` → `chatSince: null, chatUntil: null`(完全等同现状,回归安全);
- `1m/2m/3m` → `chatSince = minusMonths(今日0点, N)`(**自然月整天边界**,起始日 00:00:00.000,不保留当前时分秒 —— 否则起始日当天早间消息被切),`chatUntil = 今日 23:59:59.999`("至今"取今天整天,与 custom 止=今天同一路径);
- `custom` → `chatSince = 起日 T00:00:00.000`,`chatUntil = 止日 T23:59:59.999`(含止日全天);
- **校验(popup 报错不发 start)**:起 > 止 / 起或止为空 / **止日 > 今天**(未来止日会造出未来 cursor,属未实测形态);
- ⚠️ **实现陷阱(date-only UTC)**:`new Date('2026-08-25')` 按 **UTC** 解析,中国时区差 8 小时,止日凌晨的消息会被静默滤掉。必须写 `new Date(v + 'T00:00:00')`(带时间部分 → 本地解析)或 `new Date(y, m-1, d)`。verify 脚本有用例锁定。
### 4.2 参数传递与状态(content.js)
- start 消息新增 `chatSince` / `chatUntil`(ISO 字符串或 null),存入 `state` 并随 `saveState()` 持久化 —— 复用现有跨导航/暂停恢复/扩展重载的状态链路,零额外机制。注意:现有 start 分支是**全量重建 state 对象**,需把这两个字段显式加入重建清单(漏写会变 undefined,虽按 null 容错可用,但显式更清晰)。
- 进入抓取时派生一次毫秒值:`sinceMs = chatSince ? Date.parse(chatSince) : null`(untilMs 同理),后续循环只用毫秒比较。
- **时间模式下 popup 一律发 `chatLimit: 0`**:使 API 路径(MAX_ROUNDS→2000)与 DOM 回退路径(→300)两条现有公式都自然解析为全量轮数,无需对称修改换算逻辑。
### 4.3 API 主路径(fetchChatByApi)改动
翻页循环(现有停止条件:增量命中 hitLastTop / list.length < 20 到顶 / MAX_ROUNDS)增加:
1. **初始游标**:`cursor = (untilMs != null) ? untilMs + 1 : Date.now()`
- +1ms 缓冲:宁可多取一轮,由批内过滤兜底,确保 until 邻近消息不漏(§6.2);
- 自定义过去截止日时直接从截止日开始往回翻,**省掉 now→until 之间的全部请求**。
2. **收集过滤**(批内跨边界):`raw.push(m)` 仅当
`m.sendTime == null || ((!sinceMs || m.sendTime >= sinceMs) && (!untilMs || m.sendTime <= untilMs))`
—— sendTime 为空的消息**防御性保留**(与 §5 约定一致;若不显式加此 guard,`undefined >= sinceMs` 为 false 会把无时间消息错误丢弃)。
**顺序约定:水位命中检测遍历未过滤的原始 list(现有口径),命中即停;未命中的消息再套范围过滤决定 push。** 禁止先 `list.filter(范围)` 再查水位 —— 水位消息落在范围外时行为会漂移。
3. **翻页停止**:每轮游标更新后,`if (sinceMs && minT < sinceMs) → break`,日志记 `时间边界提前停止(早于 <since ISO>)`。
4. MAX_ROUNDS 在时间模式下固定 2000(安全阀防死循环;正常由时间/增量/到顶停)。
5. 日志:范围模式下每询盘开始抓聊天时输出当前范围(`聊天范围: 2026-07-25 ~ 至今` / `~ 2026-08-31`;all 模式无此日志);完成日志统一带**停止原因**:`聊天 API 完成: N 条(停:时间边界/增量命中/到顶/空列表/轮数上限)` —— 手动验证一眼分辨是哪种停。
**until 跳游标的保守 fallback(首轮专项)**:**2026-08-25 已实测**(附录 B):过去时间戳 cursor 完全受支持,语义 = 返回 sendTime **严格小于** timeStamp 的 N 条(方向与现网一致);`untilMs+1` 缓冲恰好覆盖含止日全天。fallback 保留为**保险**(实测样本单一:1 账号 1 会话,阿里行为可能变化),并按实测精化:
- **触发条件**(仅 `untilMs != null` 时的**首轮**,且仅首轮):
1. `PROBE_FAIL` 形态:非对象 / code≠200 / data=null —— 参数异常;
2. **返回空 list** —— 实测(附录 B 组)表明这大概率是"该会话在 until 前确无消息"(真空,如新询盘),但单次响应无法与"不支持"区分,仍降级以保正确(空 list 会被现有"正常到顶"分支 [content.js:816](content.js#L816) 静默吞掉,是真静默丢数据形态)。
- **实现次序(P1-2)**:首轮结构断言单独 try 包住,抛**独立前缀** `PROBE_FAIL`(勿复用 RISK 前缀,避免误吞 `RISK:结构异常(non-JSON)`);降级集 = {PROBE_FAIL, 空 list};postForm 自身抛的 RISK(HTTP 非 200 / 关键词 / non-JSON)在 try 之外,照常冒泡暂停,不得吞掉。
- **降级动作**:cursor 重置 `Date.now()`,**同循环 continue** 从现在往回翻到 since 停(批内过滤兜 until 上界);重试首批仍空 list = 真空(正常到顶),不递归降级。
- **任务级标志(final review 再收紧)**:仅当**降级原因为 'fail'(结构异常)且降级重试首批非空**才设 `state.cursorJumpUnsupported = true`。'empty'(空 list)多为"会话在 until 前本无消息"的真空(附录 B 组形态)—— 据此设标志会把跳游标误禁一整个任务(所有询盘退化为 now 全量回翻,风控暴露面回升),故 empty 降级保正确性但不设标志。实现以局部变量 `downgradeKind` 记录原因。
### 4.4 DOM 回退路径(loadAllChatHistory,API 失败才走)
- **全量分支**:滚动循环每轮**轻量探测**——只读**第一个** `.message-item-wrapper` 的时间 span(聊天 DOM 顶部=最旧;不要全量提取,几千条时每轮全提性能差),若其 sendTime 早于 sinceMs → 停止往上滚(再滚加载的全是范围外)。
- 滚动结束后 `extractDomChatMessages()` 提取,再按 since/until 过滤后才挂到 `state._incDomMessages` / chatMessages。
- **增量分支**:现有 `sendTime > lastDomTime` 过滤后,**再叠加** since/until 过滤(两条件都满足才收)。
- **预期降级(非 bug)**:增量分支只取页面已显示的最新 N 条、从不向过去滚动 —— 自定义**过去**截止日时,叠加过滤后可能产出 0 条。这是回退路径的可接受退化(API 路径才是主路径),勿当 bug 上报。
- `lastDomTime` 水位计算沿用**未过滤**集合的 max(水位反映"DOM 已见到的最新",过滤只影响推送)—— 现有 max 逻辑天然单调前进,无需改动。
### 4.5 增量水位只前进修复(自评漏洞 #1)
**问题**:自定义过去截止日(如 6-01~6-30)抓完后,`lastTopMsgId` 会被覆盖成本轮翻到的最新(≈6-30),把原 7-28 的水位**倒退** → 下次增量要多翻整月已抓过的消息(数据不脏,后端 skipDuplicates,但请求白费、风控暴露面白增)。
**修复**:`lastTopMsg_<inquiryId>` 存储结构改为 JSON `{ id, sendTime }`:
- 读取兼容旧格式(纯 string 视为 `sendTime` 未知 → 允许覆盖一次,升级到新格式);
- 更新条件简化为:`候选水位.sendTime > 旧水位.sendTime` 才写入(**水位只前进不倒退**)。原实现的 `raw.length > 0` 附加条件**移除** —— 它在旧格式(id 不可比时间)下防误清,新格式的只前进比较本身已安全;保留反而导致"本轮全被 until 滤空、但确实见到更新消息"时水位保守不前,下次多翻;
- 候选水位取**各轮见过的最大 sendTime**(未过滤 list 口径)。注意与现状的差异:现有实现只在首批设一次(`if (newestMsgIdThisRound === null && ...)`,[content.js:818-823](content.js#L818-L823)),fallback 降级重翻后首批(更新侧)无法再上调候选 → 水位保守停在 probe 轮的旧值,下次多翻。改为逐轮取 max 后此问题消除。
注:DOM 侧 `lastDomTime` 是毫秒时间戳比较,天然单调,无需此修复。
### 4.6 不改动的部分
- 同客户聊天去重(`seenChatCustomer`)、`chatSkipped` 标记、后端归代表:不受影响;
- API 返回空数组 `[]` = 成功无新增(不触发 DOM 回退)的现有语义:不变;
- CSV 导出、enqueue 推送、payload.js 映射、后端/前端:**零改动**(过滤在扩展侧完成,messages 照常流转);
- 询盘列表抓取、详情页客户资料/行为数据:**不受时间范围影响**(时间范围只作用于聊天)。
## 5. 边界语义与容错
| 项 | 约定 |
|---|---|
| 含边界 | `sendTime >= since` 且 `<= until`;自定义含止日全天(23:59:59.999) |
| 近 N 月 | 自然月,非 30 天,**整天边界**:since=起始日 00:00:00.000、until=今日 23:59:59.999(需求变更 2026-08-25:不保留当前时分秒)。⚠️ 不能直接 `setMonth(-N)`:月末日期溢出(3-31 − 1月 → "2-31" 溢出成 3-02,since 晚一个月**漏抓整月**)。须用 `minusMonths`(退到月初再钳制到目标月最后一天);verify 脚本有用例锁定 |
| 时区 | since/until 按用户**本地时区**构造(popup `new Date()`),与 API 毫秒时间戳、DOM 本地时间解析一致 |
| parse 失败 | chatSince/chatUntil 非法 → 视为 null(降级为不限),日志提示,**不中断抓取** |
| 消息无 sendTime | 防御性保留(宁多勿漏);实际 API 消息均带 sendTime |
| 起止校验 | custom 起>止 / 空 / 止日>今天 → popup 报错不发 start |
## 6. 语义澄清(用户预期管理)
1. **范围 ≠ 删除**:时间范围只控制"这次抓什么",**不会删除后端已有历史**。库内数据 = 历次抓取的并集(后端按 messageId skipDuplicates)。改选更短范围重抓,旧数据仍在。
2. **增量水位对"过去区间"天然无效**:水位只优化"追加新消息"场景;自定义过去区间翻不到水位属正常,由 since 边界停。
3. **抓取正确性承诺**:范围内一条不漏(边界含)、范围外一条不进(批内过滤)、已抓过的不重复翻(水位只前进 + 增量命中停)。
## 7. 验证方案
### 7.1 自动验证(content.js 为 IIFE,无法被 vitest import → 沿用复刻脚本模式)
新增 `scripts/verify-chat-range.mjs`(与 `verify-risk-detect.mjs` 同模式,文件头注明与 content.js 同步义务),覆盖:
- 自然月计算(跨年:3 月 31 日 - 1 月 = 2 月 28/29?边界行为锁定);
- 边界含/不含(since 当天消息收、since 前一天不收);
- 跨边界批过滤(一页 20 条横跨 since);
- 水位只前进(旧 {id,sendTime=7-28} 不被新 sendTime=6-30 覆盖;旧 string 格式兼容);
- cursor 缓冲(untilMs+1)与 parse 容错(null → 不限);
- date 输入**本地时区**构造(`new Date(v+'T00:00:00')` 非 UTC 解析,锁定 8-25T00:00 本地毫秒);
- fallback 触发判定(空 list / 结构异常 code、data=null 两形态 → 降级;HTTP/关键词/non-JSON 真风控形态不降级);
- 水位更新:候选**逐轮取 max**(覆盖 fallback 降级重翻场景)+ 只前进写入(sendTime 更大才写;raw 为空也可前进)。
### 7.2 手动验证清单
1. 老客户询盘选"近 1 个月" → 日志出现 `时间边界提前停止`,条数远小于全量;
2. 自定义 起>止 → popup 报错不发;
3. 选"全部" → 行为与当前版本完全一致(回归);
4. **custom 含过去截止日 → 首轮盯 SW Network**(附录 B 已实测通过,此项为实现上线后的复测):`getOpMessages` 请求体 timeStamp 是否为 untilMs+1、返回是否正常;
5. 同询盘先"近3月"再"近1月"重抓 → 第二次条数显著少(增量)且无重复推送。
## 8. 风险与开放问题
| 风险 | 缓解 |
|---|---|
| ~~cursor 传过去时间戳的 API 行为未实测~~ → **已实测(2026-08-25,附录 B)**:受支持,语义=严格小于,+1ms 缓冲正确;残余风险 = 样本单一(1 账号/1 会话) | 保留首轮 fallback 保险(PROBE_FAIL / 空 list → 降级 now)+ 标志仅在"降级重试首批非空"时设置 + 实现后手动清单 #4 复测 |
| 复刻验证脚本与 content.js 漂移 | 脚本头注明同步义务(同 verify-risk-detect 现状,可接受的架构权衡) |
| 时间模式 + 很老的自定义起点 | MAX_ROUNDS=2000 安全阀兜底;用户自选范围,预期内 |
## 9. 涉及文件
| 文件 | 改动 |
|---|---|
| popup.html | 时间范围下拉 + 自定义日期框 + 条数置灰逻辑的 UI |
| popup.js | 计算 since/until(自然月/含全天/校验)、置灰切换、start 传参 |
| content.js | state 字段、fetchChatByApi(游标/过滤/停止/水位)、loadAllChatHistory(探测/过滤)、lastTopMsg 存储结构 |
| scripts/verify-chat-range.mjs | 新增,复刻判定逻辑的自动验证 |
## 附录 A:§4.3 修订后翻页循环伪代码(实现基准)
§4.3 的 7 处改动(游标/探测降级/过滤/顺序/停止/日志/水位)在此串成完整循环,实现以此为准:
```text
// 前置:sinceMs/untilMs 已从 state.chatSince/chatUntil 派生(§4.2);lastTop = 读水位(§4.5)
jump = untilMs != null && !state.cursorJumpUnsupported
cursor = jump ? untilMs + 1 : Date.now()
probeRound = jump // 仅跳游标首轮为探测轮
candidate = null // 候选水位 {id, sendTime},逐轮取 max(§4.5)
stopReason = '轮数上限'
for i in 0..MAX_ROUNDS-1:
if state.phase == 'paused': return null
await waitUntilWorkTime()
json = postForm(getOpMessages, { timeStamp: cursor, forward: false, pageSize: 20, ... })
if probeRound:
probeRound = false
try:
assertStructure(json) // 非对象 / code≠200 / data=null → throw PROBE_FAIL(独立前缀,勿复用 RISK 前缀)
if emptyList(json): throw PROBE_FAIL
catch e:
if e 是 PROBE_FAIL: // 仅降级集;postForm 的 RISK(non-JSON/HTTP/关键词)在 try 外照常冒泡
// ⚠️ 勘误(实现以 §4.3 收紧版为准):此处不立即设 cursorJumpUnsupported;
// 实现记 downgradeKind,仅 kind='fail' 且降级重试首批非空才设(empty 多为真空,勿误禁全任务)
downgradeKind = kind
log('until 跳游标不受支持,降级 cursor=now')
cursor = Date.now()
await randSleep(2000, 5000)
continue // 重试按常规逻辑,不再探测;仍空 list = 正常到顶,不递归降级
throw e // 真风控(HTTP/关键词/non-JSON)照常抛出暂停
else:
assertStructure(json) // 常规轮:结构异常直接 RISK
list = json.data?.list || []
if list 为空: stopReason = '到顶(空列表)'; break
for m of list: // 候选水位:逐轮最大 sendTime,未过滤口径(§4.5)
if m.sendTime != null && (candidate == null || m.sendTime > candidate.sendTime):
candidate = { id: String(m.messageId), sendTime: m.sendTime }
hit = false
for m of list: // 顺序:水位命中查未过滤 list,再范围过滤收集(§4.3.2)
if lastTop && String(m.messageId) == lastTop.id: hit = true; break
if m.sendTime == null || (!sinceMs || m.sendTime >= sinceMs) && (!untilMs || m.sendTime <= untilMs):
raw.push(m)
cursor = min(list 中非 null 的 sendTime) // null 必须跳过(与现状 [content.js:830-831] 一致);Math.min 含 null 得 0 → 游标跳 1970
if hit: stopReason = '增量命中'; break
if sinceMs && cursor < sinceMs: stopReason = '时间边界'; break
if list.length < 20: stopReason = '到顶'; break
await randSleep(2000, 5000)
log(`聊天 API 完成: ${raw.length} 条(停:${stopReason})`)
if candidate && (旧水位为旧格式string || candidate.sendTime > 旧水位.sendTime):
写入 candidate // 水位只前进(§4.5)
```
## 附录 B:过去时间戳 cursor 实测记录(2026-08-25)
**环境**:用户登录态,询盘详情页(imInquiryId=13908460211,会话共 25 条消息),页面上下文复刻 content.js 的 postForm(**无签名头**),请求间隔 2.5s,共 4 次请求,未触发任何风控。
| 组 | cursor | HTTP/code | 返回 | 结论 |
|---|---|---|---|---|
| A 对照 | `now` | 200/"200" | 20 条(最新 20 条,oldest=8-24T13:06:27.788Z) | 现网行为正常 |
| B 过去30天 | `now−30d` | 200/"200" | **空 list,完全静默** | 该会话是新询盘,30 天前确无消息 → 空是**正确语义的空**;同时证明空 list 形态真实存在且与"不支持"无法从单次响应区分(P1-1 降级必要性成立) |
| C 边界 | =A 批最旧消息 sendTime | 200/"200" | 5 条,全部**严格小于** cursor(不含 cursor 那条),与 A 批无缝衔接 | ① 语义 = `sendTime < timeStamp`(严格小于,向前翻);② **过去时间戳受支持、方向正确**;③ 翻页连续(cursor=minT 下批不重不漏);④ `untilMs+1` 恰好覆盖 `<= untilMs` 全部消息 |
**衍生结论**:① until 跳游标可用(省 now→until 全部请求);② +1ms 缓冲正确且必要;③ 空 list 二义性真实 → fallback 保留,但任务级标志仅在"降级重试首批非空"时设置(§4.3 实测精化);④ 复刻 postForm 无签名头直接可用,佐证 §3 选型。