19 KiB
聊天记录按时间范围抓取 设计文档
- 日期: 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 区"每询盘聊天上限"行下方新增一行 聊天时间范围:
<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)增加:
- 初始游标:
cursor = (untilMs != null) ? untilMs + 1 : Date.now()- +1ms 缓冲:宁可多取一轮,由批内过滤兜底,确保 until 邻近消息不漏(§6.2);
- 自定义过去截止日时直接从截止日开始往回翻,省掉 now→until 之间的全部请求。
- 收集过滤(批内跨边界):
raw.push(m)仅当m.sendTime == null || ((!sinceMs || m.sendTime >= sinceMs) && (!untilMs || m.sendTime <= untilMs))—— sendTime 为空的消息防御性保留(与 §5 约定一致;若不显式加此 guard,undefined >= sinceMs为 false 会把无时间消息错误丢弃)。 顺序约定:水位命中检测遍历未过滤的原始 list(现有口径),命中即停;未命中的消息再套范围过滤决定 push。 禁止先list.filter(范围)再查水位 —— 水位消息落在范围外时行为会漂移。 - 翻页停止:每轮游标更新后,
if (sinceMs && minT < sinceMs) → break,日志记时间边界提前停止(早于 <since ISO>)。 - MAX_ROUNDS 在时间模式下固定 2000(安全阀防死循环;正常由时间/增量/到顶停)。
- 日志:范围模式下每询盘开始抓聊天时输出当前范围(
聊天范围: 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时的首轮,且仅首轮):PROBE_FAIL形态:非对象 / code≠200 / data=null —— 参数异常;- 返回空 list —— 实测(附录 B 组)表明这大概率是"该会话在 until 前确无消息"(真空,如新询盘),但单次响应无法与"不支持"区分,仍降级以保正确(空 list 会被现有"正常到顶"分支 content.js:816 静默吞掉,是真静默丢数据形态)。
- 实现次序(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),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. 语义澄清(用户预期管理)
- 范围 ≠ 删除:时间范围只控制"这次抓什么",不会删除后端已有历史。库内数据 = 历次抓取的并集(后端按 messageId skipDuplicates)。改选更短范围重抓,旧数据仍在。
- 增量水位对"过去区间"天然无效:水位只优化"追加新消息"场景;自定义过去区间翻不到水位属正常,由 since 边界停。
- 抓取正确性承诺:范围内一条不漏(边界含)、范围外一条不进(批内过滤)、已抓过的不重复翻(水位只前进 + 增量命中停)。
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 个月" → 日志出现
时间边界提前停止,条数远小于全量; - 自定义 起>止 → popup 报错不发;
- 选"全部" → 行为与当前版本完全一致(回归);
- custom 含过去截止日 → 首轮盯 SW Network(附录 B 已实测通过,此项为实现上线后的复测):
getOpMessages请求体 timeStamp 是否为 untilMs+1、返回是否正常; - 同询盘先"近3月"再"近1月"重抓 → 第二次条数显著少(增量)且无重复推送。
8. 风险与开放问题
| 风险 | 缓解 |
|---|---|
| 保留首轮 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 处改动(游标/探测降级/过滤/顺序/停止/日志/水位)在此串成完整循环,实现以此为准:
// 前置: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 选型。