Files
catbus/tests/douyin.test.ts
cv-cat dc078b248b catbus:一个命令操作小红书、抖音、TikTok、B 站、快手、微博、闲鱼、淘宝、京东、X
10 个平台的 web 端全部用 TypeScript 移植自 cv-cat 的开源项目,请求与上游逐字节对拍。
统一的命令语法与 JSON 输出、按 平台 × 端 × 账号 隔离的登录态、给 AI Agent 用的技能与 llms.txt。
2026-10-06 19:24:55 +08:00

1233 lines
70 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import * as rand from '../src/core/rand.js'
import type { LocalMedia } from '../src/core/files.js'
import { fakeResponse, type HttpResponse, mockSender, type PreparedRequest } from '../src/core/http.js'
import { RAW } from '../src/core/schemas.js'
import * as api from '../src/platforms/douyin/web/api.js'
import { Douyin } from '../src/platforms/douyin/web/client.js'
import * as creator from '../src/platforms/douyin/web/creator.js'
import { crc32Hex } from '../src/platforms/douyin/web/crypto.js'
import { buildBlob, computedFeatures, defaultProfile, murmur3 } from '../src/platforms/douyin/web/dtrait.js'
import * as im from '../src/platforms/douyin/web/im.js'
import * as live from '../src/platforms/douyin/web/live.js'
import { Passport } from '../src/platforms/douyin/web/passport.js'
import * as proto from '../src/platforms/douyin/web/proto.js'
import { commonBehavior, commonReport, strDataReport } from '../src/platforms/douyin/web/mssdk.js'
import { ABogus, fakeWebid, liveSignature, randomMsToken, signedUrl, sm3, spliceUrl, svWebId, XBogus } from '../src/platforms/douyin/web/sign.js'
import { type GoldenCase, type GoldenRequest, loadCase, makeCtx, normalize, replay } from './golden.js'
import { cli, useTempHome } from './helpers.js'
const NOW = 1790000000123
const SEC_UID = 'MS4wLjABAAAAfakeSecUid0123456789abcdef'
const AWEME = '7433523124836060416'
/** 请求序列逐字节比较(cookie 的顺序也比较)。私信的 protobuf body 解码后比较:上游 upb 的 map 按哈希顺序序列化。 */
function expectReqs(actual: GoldenRequest[], expected: GoldenRequest[]): void {
const view = (r: GoldenRequest) => (r.url.startsWith('https://imapi.douyin.com/') && r.body && typeof r.body === 'object' ? { ...r, body: pbBody(r.body.base64) } : r)
actual.slice(0, expected.length).forEach((r, i) => expect(view(r), `第 ${i + 1} 个请求:${r.method} ${r.url}`).toEqual(view(expected[i]!)))
expect(actual.map((r) => `${r.method} ${r.url}`)).toEqual(expected.map((r) => `${r.method} ${r.url}`))
}
function pbBody(b64: string): unknown {
return proto.decode('Request', 'Request', Buffer.from(b64, 'base64'))
}
/** golden.ts 的 replay 加上二进制响应({base64})的支持。 */
async function replayBin<T>(c: GoldenCase, run: () => Promise<T>): Promise<{ requests: GoldenRequest[]; result?: T; error?: unknown }> {
const requests: GoldenRequest[] = []
const restoreRand = rand.deterministic({ seed: c.seed, now: c.now })
const restoreSender = mockSender((p) => {
requests.push(normalize(p))
const r = c.responses[requests.length - 1]
if (!r) throw new Error(`TS 实现多发了请求:${p.method} ${p.url}`)
return goldenReply(r, p.url)
})
try {
return { requests, result: await run() }
} catch (error) {
return { requests, error }
} finally {
restoreSender()
restoreRand()
}
}
const media = (b64: string, filename = 'file.bin'): LocalMedia => ({ data: new Uint8Array(Buffer.from(b64, 'base64')), filename, contentType: 'application/octet-stream' })
/** 登录态:与 gen.py 的 logged_auth 一致(msToken、webid、uid 已缓存)。 */
function loggedCtx(c: GoldenCase) {
const ctx = makeCtx({ platform: 'douyin', cookies: c.input.cookies, cookieDomain: '.douyin.com' })
const tokens = ctx.credential.scopes.main!.tokens
Object.assign(tokens, { ticket: c.input.ticket, ts_sign: c.input.ts_sign, client_cert: c.input.client_cert, webid: c.input.webid, uid: c.input.uid })
tokens.msToken = { value: c.input.ms_token, at: NOW }
Object.assign(ctx.credential.device, { private_key: c.input.private_key, dtrait_blob: c.input.dtrait_blob })
if (c.input.dtrait_profile) ctx.credential.device.dtrait_profile = c.input.dtrait_profile
return ctx
}
async function logged<T>(c: GoldenCase, fn: (d: Douyin) => Promise<T>): Promise<T> {
const d = new Douyin(loggedCtx(c))
await d.init()
return fn(d)
}
type Run = (c: GoldenCase) => Promise<unknown>
const CASES: Record<string, Run> = {
work_info: (c) => logged(c, (d) => api.workInfo(d, AWEME)),
user_works: (c) => logged(c, (d) => api.userWorks(d, SEC_UID, '0')),
user_works_p2: (c) => logged(c, (d) => api.userWorks(d, SEC_UID, '1735660800000')),
comments: (c) => logged(c, (d) => api.comments(d, AWEME, '10')),
replies: (c) => logged(c, (d) => api.replies(d, AWEME, c.input.cid, '0', '10')),
user_info: (c) => logged(c, (d) => api.userInfo(d, SEC_UID)),
search_general: (c) => logged(c, (d) => api.searchGeneral(d, '美食 探店', '15')),
search_user: (c) => logged(c, (d) => api.searchUser(d, '巴旦木公主')),
search_live: (c) => logged(c, (d) => api.searchLive(d, '三角洲', '15')),
user_favorite: (c) => logged(c, (d) => api.userFavorite(d, SEC_UID)),
collect_list: (c) => logged(c, (d) => api.collectList(d)),
followers: (c) => logged(c, (d) => api.followers(d, c.input.user_id, SEC_UID)),
following: (c) => logged(c, (d) => api.following(d, c.input.user_id, SEC_UID, '1735660800')),
notices: (c) => logged(c, (d) => api.notices(d, '1735660000', '1735660800')),
feed: (c) => logged(c, (d) => api.feed(d, '20', '3')),
my_uid: (c) => logged(c, (d) => api.myUid(d)),
device_id: (c) => logged(c, (d) => api.deviceId(d)),
my_sec_uid: (c) => logged(c, (d) => api.mySecUid(d)),
digg: (c) => logged(c, (d) => api.digg(d, AWEME, '1')),
undigg: (c) => logged(c, (d) => api.digg(d, AWEME, '0')),
collect: (c) => logged(c, (d) => api.collect(d, AWEME, '1')),
uncollect: (c) => logged(c, (d) => api.collect(d, AWEME, '0')),
comment_publish: (c) => logged(c, (d) => api.publishComment(d, AWEME, '好看! & ok')),
comment_reply: (c) => logged(c, (d) => api.publishComment(d, AWEME, '回复', c.input.reply_id)),
digg_uid: (c) =>
logged(c, (d) => {
delete d.tokens.uid
return api.digg(d, AWEME, '1')
}),
live_info: (c) => logged(c, (d) => api.liveInfo(d, c.input.web_rid)),
live_room_enter: (c) => logged(c, (d) => api.liveRoomEnter(d, c.input.web_rid)),
live_rank: (c) => logged(c, (d) => api.liveRank(d, c.input.room_id, c.input.anchor_id, c.input.sec_uid, c.input.web_rid)),
live_production: (c) => logged(c, (d) => api.liveProduction(d, c.input.url, c.input.room_id, c.input.anchor_id)),
product_detail: (c) => logged(c, (d) => api.productDetail(d, 'https://live.douyin.com/', c.input.promotion_id)),
product_comments: (c) => logged(c, (d) => api.productComments(d, c.input.product_id, c.input.shop_id, '10')),
webcast_fetch: (c) => logged(c, (d) => api.webcastFetch(d, c.input.user_id, c.input.room_id, `https://live.douyin.com/852953608964`)),
live_like: (c) => logged(c, (d) => api.liveLike(d, c.input.room_id, '1')),
live_chat: (c) => logged(c, (d) => api.liveChat(d, c.input.room_id, c.input.content, c.input.web_rid)),
mstoken_renew: (c) =>
logged(c, (d) => {
;(d.tokens.msToken as any).at = 0
return d.msToken()
}),
mstoken_common: (c) => logged(c, (d) => api.commonMstoken(d, c.input.ms_token, commonReport())),
mstoken_common_sms: (c) => logged(c, (d) => api.commonMstoken(d, c.input.ms_token, commonReport(6383, 6241, true))),
mstoken_behavior: (c) => logged(c, (d) => api.commonMstoken(d, c.input.ms_token, commonBehavior())),
server_cert: (c) => logged(c, (d) => api.serverCert(d, 2906, c.input.cookies, 'https://creator.douyin.com')),
im_create: (c) => logged(c, (d) => im.createConversation(d, c.input.to_uid)),
im_send_text: (c) =>
logged(c, (d) => im.sendMessage(d, { conversationId: c.input.conversation_id, shortId: c.input.short_id, ticket: c.input.conv_ticket }, 7, im.textContent(c.input.text))),
comment_publish_ecdsa: (c) => logged(c, (d) => api.publishComment(d, AWEME, 'ecdsa')),
im_upload_image: (c) => logged(c, (d) => im.uploadImage(d, media(c.input.png))),
im_send_image: (c) =>
logged(c, async (d) => {
const content = await im.uploadImage(d, media(PNG))
return im.sendMessage(d, { conversationId: CONV_ID, shortId: '7400000000000000123', ticket: 'fake-conv-ticket' }, 27, content)
}),
post_images: (c) => logged(c, (d) => creator.postImages(d, [media(PNG), media(PNG)], { title: c.input.title, desc: c.input.desc, visibility: 0 })),
post_video_cover: (c) => logged(c, (d) => creator.postVideo(d, media(c.input.video), media(PNG), { title: c.input.title, desc: c.input.desc, visibility: 2 })),
post_video: (c) => logged(c, (d) => creator.postVideo(d, media(c.input.video), null, { title: c.input.title, desc: c.input.desc, visibility: 0, timing: c.input.timing })),
media_upload_image: (c) =>
logged(c, async (d) => {
await creator.bootstrap(d)
const sts = await creator.uploadAuth(d, creator.POST_IMAGE_REFERER)
const info = await creator.uploadImage(d, sts, media(PNG), '')
return [info, await creator.mediaUrl(d, info.uri)]
}),
// ---------------------------------------------------------------- 第三部分(gen_gap.py)
search_general_filter: (c) =>
logged(c, (d) => api.searchGeneral(d, '美食', '0', '', { sortType: '2', publishTime: '7', filterDuration: '1-5', searchRange: '3', contentType: '2' })),
search_video: (c) => logged(c, (d) => api.searchVideo(d, '美食 探店', '0', '16', { sortType: '1', publishTime: '180', filterDuration: '0-1', searchRange: '1' })),
search_video_p2: (c) => logged(c, (d) => api.searchVideo(d, '美食', '16', '16', { sortType: '0', publishTime: '0', filterDuration: '', searchRange: '0' }, c.input.search_id)),
search_user_filter: (c) => logged(c, (d) => api.searchUser(d, '巴旦木公主', '0', '25', '1w_10w', 'enterprise_user')),
work_list: (c) =>
logged(c, async (d) => {
await creator.bootstrap(d)
return creator.workList(d)
}),
post_images_extra: (c) =>
logged(c, (d) =>
creator.postImages(d, [media(PNG), media(PNG)], {
title: '标题',
desc: '正文 #话题 @好友',
visibility: 1,
allowDownload: false,
timing: 1790086400,
coverIndex: 1,
poi: POI,
mixId: MIX,
hotSpot: { word: '热点词' },
}),
),
post_video_extra: (c) =>
logged(c, (d) =>
creator.postVideo(d, media(c.input.video), 'tos-cn-i-fake/mycover', {
title: '视频标题',
desc: '视频描述',
visibility: 0,
allowDownload: false,
poi: POI,
mixId: MIX,
hotSpot: { word: '热点词' },
}),
),
collect_move: (c) => logged(c, (d) => api.collectMove(d, AWEME, '我的收藏夹', c.input.folder)),
collect_remove: (c) => logged(c, (d) => api.collectRemove(d, AWEME, '我的收藏夹', c.input.folder)),
im_send_file: (c) => logged(c, async (d) => im.sendMessage(d, CONV, im.IM_FILE, await im.uploadFile(d, media(c.input.file, 'file.bin')))),
im_share_aweme: (c) =>
logged(c, async (d) => {
const detail = (await api.workInfo(d, AWEME)).aweme_detail
return im.sendMessage(d, CONV, im.IM_SHARE_AWEME, im.shareAwemeContent(detail, await d.uid()))
}),
im_share_photos: (c) =>
logged(c, async (d) => {
const detail = (await api.workInfo(d, '7433523124836060417')).aweme_detail
return im.sendMessage(d, CONV, im.IM_SHARE_PHOTOS, im.sharePhotosContent(detail, await d.uid()))
}),
im_user_card: (c) => logged(c, (d) => im.sendMessage(d, CONV, im.IM_SHARE_USER, im.userCardContent({ uid: '5550001', secUid: SEC_UID, name: '作者', avatar: c.input.avatar }))),
im_share_web: (c) => logged(c, (d) => im.sendMessage(d, CONV, im.IM_SHARE_WEB, im.shareWebContent(c.input.url))),
live_rank_thousand: (c) => logged(c, (d) => api.liveThousandRank(d, c.input.room_id, c.input.web_rid)),
live_like_count: (c) => logged(c, (d) => api.liveLike(d, c.input.room_id, '10')),
product_comment_counter: (c) => logged(c, (d) => api.productCommentCounter(d, '3622058069401408999', 'fakeShop01')),
product_comments_tag: (c) => logged(c, (d) => api.productComments(d, '3622058069401408999', 'fakeShop01', '0', '10', '0', '7')),
notices_group: (c) => logged(c, (d) => api.notices(d, '0', '0', '10', '401')),
// ---------------------------------------------------------------- dtrait 设备档案(上游 fix-dtrait-blob):没有导入 dtrait_blob 时按档案现算
comment_publish_profile: (c) => logged(c, (d) => api.publishComment(d, AWEME, c.input.text)),
comment_publish_custom_profile: (c) => logged(c, (d) => api.publishComment(d, AWEME, c.input.text)),
post_images_profile: (c) => logged(c, (d) => creator.postImages(d, [media(PNG), media(PNG)], { title: c.input.title, desc: c.input.desc, visibility: 0 })),
}
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6BAAAAEklEQVR4nGNgYGD4z8DAwMAAAAwAAf8v0Mo8AAAAAElFTkSuQmCC'
const CONV_ID = '0:1:97872126662:1234567890'
const CONV = { conversationId: CONV_ID, shortId: '7400000000000000123', ticket: 'fake-conv-ticket' }
const POI = { poi_id: '6601136811511474183', poi_name: '北京·天安门' }
const MIX = '7400000000000000888'
describe('douyin 对拍:请求构造与签名', () => {
for (const [name, run] of Object.entries(CASES)) {
it(name, async () => {
const c = loadCase('douyin', name)
const { requests, error } = await replayBin(c, () => run(c))
if (error) throw error
expectReqs(requests, c.requests)
})
}
})
describe('douyin 对拍:命令流程', () => {
it('游客 item get:注册 ttwid → 设备号 → mssdk 换 msToken → 作品详情', async () => {
const c = loadCase('douyin', 'guest_item_get')
const { itemGet } = await import('../src/platforms/douyin/web/commands.js')
const ctx = makeCtx({ platform: 'douyin', account: 'guest', args: { item: `https://www.douyin.com/video/${AWEME}` } })
const { requests, result, error } = await replay(c, () => itemGet(ctx))
if (error) throw error
expectReqs(requests, c.requests)
expect((result as any)[RAW]).toEqual(c.result.aweme_detail)
expect(result).toMatchObject({ id: AWEME, kind: 'video', url: `https://www.douyin.com/video/${AWEME}`, author: { id: SEC_UID, name: '作者' } })
// 设备数据留在游客凭证里,下次直接复用
const tokens = ctx.credential.scopes.main!.tokens
expect(tokens.webid).toBe('7400000000000000001')
expect((tokens.msToken as any).value).toBe('guest-mstoken-from-mssdk')
expect(ctx.credential.scopes.main!.cookies.map((x) => x.name)).toEqual(['s_v_web_id', 'ttwid'])
})
it('live products:直播间页面 → pop/v3 商品,输出带 product_id / shop_id 的商品 url', async () => {
const c = loadCase('douyin', 'live_products')
const { liveProducts } = await import('../src/platforms/douyin/web/commands.js')
const ctx = loggedCtx(c)
ctx.args = { room: c.input.web_rid }
const { requests, result, error } = await replay(c, () => liveProducts(ctx))
if (error) throw error
expectReqs(requests, c.requests)
expect(result).toMatchObject([{ id: '3622058069401408240', kind: 'goods', title: '测试商品', price: { amount: 19.9, currency: 'CNY' } }])
expect((result as any)[0].url).toContain('shop_id=fakeShop01')
})
})
describe('douyin 纯算', () => {
it('sm3 / a_bogus / X-Bogus / secsdk / mssdk 与上游一致', () => {
const c = loadCase('douyin', 'pure')
const restore = rand.deterministic({ seed: c.seed, now: c.now })
try {
const signer = new ABogus()
const xb = new XBogus()
const queries = ['device_platform=webapp&aid=6383', 'a=%E4%B8%AD&b=', '']
const hosts = ['www.douyin.com', 'live.douyin.com', 'creator.douyin.com', 'login.douyin.com']
const hex = (b: Uint8Array) => Buffer.from(b).toString('hex')
const md5 = (s: string) => createHash('md5').update(s).digest('hex')
expect({
sm3: [hex(sm3('abc')), hex(sm3('abcd'.repeat(16))), hex(sm3('中文'))],
abogus: queries.flatMap((q, i) => hosts.map((h) => signer.sign(q, i === 1 ? 'x=1' : '', h))),
counter: signer.counter,
xbogus: [xb.sign(md5('stub')), xb.sign(md5('stub2'))],
signature: liveSignature(new XBogus(), '7400000000000000000', '111222333'),
sign_url: [
signedUrl('https://www.douyin.com/aweme/v1/web/aweme/detail/', 'aweme_id=1&keyword=a+b;c&x=100%&e=&k', 'fake-uifid'),
signedUrl('https://www.douyin.com/aweme/v1/web/aweme/post/', 'uifid=u%20v&a=中文'),
],
mstoken: randomMsToken(),
webid: fakeWebid(),
sv_web_id: svWebId(),
report: strDataReport(),
splice: spliceUrl([
['a', 'x/y z'],
['b', null],
['c', 1],
['中', '文'],
]),
}).toEqual(c.result)
} finally {
restore()
}
})
})
describe('douyin 对拍:登录', () => {
function loginCtx(c: GoldenCase) {
const ctx = makeCtx({ platform: 'douyin', account: 'guest' })
ctx.credential.device.dtrait_blob = c.input.dtrait_blob
return ctx
}
it('扫码:页面 bootstrap(acrawler、challenge template、client_data_v2)→ 取码 → new / scanned / confirmed → 跟随重定向', async () => {
const c = loadCase('douyin', 'login_qrcode')
const d = new Douyin(loginCtx(c))
const p = new Passport(d)
const shown: string[] = []
const { requests, error } = await replayBin(c, () => p.qrcodeLogin(async (url) => void shown.push(url)))
if (error) throw error
expectReqs(requests, c.requests)
expect(shown).toHaveLength(1)
expect(Object.fromEntries(d.cookies())).toEqual(c.result.cookies)
expect(d.tokens).toMatchObject({ ticket: c.result.ticket, ts_sign: c.result.ts_sign, client_cert: c.result.client_cert })
}, 60_000)
it('短信(严格):bootstrap → 发验证码 → 验证码登录', async () => {
const c = loadCase('douyin', 'login_sms')
const d = new Douyin(loginCtx(c))
const p = new Passport(d)
const { requests, error } = await replayBin(c, async () => {
await p.bootstrap(true)
await p.sendSmsCode(c.input.phone)
await p.phoneLogin(c.input.phone, c.input.code)
})
if (error) throw error
expectReqs(requests, c.requests)
expect(Object.fromEntries(d.cookies())).toEqual(c.result.cookies)
expect(d.tokens).toMatchObject({ ticket: c.result.ticket, ts_sign: c.result.ts_sign })
}, 60_000)
it('短信(严格):没有导入 dtrait_blob 时按默认设备档案现算 x-tt-session-dtrait,不再本地拒绝', async () => {
const c = loadCase('douyin', 'login_sms_profile')
const d = new Douyin(loginCtx(c))
const p = new Passport(d)
const { requests, error } = await replayBin(c, async () => {
await p.bootstrap(true)
await p.sendSmsCode(c.input.phone)
await p.phoneLogin(c.input.phone, c.input.code)
})
if (error) throw error
expectReqs(requests, c.requests)
expect(Object.fromEntries(d.cookies())).toEqual(c.result.cookies)
}, 60_000)
})
describe('douyin 对拍:长连接', () => {
it('直播弹幕:直播间页面 → im/fetch → wss 地址(X-Bogus signature)与握手头', async () => {
const c = loadCase('douyin', 'live_ws')
const { requests, result, error } = await replayBin(c, () =>
logged(c, async (d) => live.liveSocket(d, (await api.liveInfo(d, c.input.web_rid))!, c.input.web_rid)),
)
if (error) throw error
expectReqs(requests, c.requests)
expect(result!.url).toBe(c.result.url)
const { Cookie, Origin, ...headers } = result!.headers
expect(headers).toEqual(Object.fromEntries(Object.entries(c.result.header).filter(([k]) => !['Upgrade', 'Connection'].includes(k))))
expect([Cookie, Origin]).toEqual([c.result.cookie, c.result.origin])
})
it('直播推送帧:gzip 解压、needAck 回 ack,聊天 / 礼物 / 进场 / 关注转成 Event', () => {
const c = loadCase('douyin', 'live_frame')
const { events, ack } = live.liveFrame(new Uint8Array(Buffer.from(c.result.frame, 'base64')))
expect(Buffer.from(ack!).toString('base64')).toBe(c.result.sent[0])
expect(events.map((e) => [e.type, e.user?.name, e.text, e.gift])).toEqual([
['chat', '观众A', '主播好!', null],
['gift', '观众B', null, { name: '小心心', count: 3 }],
['enter', '观众C', null, null],
['follow', '观众D', null, null],
])
expect(events[0]!.user).toEqual({ id: 'MS4wLjABAAAAviewerA', name: '观众A', url: 'https://www.douyin.com/user/MS4wLjABAAAAviewerA' })
})
it('私信:设备号 → frontier-im 地址(access_key)与握手头;推送帧转成 Message', async () => {
const c = loadCase('douyin', 'recv_ws')
const { requests, result, error } = await replayBin(c, () => logged(c, (d) => live.frontierSocket(d)))
if (error) throw error
expectReqs(requests, c.requests)
expect(result!.url).toBe(c.result.url)
const { Cookie, Origin, ...headers } = result!.headers
expect(headers).toEqual(Object.fromEntries(Object.entries(c.result.header).filter(([k]) => k !== 'Sec-WebSocket-Extensions')))
expect([Cookie, Origin]).toEqual([c.result.cookie, c.result.origin])
const msg = live.imFrame(new Uint8Array(Buffer.from(loadCase('douyin', 'im_push').result.frame, 'base64')))
expect(msg).toMatchObject({ id: '7400000000000000777', conversation_id: CONV_ID, from: { id: '1234567890' }, type: 'text', text: '你好呀' })
})
})
describe('douyin 归一化(真实响应的结构)', () => {
it('通知类型按通知体的字段判断:comment → 评论,digg → 点赞,interactive_notice → 系统', async () => {
const norm = await import('../src/platforms/douyin/web/normalize.js')
const user = { uid: '1', sec_uid: SEC_UID, nickname: '粉丝' }
const aweme = { aweme_id: '7600000000000000001' }
const comment = norm.notice({ nid_str: '1', type: 31, create_time: 1790399397, comment: { comment: { text: '好看', user }, aweme } })
expect(comment).toMatchObject({ type: 'comment', user: { id: SEC_UID, name: '粉丝' }, target: { id: aweme.aweme_id }, text: '好看' })
const like = norm.notice({ nid_str: '2', type: 41, create_time: 1790399397, digg: { from_user: [user], aweme, content: '赞了你的作品' } })
expect(like).toMatchObject({ type: 'like', user: { id: SEC_UID }, target: { id: aweme.aweme_id }, text: '赞了你的作品' })
const other = norm.notice({ nid_str: '3', type: 9009, create_time: 1790399397, aweme_id: '0', interactive_notice: { content: '推荐了你的图文', from_user: [user] } })
expect(other).toMatchObject({ type: 'system', user: { id: SEC_UID }, target: null, text: '推荐了你的图文' })
})
})
describe('douyin 缺 UIFID 时自动补上', () => {
it('报 Uifid Not Found 且凭证里没有 UIFID:请求一次推荐流,拿到 Set-Cookie 的 UIFID 后重试', async () => {
const { retryWithUifid } = await import('../src/platforms/douyin/web/commands.js')
const { CatbusError } = await import('../src/core/errors.js')
const ctx = makeCtx({ platform: 'douyin', cookies: 'sessionid=fake; ttwid=fake', cookieDomain: '.douyin.com' })
const urls: string[] = []
const restore = mockSender((p) => {
urls.push(p.url)
if (p.url.includes('/aweme/v1/web/tab/feed/') || p.url.includes('/aweme/v1/web/module/feed/')) {
return fakeResponse({ status_code: 0, aweme_list: [] }, { headers: [['set-cookie', 'UIFID=fake-uifid; Domain=.douyin.com; Path=/']] })
}
return fakeResponse({ status_code: 0 })
})
let calls = 0
try {
const run = retryWithUifid(async (c) => {
calls++
if (!c.credential.scopes.main!.cookies.some((k) => k.name === 'UIFID')) throw new CatbusError('RISK_CONTROL', 'Uifid Not Found', { detail: { kind: 'blocked', reason: 'uifid' } })
return 'ok'
})
expect(await run(ctx)).toBe('ok')
} finally {
restore()
}
expect(calls).toBe(2)
expect(urls.some((u) => u.includes('/feed/'))).toBe(true)
})
it('其他错误原样抛出;长连接的 handler(同步返回 AsyncIterable)原样交回', async () => {
const { retryWithUifid } = await import('../src/platforms/douyin/web/commands.js')
const { CatbusError } = await import('../src/core/errors.js')
const ctx = makeCtx({ platform: 'douyin', cookies: 'sessionid=fake', cookieDomain: '.douyin.com' })
await expect(retryWithUifid(async () => Promise.reject(new CatbusError('UPSTREAM', 'x')))(ctx) as Promise<unknown>).rejects.toMatchObject({ code: 'UPSTREAM' })
const stream = (async function* () {})()
expect(retryWithUifid(() => stream)(ctx)).toBe(stream)
})
})
describe('douyin 对拍:补齐的命令流程', () => {
it('短信(SSO):login.douyin.com 页 → send_activation_code(gfkadpd 拦截页补 cookie 重试)→ quick_login → 跟随重定向', async () => {
const c = loadCase('douyin', 'login_sms_sso')
const d = new Douyin(makeCtx({ platform: 'douyin', account: 'guest' }))
const p = new Passport(d)
const { requests, error } = await replayBin(c, async () => {
await p.bootstrapSso()
await p.sendSmsCodeSso(c.input.phone)
await p.phoneLoginSso(c.input.phone, c.input.code)
})
if (error) throw error
expectReqs(requests, c.requests)
expect(Object.fromEntries(d.cookies())).toEqual(c.result.cookies)
expect(d.tokens).toMatchObject({ ticket: c.result.ticket, ts_sign: c.result.ts_sign })
// 非 TTY 两步登录:发码后的会话能原样恢复,并记得走 SSO
const restored = new Passport(new Douyin(makeCtx({ platform: 'douyin', account: 'guest' })))
restored.restore(p.snapshot() as Record<string, unknown>)
expect([restored.sso, restored.ssoPageStartedMs]).toEqual([true, c.now])
})
it('item search --type video:视频频道搜索,游标带上 X-Tt-Logid 作为下一页的 search_id', async () => {
const c = loadCase('douyin', 'search_video')
const { itemSearch } = await import('../src/platforms/douyin/web/commands.js')
const ctx = loggedCtx(c)
ctx.args = { keyword: c.input.keyword }
ctx.options = { type: 'video', sort: 'popular', time: 'half_year', length: 'short', range: 'seen' }
const { requests, result, error } = await replay(c, () => itemSearch(ctx) as Promise<any>)
if (error) throw error
expectReqs(requests, c.requests)
expect(result.page).toEqual({ cursor: `16,${c.result[0]}`, has_more: true })
expect(result.data).toMatchObject([{ id: AWEME, kind: 'video', text: '搜索到的视频' }])
})
it('item list:进入创作者中心(页面、oversea、csrf)→ work_list', async () => {
const c = loadCase('douyin', 'work_list')
const { itemList } = await import('../src/platforms/douyin/web/commands.js')
const ctx = loggedCtx(c)
const { requests, result, error } = await replay(c, () => itemList(ctx) as Promise<any>)
if (error) throw error
expectReqs(requests, c.requests)
expect(result).toEqual({ data: [], page: { cursor: null, has_more: false } })
})
it('搜索筛选的取值映射:--type image 在综合频道是 content_type 2,--range all 在视频频道是 0', async () => {
const { searchFilters } = await import('../src/platforms/douyin/web/commands.js')
expect(searchFilters({}, false)).toEqual({ sortType: '0', publishTime: '0', filterDuration: '', searchRange: '', contentType: '' })
expect(searchFilters({ type: 'image' }, false)).toMatchObject({ contentType: '2' })
expect(searchFilters({ type: 'all' }, false)).toMatchObject({ contentType: '' })
expect(searchFilters({ sort: 'latest', time: 'week', length: 'medium', range: 'following' }, false)).toEqual({
sortType: '2',
publishTime: '7',
filterDuration: '1-5',
searchRange: '3',
contentType: '',
})
expect(searchFilters({ range: 'all', length: 'long' }, true)).toMatchObject({ searchRange: '0', filterDuration: '5-10000', contentType: undefined })
})
it('item collect --folder:按名字找到收藏夹,收藏后移进去;uncollect --folder 只移出', async () => {
const { itemCollect, itemUncollect } = await import('../src/platforms/douyin/web/commands.js')
const c = loadCase('douyin', 'collect_move')
const urls: string[] = []
const restore = mockSender((p) => {
urls.push(`${p.method} ${p.url.split('?')[0]}`)
if (p.url.includes('/collects/list/')) return fakeResponse({ status_code: 0, collects_list: [{ collects_id_str: c.input.folder, collects_name: '我的收藏夹' }] })
if (p.method === 'HEAD') return fakeResponse('', { headers: [['x-ware-csrf-token', '0001,fakecsrf,86370,success,x']] })
if (p.url.includes('get_client_cert')) return fakeResponse({ message: 'success', data: { server_cert: 'x' } })
if (p.url.includes('/collects/video/move/')) expect(p.url).toContain(p.url.includes('to_collects_id') ? `to_collects_id=${c.input.folder}` : `from_collects_id=${c.input.folder}`)
return fakeResponse({ status_code: 0 })
})
try {
const ctx = loggedCtx(c)
ctx.args = { item: AWEME }
ctx.options = { folder: '我的收藏夹' }
expect(await itemCollect(ctx)).toEqual({ id: AWEME })
const moves = urls.filter((u) => u.includes('/collect'))
expect(moves).toEqual([
'GET https://www.douyin.com/aweme/v1/web/collects/list/',
'POST https://www.douyin.com/aweme/v1/web/aweme/collect/',
'POST https://www.douyin.com/aweme/v1/web/collects/video/move/',
])
urls.length = 0
expect(await itemUncollect(ctx)).toEqual({ id: AWEME })
expect(urls.filter((u) => u.includes('/collect'))).toEqual(['GET https://www.douyin.com/aweme/v1/web/collects/list/', 'POST https://www.douyin.com/aweme/v1/web/collects/video/move/'])
ctx.options = { folder: '不存在' }
await expect(itemCollect(ctx)).rejects.toMatchObject({ code: 'USAGE' })
} finally {
restore()
}
})
it('发布正文:--topic / --tag 写成 #话题,--mention 写成 @用户(上游说明:描述里可内嵌纯文本)', async () => {
const { publishDesc } = await import('../src/platforms/douyin/web/commands.js')
expect(publishDesc({ text: '正文', topic: ['旅行', '#美食'], tag: ['日常'], mention: ['@好友'] })).toBe('正文 #日常 #旅行 #美食 @好友')
expect(publishDesc({ topic: ['旅行'] })).toBe('#旅行')
})
it('msg send 的参数约束:--file / --share 也算消息内容', async () => {
const { PLATFORMS } = await import('../src/platforms/index.js')
const web = PLATFORMS.find((p) => p.id === 'douyin')!.endpoints.web
const cmd = (web as any).commands.get('msg send')
expect(cmd.check({}, { to: 'u', share: 'x' })).toBeUndefined()
expect(cmd.check({}, { to: 'u', file: 'a.pdf' })).toBeUndefined()
expect(cmd.check({}, { to: 'u' })).toMatch(/--file 或 --share/)
expect(cmd.check({ text: 'hi' }, { to: 'u', conversation: 'c' })).toMatch(/需要用一个/)
// --to 与 --item 可以同时用(AGENTS 4.8),抖音不支持,由 handler 报 UNSUPPORTED
expect(cmd.check({ text: 'hi' }, { to: 'u', item: 'i' })).toBeUndefined()
})
})
describe('douyin 归一化:补齐的命令', () => {
it('item list:定时未发布 → draft,审核中 / 违规 / 私密,其余 published', async () => {
const norm = await import('../src/platforms/douyin/web/normalize.js')
const base = { aweme_id: '7600000000000000001', desc: '作品', create_time: 1790000000, video: { cover: { url_list: ['https://p3.douyinpic.com/c.jpeg'] } }, statistics: { play_count: 12 } }
expect(norm.work({ ...base, timer: { status: 0, public_time: 1790086400 } })).toMatchObject({ id: base.aweme_id, kind: 'video', status: 'draft', stats: { views: 12 } })
expect(norm.work({ ...base, status: { in_reviewing: true } }).status).toBe('reviewing')
expect(norm.work({ ...base, status: { is_prohibited: true } }).status).toBe('rejected')
expect(norm.work({ ...base, status: { private_status: 1 } }).status).toBe('private')
expect(norm.work({ ...base, status: { private_status: 0 }, timer: { status: 1 } }).status).toBe('published')
expect(norm.work({ ...base, aweme_type: 68, images: [{ url_list: ['https://p3.douyinpic.com/i.jpeg'] }] })).toMatchObject({ kind: 'image', url: `https://www.douyin.com/note/${base.aweme_id}` })
})
it('live media:live_core_sdk_data 的各清晰度 flv / hls(带分辨率),再补 flv_pull_url / hls_pull_url_map,去重', async () => {
const norm = await import('../src/platforms/douyin/web/normalize.js')
const streamData = { data: { origin: { main: { flv: 'https://pull-flv-l1.douyincdn.com/stage/stream-1_or4.flv', hls: 'https://pull-hls-l1.douyincdn.com/stage/stream-1_or4/index.m3u8', sdk_params: '{"resolution":"1920x1080","vbitrate":6000000}' } } } }
const body = {
status_code: 0,
data: {
data: [
{
status: 2,
stream_url: {
flv_pull_url: { FULL_HD1: 'https://pull-flv-l1.douyincdn.com/stage/stream-1_or4.flv', SD1: 'https://pull-flv-l1.douyincdn.com/stage/stream-1_ld.flv' },
hls_pull_url_map: { SD1: 'https://pull-hls-l1.douyincdn.com/stage/stream-1_ld/index.m3u8' },
live_core_sdk_data: { pull_data: { stream_data: JSON.stringify(streamData) } },
},
},
],
},
}
expect(norm.liveStreams(body).map((m) => [m.id, m.url?.split('/').at(-1), m.width, m.height])).toEqual([
['origin.flv', 'stream-1_or4.flv', 1920, 1080],
['origin.hls', 'index.m3u8', 1920, 1080],
['SD1.flv', 'stream-1_ld.flv', null, null],
['SD1.hls', 'index.m3u8', null, null],
])
expect(norm.liveStreams({ data: { data: [{ status: 4 }] } })).toEqual([])
})
it('live rank / 千票榜的行、商品评价标签', async () => {
const norm = await import('../src/platforms/douyin/web/normalize.js')
const rows = norm.rankRows({ data: { list: [{ user: { id_str: '1', sec_uid: SEC_UID, nickname: '榜一' }, score: 1000 }, { user: { id_str: '2', nickname: '榜二' }, rank: 2 }] } })
expect(rows.map((r) => [r.rank, r.user.id, r.user.name, r.score])).toEqual([
[1, SEC_UID, '榜一', 1000],
[2, '2', '榜二', null],
])
const labels = norm.commentLabels({ counter_info: { tags: [{ tag_id: 7, tag_name: '好评', count: 120 }, { TagId: '9', TagName: '有图', Count: '3' }] } })
expect(labels).toEqual([
{ id: '7', name: '好评', count: 120 },
{ id: '9', name: '有图', count: 3 },
])
})
it('live history:im/fetch 的 LiveResponse 里带回的消息转成 Event', () => {
const chat = proto.encode('Live', 'ChatMessage', { user: { nickname: '观众A', sec_uid: 'MS4wLjABAAAAviewerA' }, content: '主播好' })
const member = proto.encode('Live', 'MemberMessage', { user: { id: 222, nickname: '观众B' } })
const raw = proto.encode('Live', 'LiveResponse', {
messagesList: [
{ method: 'WebcastChatMessage', payload: chat },
{ method: 'WebcastMemberMessage', payload: member },
{ method: 'WebcastUnknownMessage', payload: chat },
],
cursor: 't-1',
})
expect(live.fetchEvents(raw).map((e) => [e.type, e.user?.name, e.text])).toEqual([
['chat', '观众A', '主播好'],
['enter', '观众B', null],
])
})
})
/** 上游 tests/test_dtrait_profile.py 的 CAPTURED_BLOB:Chrome 153 DevTools 里抓到的内层 blob,默认档案就是从它取证的。 */
const CAPTURED_BLOB =
'IAAAAADQIC0FVqIh4kBm/yLQO48ZI8SHsdUkUnQhRSUtc6pZJoXtgvcnqvUbwih5/' +
'ufyKR3SnsMqMtFjyisa3jq/LEbrp0It3oooWy6x8OwMLzYf+ukwKpioVDEAAAAAMi' +
'Zm67kzi/GgODSiHrKONRJqRnA2hZHFNjcbzZ0NOFhf5lQ5ucywezqnDTbrO0LJZ588' +
'JQFrBT3os+69Pnc1cw4/6hH+lkBcuWKWR5QWrJM='
const errorCode = (fn: () => unknown): unknown => {
try {
fn()
} catch (err) {
return (err as { code?: unknown }).code ?? err
}
return null
}
describe('douyin dtrait 内层 blob(上游 utils/dtrait_features.py、fix-dtrait-blob 的默认档案)', () => {
const c = loadCase('douyin', 'dtrait_blob')
it('默认档案生成的 blob 与 Chrome 抓到的逐字节一致,也与上游 build_blob 一致', () => {
expect(c.input.captured).toBe(CAPTURED_BLOB)
expect(buildBlob(defaultProfile())).toBe(CAPTURED_BLOB)
expect(buildBlob(defaultProfile())).toBe(c.result.default)
expect(computedFeatures(defaultProfile())).toEqual(c.result.default_features)
})
it('自定义档案:bool 位图跨过 32、reserved / version、accessType、小数与非 ASCII 字段', () => {
expect(buildBlob(c.input.custom_profile)).toBe(c.result.custom)
expect(buildBlob(c.input.custom_profile, 1)).toBe(c.result.custom_edge)
expect(computedFeatures(c.input.custom_profile)).toEqual(c.result.custom_features)
})
it('murmur3 与 JS 的 Number → 字符串(上游 js_number_to_str)', () => {
expect(c.result.murmur3.map(([s]: [string]) => [s, murmur3(s)])).toEqual(c.result.murmur3)
expect(c.result.js_number.map(([x]: [number]) => [x, String(x)])).toEqual(c.result.js_number)
})
it('档案不完整时报 USAGE(上游加载档案时就用 build_blob 校验)', () => {
const p = c.input.custom_profile
const hashes = { ...p.render_hashes }
delete hashes['7']
expect(errorCode(() => buildBlob([]))).toBe('USAGE')
expect(errorCode(() => buildBlob({ ...p, ua: undefined }))).toBe('USAGE')
expect(errorCode(() => buildBlob({ ...p, languages: 'zh-CN' }))).toBe('USAGE')
expect(errorCode(() => buildBlob({ ...p, device_memory: null }))).toBe('USAGE')
expect(errorCode(() => buildBlob({ ...p, render_hashes: hashes }))).toBe('USAGE')
expect(errorCode(() => buildBlob({ ...p, render_hashes: { ...p.render_hashes, 7: 2 ** 32 } }))).toBe('USAGE')
expect(errorCode(() => buildBlob({ ...p, bools: { x: true } }))).toBe('USAGE')
expect(errorCode(() => buildBlob({ ...p, bools: { 1: 'yes' } }))).toBe('USAGE')
})
it('取 blob 的优先级:dtrait_blob > dtrait_profile > 默认档案;档案无效时算不出,评论 / 发布在本地拦下', async () => {
const { commentAdd } = await import('../src/platforms/douyin/web/commands.js')
const ctx = makeCtx({ platform: 'douyin', cookies: 'sessionid=fake; ttwid=fake', cookieDomain: '.douyin.com', args: { item: AWEME, text: 'x' } })
Object.assign(ctx.credential.scopes.main!.tokens, { ticket: 'hash.fake', ts_sign: 'ts.2.fake' })
const d = new Douyin(ctx)
d.device.private_key = 'fake-key'
expect(d.dtraitBlob()).toBe(CAPTURED_BLOB)
d.device.dtrait_profile = c.input.custom_profile
expect(d.dtraitBlob()).toBe(c.result.custom)
d.device.dtrait_blob = 'imported-blob'
expect(d.dtraitBlob()).toBe('imported-blob')
delete d.device.dtrait_blob
d.device.dtrait_profile = { ...c.input.custom_profile, render_hashes: {} }
expect(d.dtraitBlob()).toBeNull()
expect(d.dtraitHeader('/aweme/v1/web/comment/publish')).toBeNull()
expect(errorCode(() => d.dtraitHeader('/aweme/v1/web/comment/publish', { strict: true }))).toBe('AUTH_REQUIRED')
expect(errorCode(() => creator.requirePublishSecurity(d))).toBe('AUTH_REQUIRED')
await expect(commentAdd(ctx)).rejects.toMatchObject({ code: 'AUTH_REQUIRED' })
// 成品头只给非严格的请求用
d.device.session_dtrait = 'static-header'
expect(d.dtraitHeader('/aweme/v1/web/comment/publish')).toBe('static-header')
expect(errorCode(() => d.dtraitHeader('/web/api/media/aweme/create_v2/', { strict: true }))).toBe('AUTH_REQUIRED')
})
it('comment add:没有导入 dtrait_blob 时不再本地拒绝,按默认档案带上 x-tt-session-dtrait', async () => {
const c = loadCase('douyin', 'comment_publish_profile')
const { commentAdd } = await import('../src/platforms/douyin/web/commands.js')
const ctx = loggedCtx(c)
ctx.args = { item: AWEME, text: c.input.text }
const { requests, result, error } = await replay(c, () => commentAdd(ctx) as Promise<any>)
if (error) throw error
expectReqs(requests, c.requests)
expect(result).toMatchObject({ item_id: AWEME, text: c.input.text })
})
it('写接口要求按 path 重算的 dtrait(上游 Header.with_session_dtrait / tests/test_write_dtrait.py):收藏、收藏夹移动、评论用 strict,不接受成品头', async () => {
const d = new Douyin(loggedFrom('collect'))
await d.init()
const calls: [string, Record<string, unknown>][] = []
const original = d.dtraitHeader.bind(d)
d.dtraitHeader = (path, options = {}) => {
calls.push([path, options])
return 'd0_rsa_aes'
}
await withServer([['douyin.com', () => fakeResponse({ status_code: 0 })]], async (seen) => {
await api.collect(d, AWEME, '1')
await api.collectMove(d, AWEME, '我的收藏夹', '1')
await api.collectRemove(d, AWEME, '我的收藏夹', '1')
await api.publishComment(d, AWEME, 'x')
await api.digg(d, AWEME, '1')
const writes = seen.filter((r) => r.method === 'POST' && r.url.includes('/aweme/v1/web/'))
expect(writes.map((r) => r.headers.find(([k]) => k === 'x-tt-session-dtrait')?.[1])).toEqual(Array(5).fill('d0_rsa_aes'))
})
const strict = { strict: true, allowStatic: false }
expect(calls.map(([path]) => path)).toEqual([
'/aweme/v1/web/aweme/collect/',
'/aweme/v1/web/collects/video/move/',
'/aweme/v1/web/collects/video/move/',
'/aweme/v1/web/comment/publish',
'/aweme/v1/web/commit/item/digg/',
])
for (const [, options] of calls.slice(0, 4)) expect(options).toMatchObject(strict)
// digg 照上游仍是「算得出就带」
expect(calls[4]![1]).toEqual({})
// 算不出 blob、只有导入的成品头:收藏 / 收藏夹移动 / 评论在本地拦下,一个请求都不发;digg 仍带成品头
d.dtraitHeader = original
delete d.device.dtrait_blob
d.device.dtrait_profile = { ...defaultProfile(), render_hashes: {} }
d.device.session_dtrait = 'static-header'
await withServer([['douyin.com', () => fakeResponse({ status_code: 0 })]], async (seen) => {
await expect(api.collect(d, AWEME, '1')).rejects.toMatchObject({ code: 'AUTH_REQUIRED' })
await expect(api.collectMove(d, AWEME, '我的收藏夹', '1')).rejects.toMatchObject({ code: 'AUTH_REQUIRED' })
await expect(api.collectRemove(d, AWEME, '我的收藏夹', '1')).rejects.toMatchObject({ code: 'AUTH_REQUIRED' })
await expect(api.publishComment(d, AWEME, 'x')).rejects.toMatchObject({ code: 'AUTH_REQUIRED' })
expect(seen.filter((r) => r.url.includes('/aweme/v1/web/'))).toEqual([])
await api.digg(d, AWEME, '1')
expect(seen.at(-1)!.headers.find(([k]) => k === 'x-tt-session-dtrait')?.[1]).toBe('static-header')
})
})
it('auth login --cookie <JSON>:可选导入 dtrait_profile / dtrait_blob,坏档案报 USAGE;出现任何 dtrait 键时整组替换', async () => {
const { mkdtempSync, rmSync, readFileSync: read } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const { authLogin } = await import('../src/platforms/douyin/web/commands.js')
const home = mkdtempSync(join(tmpdir(), 'catbus-dy-'))
const prev = process.env.CATBUS_HOME
process.env.CATBUS_HOME = home
const restore = mockSender((p) => {
if (p.url.includes('/aweme/v1/web/query/user/')) return fakeResponse({ status_code: 0, id: '7400000000000000001', user_uid: '97872126662' })
if (p.url.includes('/web/api/media/user/info/')) return fakeResponse({ user: { sec_uid: SEC_UID, uid: '97872126662', nickname: '我' } })
if (p.url.includes('mssdk.bytedance.com')) return fakeResponse({ code: 0 }, { headers: [['x-ms-token', 'fake-mstoken']] })
return fakeResponse({ status_code: 0 })
})
const custom = c.input.custom_profile
const login = async (json: Record<string, unknown>, device: Record<string, unknown> = {}) => {
const ctx = makeCtx({ platform: 'douyin', account: null, options: { method: 'cookie', cookie: JSON.stringify({ cookie: 'sessionid=fake; ttwid=fake', ...json }) } })
Object.assign(ctx.credential.device, device)
await authLogin(ctx)
return JSON.parse(read(join(home, 'auth', 'douyin', 'web', 'default.json'), 'utf8')).device
}
try {
const d1 = await login({ dtrait_profile: custom })
expect(d1.dtrait_profile).toEqual(custom)
expect(d1.dtrait_blob).toBeUndefined()
// 同名账号已有 blob:这次只给档案时整组替换,旧 blob 不会压过新档案
const d2 = await login({ dtrait_profile: custom }, { dtrait_blob: 'old-blob', session_dtrait: 'old-header' })
expect([d2.dtrait_blob, d2.session_dtrait, d2.dtrait_profile]).toEqual([undefined, undefined, custom])
// 一个 dtrait 键都没给:沿用同名账号已有的
expect(await login({}, { dtrait_blob: 'old-blob', dtrait_profile: custom })).toMatchObject({ dtrait_blob: 'old-blob', dtrait_profile: custom })
// null 表示清掉,回到默认档案
expect((await login({ dtrait_blob: null }, { dtrait_blob: 'old-blob' })).dtrait_blob).toBeUndefined()
await expect(login({ dtrait_profile: { ...custom, bools: [] } })).rejects.toMatchObject({ code: 'USAGE' })
await expect(login({ dtrait_blob: 123 })).rejects.toMatchObject({ code: 'USAGE' })
} finally {
restore()
if (prev === undefined) delete process.env.CATBUS_HOME
else process.env.CATBUS_HOME = prev
rmSync(home, { recursive: true, force: true })
}
})
})
// ================================================================ 审查修复:命令级测试(不联网,按 URL 应答)
/** 用例里记下的响应 → HttpResponse(二进制响应记成 {base64})。 */
function goldenReply(r: GoldenCase['responses'][number], url: string): HttpResponse {
const hs: [string, string][] = []
for (const [k, v] of Object.entries(r.headers)) for (const x of Array.isArray(v) ? v : [v]) hs.push([k, x])
const body = typeof r.body === 'string' ? r.body : (r.body as any)?.base64 != null ? new Uint8Array(Buffer.from((r.body as any).base64, 'base64')) : JSON.stringify(r.body)
return fakeResponse(body, { status: r.status, headers: hs, url })
}
type Route = [string | RegExp, (p: PreparedRequest) => HttpResponse]
/** 按 URL 应答的假服务端(取第一个匹配的路由),时钟固定在 NOW;没配到的请求直接报错。 */
async function withServer<T>(routes: Route[], run: (seen: GoldenRequest[]) => Promise<T>): Promise<T> {
const seen: GoldenRequest[] = []
const restoreRand = rand.deterministic({ now: NOW })
const restore = mockSender((p) => {
seen.push(normalize(p))
const hit = routes.find(([m]) => (typeof m === 'string' ? p.url.includes(m) : m.test(p.url)))
if (!hit) throw new Error(`测试没有配置这个请求:${p.method} ${p.url}`)
return hit[1](p)
})
try {
return await run(seen)
} finally {
restore()
restoreRand()
}
}
const reqPath = (r: GoldenRequest) => `${r.method} ${r.url.split('?')[0]}`
const query = (r: GoldenRequest) => new URL(r.url).searchParams
const loggedFrom = (name: string, init: { args?: Record<string, string>; options?: Record<string, unknown>; cursor?: string } = {}) => {
const ctx = loggedCtx(loadCase('douyin', name))
ctx.args = init.args ?? {}
ctx.options = init.options ?? {}
ctx.cursor = init.cursor ?? null
return ctx
}
const commands = () => import('../src/platforms/douyin/web/commands.js')
describe('douyin 审查修复:登录态与错误映射', () => {
useTempHome()
it('auth login --sso 只能配 --method sms:cookie / qrcode 在发任何请求之前报 USAGE', async () => {
const { authLogin } = await commands()
for (const method of ['qrcode', 'cookie']) {
const ctx = makeCtx({ platform: 'douyin', account: null, options: { method, sso: true, cookie: 'sessionid=fake' } })
await withServer([], async (seen) => {
await expect(authLogin(ctx)).rejects.toMatchObject({ code: 'USAGE' })
expect(seen).toEqual([])
})
}
})
it('webcast 的 20003(User doesn\'t login)按登录墙处理,说明取 data.message', async () => {
const { check } = await import('../src/platforms/douyin/web/client.js')
const body = { status_code: 20003, data: { message: "User doesn't login" } }
const err = (() => {
try {
check(makeCtx({ platform: 'douyin' }), body)
} catch (e) {
return e as any
}
})()
expect([err.code, err.message]).toEqual(['AUTH_EXPIRED', "User doesn't login"])
expect(errorCode(() => check(makeCtx({ platform: 'douyin', account: 'guest' }), body))).toBe('AUTH_REQUIRED')
expect(errorCode(() => check(makeCtx({ platform: 'douyin' }), { status_code: 10011, status_msg: '参数错误' }))).toBe('UPSTREAM')
})
it('auth status:风控原样抛出;query/user 没给 uid 才算未登录;取不到 sec_uid 时沿用凭证里的用户', async () => {
const { authStatus } = await commands()
const ctx = () => {
const c = makeCtx({ platform: 'douyin', cookies: 'sessionid=fake; ttwid=fake; s_v_web_id=fake', cookieDomain: '.douyin.com' })
c.credential.scopes.main!.tokens.webid = '7400000000000000001'
c.credential.user = { id: SEC_UID, name: '我', url: `https://www.douyin.com/user/${SEC_UID}` }
return c
}
const run = (queryUser: () => HttpResponse, userInfo: () => HttpResponse = () => fakeResponse({ user: { sec_uid: SEC_UID, nickname: '新昵称' } })) =>
withServer(
[
['/aweme/v1/web/query/user/', queryUser],
['/web/api/media/user/info/', userInfo],
['/user/self', () => fakeResponse('<html></html>')],
],
() => authStatus(ctx()),
)
await expect(run(() => fakeResponse(''))).rejects.toMatchObject({ code: 'RISK_CONTROL' })
await expect(run(() => fakeResponse('Uifid Not Found'))).rejects.toMatchObject({ code: 'RISK_CONTROL', detail: { reason: 'uifid' } })
expect(await run(() => fakeResponse({ status_code: 0 }))).toMatchObject({ logged_in: false, user: null })
expect(await run(() => fakeResponse({ status_code: 0, user_uid: '0' }))).toMatchObject({ logged_in: false })
expect(await run(() => fakeResponse({ status_code: 0, user_uid: '97872126662' }))).toMatchObject({ logged_in: true, user: { id: SEC_UID, name: '新昵称' } })
expect(await run(() => fakeResponse({ status_code: 0, user_uid: '97872126662' }), () => fakeResponse(''))).toMatchObject({ logged_in: true, user: { id: SEC_UID, name: '我' } })
})
it('auth login --cookie:query/user 被风控时报 RISK_CONTROL,不再说成 cookie 无效', async () => {
const { authLogin } = await commands()
const login = (queryUser: () => HttpResponse) =>
withServer(
[
['/aweme/v1/web/query/user', queryUser],
['/discover', () => fakeResponse('<html></html>')],
],
() => authLogin(makeCtx({ platform: 'douyin', account: null, options: { method: 'cookie', cookie: 'sessionid=fake; ttwid=fake' } })),
)
await expect(login(() => fakeResponse(''))).rejects.toMatchObject({ code: 'RISK_CONTROL' })
await expect(login(() => fakeResponse({ status_code: 0 }))).rejects.toMatchObject({ code: 'AUTH_REQUIRED', message: '登录没有成功:cookie 无效或已过期' })
})
it('读取类命令缺 UIFID 时整条重跑;登录与写操作不重跑(index.ts 只给读取类套 retryWithUifid)', async () => {
const m = await commands()
const { PLATFORMS } = await import('../src/platforms/index.js')
const web = PLATFORMS.find((p) => p.id === 'douyin')!.endpoints.web as any
const load = (key: string) => web.commands.get(key).handler()
const raw: [string, unknown][] = [
['auth login', m.authLogin],
['msg send', m.msgSend],
['comment add', m.commentAdd],
['item publish', m.itemPublish],
['item collect', m.itemCollect],
['media upload', m.mediaUpload],
['live send', m.liveSend],
['live like', m.liveLike],
]
for (const [key, fn] of raw) expect(await load(key), key).toBe(fn)
for (const [key, fn] of [
['user likes', m.userLikes],
['auth status', m.authStatus],
['item get', m.itemGet],
] as const)
expect(await load(key), key).not.toBe(fn)
})
})
describe('douyin 审查修复:私信', () => {
const file = loadCase('douyin', 'im_send_file')
const create = loadCase('douyin', 'im_create')
const sendRoutes: Route[] = [
['imapi.douyin.com/v2/conversation/create', (p) => goldenReply(create.responses[0]!, p.url)],
['/service/2/abtest_config/', (p) => goldenReply(file.responses[0]!, p.url)],
['/passport/ticket_guard/get_client_cert/', (p) => goldenReply(file.responses[1]!, p.url)],
['/aweme/v1/web/im/upload/config/v2', (p) => goldenReply(file.responses[2]!, p.url)],
['Action=ApplyUploadInner', (p) => goldenReply(file.responses[3]!, p.url)],
['tos-fake.snssdk.com', (p) => goldenReply(file.responses[4]!, p.url)],
['Action=CommitUploadInner', (p) => goldenReply(file.responses[5]!, p.url)],
['/passport/safe/get_identity_security_token/', (p) => goldenReply(file.responses[6]!, p.url)],
['imapi.douyin.com/v1/message/send', (p) => goldenReply(file.responses[7]!, p.url)],
]
const sent = (seen: GoldenRequest[]) =>
seen.filter((r) => r.url.startsWith('https://imapi.douyin.com/v1/message/send')).map((r) => (pbBody((r.body as { base64: string }).base64) as any).body.send_message_body)
it('--share 取作品失败时一条都不发(文字不会先发出去);--to 给数字 uid 时直接建会话', async () => {
const { msgSend } = await commands()
const ctx = loggedFrom('im_send_file', { args: { text: 'hi' }, options: { to: '1234567890', share: AWEME } })
await withServer([...sendRoutes, ['/aweme/v1/web/aweme/detail/', () => fakeResponse({ status_code: 0 })]], async (seen) => {
await expect(msgSend(ctx)).rejects.toMatchObject({ code: 'UPSTREAM' })
expect(seen.map(reqPath)).toEqual(['POST https://imapi.douyin.com/v2/conversation/create', 'GET https://www.douyin.com/aweme/v1/web/aweme/detail/'])
expect((pbBody((seen[0]!.body as { base64: string }).base64) as any).body.create_conversation_v2_body.participants).toEqual(['1234567890', file.input.uid])
})
})
it('先上传再逐条发送;--file 给 URL 时文件名照上游叫 file.bin', async () => {
const { msgSend } = await commands()
const ctx = loggedFrom('im_send_file', { args: { text: 'hi' }, options: { to: '1234567890', file: 'https://example.com/docs/a.pdf' } })
const bytes = Buffer.from(file.input.file, 'base64')
await withServer([...sendRoutes, ['https://example.com/docs/a.pdf', () => fakeResponse(new Uint8Array(bytes), { headers: [['content-type', 'application/pdf']] })]], async (seen) => {
const msg = await msgSend(ctx)
expect(msg).toMatchObject({ conversation_id: CONV_ID, type: 'other', text: 'file.bin' })
const paths = seen.map(reqPath)
const firstSend = paths.indexOf('POST https://imapi.douyin.com/v1/message/send')
expect(firstSend).toBeGreaterThan(paths.findIndex((x) => x.startsWith('POST https://vod.bytedanceapi.com/')))
const bodies = sent(seen)
expect(bodies.map((b: any) => b.message_type)).toEqual([im.IM_TEXT, im.IM_FILE])
expect(JSON.parse(bodies[0].content).text).toBe('hi')
expect(JSON.parse(bodies[1].content)).toMatchObject({ aweType: 15001, name: 'file.bin', format: 'bin', data_size: bytes.length })
expect(seen.some((r) => r.url.includes('/user/profile/other/'))).toBe(false)
})
})
})
describe('douyin 审查修复:搜索、评价、收藏夹的翻页', () => {
useTempHome()
it('user search / live search:has_more=1 但空列表时停止翻页;--fans / --user-type 的取值映射', async () => {
const { userSearch, liveSearch } = await commands()
await withServer(
[
['/aweme/v1/web/discover/search/', () => fakeResponse({ status_code: 0, has_more: 1, user_list: [] })],
['/aweme/v1/web/live/search/', () => fakeResponse({ status_code: 0, has_more: 1, data: [] })],
],
async (seen) => {
const users: any = await userSearch(loggedFrom('search_user', { args: { keyword: '巴旦木' }, options: { fans: '1w_10w', userType: 'enterprise' } }))
expect(users.page).toEqual({ cursor: null, has_more: false })
expect(JSON.parse(query(seen[0]!).get('search_filter_value')!)).toEqual({ douyin_user_fans: ['1w_10w'], douyin_user_type: ['enterprise_user'] })
const lives: any = await liveSearch(loggedFrom('search_live', { args: { keyword: '三角洲' }, cursor: '15' }))
expect(lives.page).toEqual({ cursor: null, has_more: false })
expect(query(seen[1]!).get('offset')).toBe('15')
},
)
})
it('item search 综合频道:筛选值放进 filter_selected,--type image 是 content_type 2;--type text 报 UNSUPPORTED', async () => {
const { itemSearch } = await commands()
await withServer([['/aweme/v1/web/general/search/single/', () => fakeResponse({ status_code: 0, has_more: 0, data: [] })]], async (seen) => {
await itemSearch(loggedFrom('search_general', { args: { keyword: '美食' }, options: { type: 'image', sort: 'latest', time: 'week' } }))
const q = query(seen[0]!)
expect([q.get('search_source'), q.get('is_filter_search')]).toEqual(['tab_search', '1'])
expect(q.get('filter_selected')).toBe('{"sort_type":"2","publish_time":"7","filter_duration":"","search_range":"","content_type":"2"}')
await itemSearch(loggedFrom('search_general', { args: { keyword: '美食' } }))
expect([query(seen[1]!).get('is_filter_search'), query(seen[1]!).get('filter_selected')]).toEqual([
'0',
'{"sort_type":"0","publish_time":"0","filter_duration":"","search_range":"","content_type":""}',
])
})
// 上游 tests/test_search_filters.py 的断言:对拍用例 search_general_filter 的 query
const g = query(loadCase('douyin', 'search_general_filter').requests[0]!)
expect([g.get('search_source'), g.get('is_filter_search'), g.get('filter_selected')]).toEqual([
'tab_search',
'1',
'{"sort_type":"2","publish_time":"7","filter_duration":"1-5","search_range":"3","content_type":"2"}',
])
const r = await cli('douyin', 'item', 'search', '美食', '--type', 'text')
expect([r.code, r.env.error.code]).toEqual([2, 'UNSUPPORTED'])
})
it('item search --type video 的第二页:游标「偏移,X-Tt-Logid」拆开,下一页游标接上新的 logid', async () => {
const { itemSearch } = await commands()
await withServer(
[['/aweme/v1/web/search/item/', () => fakeResponse({ status_code: 0, has_more: 1, data: [{ aweme_info: { aweme_id: AWEME, desc: 'x' } }] }, { headers: [['x-tt-logid', 'logid-2']] })]],
async (seen) => {
const r: any = await itemSearch(loggedFrom('search_video', { args: { keyword: '美食' }, options: { type: 'video' }, cursor: '16,logid-1' }))
expect([query(seen[0]!).get('offset'), query(seen[0]!).get('search_id')]).toEqual(['16', 'logid-1'])
expect(r.page).toEqual({ cursor: '32,logid-2', has_more: true })
},
)
})
it('notice list --group 的取值映射:不带时 960', async () => {
const { noticeList } = await commands()
await withServer([['/aweme/v1/web/notice/', () => fakeResponse({ status_code: 0, notice_list_v2: [], has_more: 0 })]], async (seen) => {
for (const group of [undefined, 'all', 'fans', 'mention', 'comment', 'like', 'danmaku']) await noticeList(loggedFrom('notices', { options: group ? { group } : {} }))
expect(seen.map((r) => query(r).get('notice_group'))).toEqual(['960', '700', '401', '601', '2', '3', '520'])
})
})
it('商品评价 --label:按名字或 id 找标签,找不到报 USAGE;tag_id 编进游标,翻页时不再请求 comment/counter', async () => {
const { commentList } = await commands()
const product = 'https://haohuo.jinritemai.com/ecommerce/trade/detail/index.html?id=3622058069401408999&promotion_id=3622058069401408240&shop_id=fakeShop01'
const routes: Route[] = [
['/ecom/product/comment/counter/', () => fakeResponse({ status_code: 0, counter_info: { tags: [{ tag_id: 7, tag_name: '好评', count: 3 }] } })],
['/ecom/product/comments/', () => fakeResponse({ status_code: 0, data: { Comments: [{ CommentId: '1', Content: '好', User: { NickName: 'a' } }], Cursor: 10, HasMore: true } })],
]
await withServer(routes, async (seen) => {
const p1: any = await commentList(loggedFrom('product_comments', { args: { item: product }, options: { label: '好评' } }))
expect(p1.page).toEqual({ cursor: '10,7', has_more: true })
expect(seen.map((r) => r.url.includes('/counter/'))).toEqual([true, false])
expect(query(seen[1]!).get('tag_id')).toBe('7')
seen.length = 0
const p2: any = await commentList(loggedFrom('product_comments', { args: { item: product }, options: { label: '好评' }, cursor: '10,7' }))
expect(seen.map(reqPath)).toEqual(['GET https://www.douyin.com/aweme/v1/web/ecom/product/comments/'])
expect([query(seen[0]!).get('cursor'), query(seen[0]!).get('tag_id')]).toEqual(['10', '7'])
expect(p2.page.cursor).toBe('10,7')
seen.length = 0
// 纯数字:不在计数里也照用;名字对不上报 USAGE
await commentList(loggedFrom('product_comments', { args: { item: product }, options: { label: '9' } }))
expect(query(seen[1]!).get('tag_id')).toBe('9')
await expect(commentList(loggedFrom('product_comments', { args: { item: product }, options: { label: '差评' } }))).rejects.toMatchObject({ code: 'USAGE' })
// 不带 --label 时游标就是服务端的游标
const plain: any = await commentList(loggedFrom('product_comments', { args: { item: product } }))
expect(plain.page.cursor).toBe('10')
})
})
it('folder list 按响应的 cursor / has_more 翻页;--folder 在后面的页里也能找到', async () => {
const { folderList, itemCollect } = await commands()
const page = (cursor: string | null) =>
cursor === '20'
? { status_code: 0, collects_list: [{ collects_id_str: '2002', collects_name: '第二页的收藏夹', total_number: 1 }], cursor: 21, has_more: false }
: { status_code: 0, collects_list: [{ collects_id_str: '1001', collects_name: '第一页的收藏夹', total_number: 3 }], cursor: 20, has_more: true }
const routes: Route[] = [
['/collects/list/', (p) => fakeResponse(page(new URL(p.url).searchParams.get('cursor')))],
['/service/2/abtest_config/', () => fakeResponse('', { headers: [['x-ware-csrf-token', '0001,fakecsrf,86370,success,x']] })],
['/aweme/v1/web/aweme/collect/', () => fakeResponse({ status_code: 0 })],
['/collects/video/move/', () => fakeResponse({ status_code: 0 })],
]
await withServer(routes, async (seen) => {
const p1: any = await folderList(loggedFrom('collect_list'))
expect([p1.data.map((f: any) => f.id), p1.page]).toEqual([['1001'], { cursor: '20', has_more: true }])
const p2: any = await folderList(loggedFrom('collect_list', { cursor: '20' }))
expect([p2.data.map((f: any) => f.name), p2.page]).toEqual([['第二页的收藏夹'], { cursor: null, has_more: false }])
seen.length = 0
expect(await itemCollect(loggedFrom('collect_move', { args: { item: AWEME }, options: { folder: '第二页的收藏夹' } }))).toEqual({ id: AWEME })
const lists = seen.filter((r) => r.url.includes('/collects/list/')).map((r) => query(r).get('cursor'))
expect(lists).toEqual(['0', '20'])
const move = seen.find((r) => r.url.includes('/collects/video/move/'))!
expect([query(move).get('to_collects_id'), query(move).get('collects_name')]).toEqual(['2002', '第二页的收藏夹'])
})
})
})
describe('douyin 审查修复:参数与发布', () => {
it('--share 的目标:主页上点开的作品(/user/<sec_uid>?modal_id=)按作品算,与 resolveItem 一致', async () => {
const { resolveItem, resolveShare } = await import('../src/platforms/douyin/web/resolve.js')
const d = new Douyin(makeCtx({ platform: 'douyin' }))
const modal = `https://www.douyin.com/user/${SEC_UID}?modal_id=${AWEME}`
expect(await resolveShare(d, modal)).toEqual({ kind: 'item', id: AWEME })
expect(await resolveItem(d, modal)).toBe(AWEME)
expect(await resolveShare(d, `https://www.douyin.com/note/${AWEME}`)).toEqual({ kind: 'item', id: AWEME })
expect(await resolveShare(d, AWEME)).toEqual({ kind: 'item', id: AWEME })
expect(await resolveShare(d, `https://www.douyin.com/user/${SEC_UID}`)).toEqual({ kind: 'user', secUid: SEC_UID })
expect(await resolveShare(d, SEC_UID)).toEqual({ kind: 'user', secUid: SEC_UID })
expect(await resolveShare(d, 'https://example.com/a?b=1')).toEqual({ kind: 'web', url: 'https://example.com/a?b=1' })
// /user/self 不是 sec_uid,当网页
expect(await resolveShare(d, 'https://www.douyin.com/user/self')).toEqual({ kind: 'web', url: 'https://www.douyin.com/user/self' })
await expect(resolveShare(d, 'hello')).rejects.toMatchObject({ code: 'USAGE' })
})
it('item publish 的本地校验(不发请求)与 PublishOptions 映射', async () => {
const { itemPublish, publishOptions } = await commands()
for (const options of [{}, { video: 'v.mp4', image: ['a.png'] }, { image: ['a.png'], poiName: '北京' }, { image: ['a.png', 'b.png'], cover: 'c.png' }]) {
await withServer([], async (seen) => {
await expect(itemPublish(makeCtx({ platform: 'douyin', cookies: 'sessionid=fake', cookieDomain: '.douyin.com', options })), JSON.stringify(options)).rejects.toMatchObject({ code: 'USAGE' })
expect(seen).toEqual([])
})
}
const schedule = '2026-10-01T12:00:00+08:00'
expect(publishOptions({ title: 'T', text: '正文', tag: ['a'], visibility: 'friends', schedule, noDownload: true, poi: 123, poiName: '北京', series: '777', hotspot: '热点' })).toEqual({
title: 'T',
desc: '正文 #a',
visibility: 2,
timing: Math.floor(Date.parse(schedule) / 1000),
allowDownload: false,
poi: { poi_id: '123', poi_name: '北京' },
mixId: '777',
hotSpot: { word: '热点' },
})
expect(publishOptions({ visibility: 'private', poi: '1' })).toMatchObject({ desc: '', visibility: 1, allowDownload: true, poi: { poi_id: '1', poi_name: '' }, timing: undefined })
})
})
describe('douyin 审查修复:直播', () => {
const liveInfoCase = loadCase('douyin', 'live_info')
it('live listen:房间解析不出来时直接报 UPSTREAM,不当成断线一直重连', async () => {
const ctx = makeCtx({ platform: 'douyin', cookies: 'sessionid=fake; ttwid=fake', cookieDomain: '.douyin.com' })
await withServer([['https://live.douyin.com/', () => fakeResponse('<html>直播已结束</html>')]], async (seen) => {
await expect(live.listenLive(ctx, new Douyin(ctx), '123')[Symbol.asyncIterator]().next()).rejects.toMatchObject({ code: 'UPSTREAM' })
expect(seen.map(reqPath)).toEqual(['GET https://live.douyin.com/123'])
})
})
it('live history:im/fetch 返回的不是 protobuf 时报 UPSTREAM', async () => {
const { liveHistory } = await commands()
const ctx = loggedFrom('live_info', { args: { room: liveInfoCase.input.web_rid } })
await withServer(
[
['/webcast/im/fetch/', () => fakeResponse('<html>captcha</html>')],
['/service/2/abtest_config/', () => fakeResponse('')],
[`https://live.douyin.com/${liveInfoCase.input.web_rid}`, (p) => goldenReply(liveInfoCase.responses[0]!, p.url)],
],
async () => {
await expect(liveHistory(ctx)).rejects.toMatchObject({ code: 'UPSTREAM', message: expect.stringContaining('im/fetch') })
},
)
expect(errorCode(() => live.decodeFetch(new Uint8Array([0xff, 0xff, 0xff])))).toBe('UPSTREAM')
})
it('直播消息的时间取公共头 Common 的 createTime(字段 1 → 4,毫秒);没有时用当前时间', async () => {
const protobuf = (await import('protobufjs')).default
const n = await import('../src/core/normalize.js')
const common = protobuf.Writer.create().uint32((1 << 3) | 2).fork().uint32((1 << 3) | 2).string('WebcastChatMessage').uint32((4 << 3) | 0).uint64(1790000000123).ldelim().finish()
const chat = Buffer.concat([common, proto.encode('Live', 'ChatMessage', { user: { nickname: '观众A' }, content: '主播好' })])
const bare = proto.encode('Live', 'ChatMessage', { user: { nickname: '观众B' }, content: '在吗' })
expect(live.messageTime(chat)).toBe(n.time(1790000000123))
expect(live.messageTime(bare)).toBeNull()
const raw = proto.encode('Live', 'LiveResponse', {
messagesList: [
{ method: 'WebcastChatMessage', payload: chat },
{ method: 'WebcastChatMessage', payload: bare },
],
})
const restore = rand.deterministic({ now: NOW })
try {
expect(live.fetchEvents(raw).map((e) => [e.text, e.time])).toEqual([
['主播好', n.time(1790000000123)],
['在吗', n.time(NOW)],
])
} finally {
restore()
}
})
})
describe('douyin 审查修复:TOS 上传', () => {
const node = { store_uri: 'tos-cn-v-fake/abc', auth: 'fake-auth', upload_id: '', upload_host: 'tos-fake.snssdk.com', session_key: 'sk', upload_header: { 'x-tos-extra': '1' } }
it('分片上传(超过 3MB):创作者中心与私信共用流程,content-crc32 的位置与 uploadid 编码(urllib.parse.quote)各照上游', async () => {
const tos = await import('../src/platforms/douyin/web/tos.js')
const data = new Uint8Array(3 * 1024 * 1024 + 1).fill(7)
const cases = [
{ style: tos.CREATOR_TOS, id: 'up/id+1', origin: 'https://creator.douyin.com', crcAt: 4 },
{ style: tos.IM_TOS, id: 'up/id%2B1', origin: 'https://www.douyin.com', crcAt: 11 },
]
for (const c of cases) {
const routes: Route[] = [
['phase=init', () => fakeResponse({ code: 2000, data: { uploadid: 'up/id+1' } })],
['tos-fake.snssdk.com', () => fakeResponse({ code: 2000 })],
]
await withServer(routes, async (seen) => {
await tos.tosUpload(new Douyin(makeCtx({ platform: 'douyin' })), c.style, node, data, '42')
const base = 'https://tos-fake.snssdk.com/upload/v1/tos-cn-v-fake/abc'
expect(seen.map((r) => r.url)).toEqual([
`${base}?uploadmode=part&phase=init`,
`${base}?uploadid=${c.id}&part_number=1&phase=transfer&part_offset=0`,
`${base}?uploadmode=part&phase=finish&uploadid=${c.id}`,
])
const names = seen[1]!.headers.map(([k]) => k)
expect(names.indexOf('content-crc32')).toBe(c.crcAt)
expect(names.at(-1)).toBe('x-tos-extra')
expect(Object.fromEntries(seen[1]!.headers)).toMatchObject({ origin: c.origin, referer: `${c.origin}/`, 'x-storage-u': '42' })
expect(seen[0]!.headers.some(([k]) => k === 'content-crc32')).toBe(false)
expect(seen[2]!.body).toBe(`1:${crc32Hex(data)}`)
})
}
// 成功码:创作者中心只认 2000,私信也接受没有 code 的响应
const small = new Uint8Array(10)
for (const [style, code] of [
[tos.CREATOR_TOS, 'UPSTREAM'],
[tos.IM_TOS, null],
] as const) {
await withServer([['tos-fake.snssdk.com', () => fakeResponse({})]], async () => {
const run = tos.tosUpload(new Douyin(makeCtx({ platform: 'douyin' })), style, node, small, '')
if (code) await expect(run).rejects.toMatchObject({ code })
else await run
})
}
})
})