翻页与会话
声明式翻页的收敛条件与聚合方式,扫码登录的状态机,以及实现与设计文档不一致的几处
翻页与会话
两种「一次调用对应多次网络往返」的机制。翻页是无状态的,写在端点声明里;会话是有状态的,不是端点。
声明式翻页
端点声明里的 paginate 槽位。执行在 runtime/paginate.ts,它不 catch 任何异常(唯一的 catch 在 execute)。
算法
limitKey = limitParam ?? 'number' // 读「想要多少条」的参数名
countKey = countParam ?? limitKey // 写「本页要多少条」的参数名
target = resolveTarget(params[limitKey], maxPageSize) // 循环外算一次
while (collected.length < target):
pageSize = min(target - collected.length, maxPageSize) // 逐页收窄
pageParams[countKey] = pageSize
非首页且 pageIntervalMs > 0 → sleep(pageIntervalMs)
page = runPage(pageParams, isFirst ? 'initial' : 'page')
失败 → 首页:立刻返回该错误;否则 break 返回部分结果(tolerate 时再记进 interrupted)
items(page) 非空数组 → 累积
!hasMore(page) → break
本页 items 非数组或为空 → break
pageParams = nextParams(pageParams, page)
return { lastPage, pages, items: collected.slice(0, target), interrupted? }五个容易忽略的细节:
resolveTarget对非有限数回退到maxPageSize,负数归 0,其余Math.trunc。number: 0意味着一个请求都不发 —— 结果落PaginateOutcome的empty: true判别支,没有lastPage可言。- page-size clamping 包含第一页 ——
pageSize写回countKey是在循环内每轮都做,不是只在后续页做。 - 停止条件顺序固定:先
hasMore,再「本页 items 为空」。 - 没有最大页数上限,只靠上面两个条件收敛。目标条数是唯一的硬边界。
- 翻页间隔
pageIntervalMs只加在页与页之间:首页前、末页后都不等。等待走execute的可注入sleep,测试不必真等。
execute 侧:翻页分支排在首次 build / sign 之前,每页回调里重新 build 并重新 sign,再走 send / decode / judge / retryOn。所以 meta.attempts = 页数 + 各页重试数,第 2 页起 trace 的 reason 是 page。
15 个端点的共同约定
当前 15 个用 paginate 的端点有四条一致的做法,读任何一个都能推到其余:
- 全部省略
limitParam/countParam—— 读写都用number。 - 收尾全部写在
paginate.merge里,解构{ lastPage, items }——merge的第一参是收窄好的PaginatedValue<TPage, TItem>:lastPage恒有值(空跑由empty判别支单独承载),items是跨页累积并截断到目标条数的条目。PaginatedValue不带pages(旧结构留每页完整解码体、没人读,纯内存滞留,已删)。 - 全部声明了
partial: 'tolerate'—— 中途某页失败时保留已取数据,见下文「中途失败」。 - 快手 5 个翻页端点都声明了
pageIntervalMs:评论 / 评论回复 / 用户作品1200(取 ksp 参考实现的间隔),两个搜索端点10_000(搜索接口对连续翻页更敏感);其余平台不设间隔。
merge 的产出形状统一是「最后一页的浅拷贝 + 跨页聚合后的 items 塞回列表字段」(...lastPage 直接展开,因 lastPage 恒有值、字段保持必填,不必再 ?? {} 兜底又断言回去)。所以调用方看到的不是分页结构,而是平台原始形状:
| 平台端点 | maxPageSize | items 字段 | hasMore 判据 |
|---|---|---|---|
| 抖音 评论 / 评论回复 | 50 / 3 | comments | has_more === 1 |
| 抖音 三个列表类 | 18 | aweme_list | has_more === 1,但 userRecommendList 是布尔 === true |
| 抖音 合集作品 | 20 | aweme_list | has_more === 1 |
| 抖音 搜索 | 15 | user_list 或 data | has_more !== 0(undefined 也算有更多) |
| B站 评论 | 100 | data.replies | cursor.is_end !== true |
| 快手 评论 / 用户作品 | 50 / 12 | 嵌套三层 | pcursor 非空串 |
| 快手 评论回复 | 50 | data.visionSubCommentList.subCommentsV2 | pcursorV2 非空串且不是 'no_more' |
| 快手 搜索用户 / 搜索作品 | 30 / 20 | users / feeds | pcursor 非空串且不是 'no_more'(复用 hasNextCursor) |
| 小红书 笔记评论 / 子评论 | 50 / 10 | data.comments | data.has_more === true |
B站评论是唯一带去重 + 二次截断的(按 rpid 去重后再按 params.number 截断)。快手用户作品是唯一不做「最后一页浅拷贝」而是重建扁平结构的。
快手那两条走 PC GraphQL 的「到底了」有两种写法 —— 空串与字面量 'no_more',所以判据走 endpoints/define.ts 的 hasNextCursor(两者都算到底)。只判空串的话最后会多打一次空页请求。
没有「共几页」「是否还有更多」之类的分页元信息透出给调用方 —— 想知道实际发了几个请求看 meta.attempts。
中途失败:返回部分结果,partial: 'tolerate' 再标出截断
- 首页失败:没有可保留的,整体失败。
- 第 2 页起失败:停在这一页,带着已取条目照常
merge,信封是成功(v7 有意偏离 v6 的「任一页失败即整体失败」)。lastPage是最后一个成功的页。 - 端点声明了
tolerate(现在 15 个翻页端点都声明了):失败页的错误额外进PaginatedValue.interrupted,同时落到信封的meta.incomplete({ kind, code, message })。没声明的端点不额外标注,部分结果与正常收尾同形。
对声明了 tolerate 的端点,截断不是静默的:完整结果的 meta 上没有 incomplete 这个键,在意条数的调用方检查它就够。
快手翻页游标:评论的 pcursor 由 api.ts 写进 GraphQL variables;userWorkList 的 build
以前用字面量对象丢掉了 pcursor、每页都请求首屏,已修(endpoints.test.ts 断言第二页 URL 带上第一页的游标)。
会话(扫码登录)
多步有状态流程不是端点。四个平台的 client.<平台>.login 都提供 qrcode()(新建)与 resume(blob)(从 serialize() 的产物恢复);B站另有 refreshCredential 做 Cookie 续期。
分工:引擎(runtime/session.ts)负责轮询循环、退避、超时、取消与 challenge 编排;平台策略(platforms/<平台>/session/qrcode.ts)只写协议细节,平台码 → phase 的映射不外泄。
状态机
LoginState 是按 phase 判别的联合类型,8 个取值。终止态是 success / expired / rejected / risk / failed。
Credential 统一为 { cookie, expiresAt?, raw }。当前四个平台的登录策略都只填 cookie 与 raw,不填 expiresAt —— 平台响应里没有可靠的会话过期时刻。别把 Qrcode.expiresAt(二维码本身的过期时间,B站策略里取码后 3 分钟)误当成凭证过期时间。
三种消费方式
// login 在四个平台上都有;此处演示三种消费形态的写法差异
import from '@ikenxuan/amagi'
const = ({ : { : '' } })
const = ...()
// ① watch 回调
const = await .({
: () => .(.),
: () => .('已扫码')
})
if (.) .(..)
// ② for await
for await (const of ) {
.(.)
}
// ③ 手动单步
const = await .()
const = await .()watch() 返回的不是 AmagiResult。 它是 { ok: true, credential } | { ok: false, error } ——
没有 success / data / message / meta。早期设计稿里写的
result.data.cookie 是早期草稿,实际要写 outcome.credential.cookie。同样从信封收窄成裸
{ ok } 联合的还有 SmsChallenge.sendCode() 与策略的三个方法。
实现与设计文档的其余差异
设计文档比实现旧,以下几条会直接影响你写出来的代码:
| 设计文档说 | 实际实现 |
|---|---|
abort 的错误码是 ABORTED | 是 INTERNAL_ERROR(ABORTED 根本不在 AmagiErrorCode 联合里);且只在每轮循环开头检查 signal.aborted,飞行中的请求不会被中断 |
每次轮询进 trace、attempts 累加 | 会话 meta 恒 durationMs: 0 / attempts: 0,没有 trace,也不发 http:* / network:* |
busy 退避加倍在引擎里 | 在抖音策略里,且没有任何测试断言它 |
biz_trace_id / verify_way 由引擎维护 | 两个 ctx key 只有读取点、没有写入点;发码闭包自己另生成一个,于是发码与验码用的是两个不同的 biz_trace_id |
平台码在 state.raw 里 | LoginState 各分支都没有 raw 字段,原始响应只在失败分支的 error.raw 里 |
另外两条实现事实,文档里没写但影响很大:
- 抖音会话完全不走 v7 transport。 策略每步新建
DouyinPassportClient,它走transport/legacy.ts的 v6 路径(validateStatus: () => true),且只挑走requestConfig的timeout/proxy/adapter三项,其余(httpsAgent、自定义 header 等)被丢弃。B站策略相反,规范地用ctx.send。 resume(blob)的 cookie 取自实例,不是 blob。 反序列化保留了 blob 里的cookie/token/qrcode,但send用的是makeSessionCtx(platform, 实例cookie).send。而且login.resume从未被运行时测试走过。
会话请求不带 Cookie 头
会话用的 HttpClient 建 config 后同样 headers.delete('cookie'),但会话路径上没有 attachCookie 的等价物。所以 ctx.cookie 只用作 B站策略里 mergeSetCookie 的基底,不会真的作为请求头发出去。
v6 的登录方法仍在
抖音 4 个 passport 方法(发码 / 验码那套)作为 @deprecated 保留在静态 douyinFetcher 上,bound 形态有意不含它们,也不在方法名映射表里。B站的 3 个登录方法是普通端点。新代码走 login 会话。
下一步
最后更新于 2026-10-23