当前浏览的是 v7 预览版文档(v7 尚未正式发布,API 可能随版本调整)。本页是 v7 新增内容,去 v6 正式版文档首页
@ikenxuan/amagi

翻页与会话

声明式翻页的收敛条件与聚合方式,扫码登录的状态机,以及实现与设计文档不一致的几处

翻页与会话

两种「一次调用对应多次网络往返」的机制。翻页是无状态的,写在端点声明里;会话是有状态的,不是端点。

声明式翻页

端点声明里的 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 的端点有四条一致的做法,读任何一个都能推到其余:

  1. 全部省略 limitParam / countParam —— 读写都用 number。
  2. 收尾全部写在 paginate.merge 里,解构 { lastPage, items } —— merge 的第一参是收窄好的 PaginatedValue<TPage, TItem>:lastPage 恒有值(空跑由 empty 判别支单独承载),items 是跨页累积并截断到目标条数的条目。PaginatedValue 不带 pages(旧结构留每页完整解码体、没人读,纯内存滞留,已删)。
  3. 全部声明了 partial: 'tolerate' —— 中途某页失败时保留已取数据,见下文「中途失败」。
  4. 快手 5 个翻页端点都声明了 pageIntervalMs:评论 / 评论回复 / 用户作品 1200(取 ksp 参考实现的间隔),两个搜索端点 10_000(搜索接口对连续翻页更敏感);其余平台不设间隔。

merge 的产出形状统一是「最后一页的浅拷贝 + 跨页聚合后的 items 塞回列表字段」(...lastPage 直接展开,因 lastPage 恒有值、字段保持必填,不必再 ?? {} 兜底又断言回去)。所以调用方看到的不是分页结构,而是平台原始形状:

平台端点maxPageSizeitems 字段hasMore 判据
抖音 评论 / 评论回复50 / 3commentshas_more === 1
抖音 三个列表类18aweme_listhas_more === 1,但 userRecommendList 是布尔 === true
抖音 合集作品20aweme_listhas_more === 1
抖音 搜索15user_list 或 datahas_more !== 0(undefined 也算有更多)
B站 评论100data.repliescursor.is_end !== true
快手 评论 / 用户作品50 / 12嵌套三层pcursor 非空串
快手 评论回复50data.visionSubCommentList.subCommentsV2pcursorV2 非空串且不是 'no_more'
快手 搜索用户 / 搜索作品30 / 20users / feedspcursor 非空串且不是 'no_more'(复用 hasNextCursor)
小红书 笔记评论 / 子评论50 / 10data.commentsdata.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 从未被运行时测试走过。

会话用的 HttpClient 建 config 后同样 headers.delete('cookie'),但会话路径上没有 attachCookie 的等价物。所以 ctx.cookie 只用作 B站策略里 mergeSetCookie 的基底,不会真的作为请求头发出去。

v6 的登录方法仍在

抖音 4 个 passport 方法(发码 / 验码那套)作为 @deprecated 保留在静态 douyinFetcher 上,bound 形态有意不含它们,也不在方法名映射表里。B站的 3 个登录方法是普通端点。新代码走 login 会话。

下一步

最后更新于 2026-10-23

On this page