232 lines
19 KiB
Markdown
232 lines
19 KiB
Markdown
# 聊天记录按时间范围抓取 设计文档
|
||
|
||
- 日期: 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 选型。
|