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

19 KiB
Raw Blame History

聊天记录按时间范围抓取 设计文档

  • 日期: 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)增加:

  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 静默吞掉,是真静默丢数据形态)。
  • 实现次序(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. 语义澄清(用户预期管理)

  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 处改动(游标/探测降级/过滤/顺序/停止/日志/水位)在此串成完整循环,实现以此为准:

// 前置: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 选型。