specific-ai-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +177 -0
  3. package/README.ja.md +177 -0
  4. package/README.ko.md +177 -0
  5. package/README.md +174 -2
  6. package/README.zh-TW.md +177 -0
  7. package/assets/banner.txt +3 -0
  8. package/dist/cli/argv.js +98 -0
  9. package/dist/cli/commands/auth.js +11 -0
  10. package/dist/cli/commands/config.js +23 -0
  11. package/dist/cli/commands/doctor.js +134 -0
  12. package/dist/cli/commands/platforms.js +24 -0
  13. package/dist/cli/commands/version.js +4 -0
  14. package/dist/cli/confirm.js +22 -0
  15. package/dist/cli/dispatch.js +440 -0
  16. package/dist/cli/help.js +223 -0
  17. package/dist/cli/hints.js +60 -0
  18. package/dist/cli/main.js +18 -0
  19. package/dist/cli/output.js +66 -0
  20. package/dist/core/auth-handlers.js +41 -0
  21. package/dist/core/auth-store.js +119 -0
  22. package/dist/core/capabilities.js +163 -0
  23. package/dist/core/config.js +121 -0
  24. package/dist/core/cookies.js +164 -0
  25. package/dist/core/errors.js +37 -0
  26. package/dist/core/files.js +89 -0
  27. package/dist/core/fsutil.js +52 -0
  28. package/dist/core/hash.js +4 -0
  29. package/dist/core/http.js +331 -0
  30. package/dist/core/image.js +280 -0
  31. package/dist/core/log.js +40 -0
  32. package/dist/core/login.js +191 -0
  33. package/dist/core/mp4.js +130 -0
  34. package/dist/core/normalize.js +207 -0
  35. package/dist/core/options.js +110 -0
  36. package/dist/core/paths.js +38 -0
  37. package/dist/core/pb.js +59 -0
  38. package/dist/core/py.js +181 -0
  39. package/dist/core/rand.js +131 -0
  40. package/dist/core/registry.js +129 -0
  41. package/dist/core/schemas.js +41 -0
  42. package/dist/core/stream.js +104 -0
  43. package/dist/core/toolkit.js +35 -0
  44. package/dist/core/vm.js +73 -0
  45. package/dist/core/vocab.js +157 -0
  46. package/dist/platforms/_shared/impaas.js +410 -0
  47. package/dist/platforms/bilibili/index.js +216 -0
  48. package/dist/platforms/bilibili/web/api.js +806 -0
  49. package/dist/platforms/bilibili/web/client.js +311 -0
  50. package/dist/platforms/bilibili/web/commands.js +783 -0
  51. package/dist/platforms/bilibili/web/ddddocr.js +342 -0
  52. package/dist/platforms/bilibili/web/gaia.js +130 -0
  53. package/dist/platforms/bilibili/web/geetest-manual.js +154 -0
  54. package/dist/platforms/bilibili/web/geetest-vision.js +232 -0
  55. package/dist/platforms/bilibili/web/geetest.js +450 -0
  56. package/dist/platforms/bilibili/web/normalize.js +234 -0
  57. package/dist/platforms/bilibili/web/profile.js +135 -0
  58. package/dist/platforms/bilibili/web/resolve.js +112 -0
  59. package/dist/platforms/bilibili/web/sign.js +223 -0
  60. package/dist/platforms/bilibili/web/upos.js +102 -0
  61. package/dist/platforms/douyin/index.js +142 -0
  62. package/dist/platforms/douyin/web/api.js +863 -0
  63. package/dist/platforms/douyin/web/client.js +413 -0
  64. package/dist/platforms/douyin/web/commands.js +712 -0
  65. package/dist/platforms/douyin/web/creator.js +601 -0
  66. package/dist/platforms/douyin/web/crypto.js +247 -0
  67. package/dist/platforms/douyin/web/dtrait.js +190 -0
  68. package/dist/platforms/douyin/web/im.js +502 -0
  69. package/dist/platforms/douyin/web/image.js +5 -0
  70. package/dist/platforms/douyin/web/live.js +225 -0
  71. package/dist/platforms/douyin/web/mssdk.js +148 -0
  72. package/dist/platforms/douyin/web/normalize.js +326 -0
  73. package/dist/platforms/douyin/web/passport.js +1247 -0
  74. package/dist/platforms/douyin/web/profile.js +221 -0
  75. package/dist/platforms/douyin/web/proto.js +26 -0
  76. package/dist/platforms/douyin/web/resolve.js +88 -0
  77. package/dist/platforms/douyin/web/sign.js +436 -0
  78. package/dist/platforms/douyin/web/tos.js +78 -0
  79. package/dist/platforms/index.js +17 -0
  80. package/dist/platforms/jd/index.js +67 -0
  81. package/dist/platforms/jd/web/aks.js +115 -0
  82. package/dist/platforms/jd/web/api.js +549 -0
  83. package/dist/platforms/jd/web/chat.js +132 -0
  84. package/dist/platforms/jd/web/client.js +470 -0
  85. package/dist/platforms/jd/web/commands.js +557 -0
  86. package/dist/platforms/jd/web/jcap/helper.js +90 -0
  87. package/dist/platforms/jd/web/jcap/host.js +107 -0
  88. package/dist/platforms/jd/web/jcap/lsd.js +300 -0
  89. package/dist/platforms/jd/web/jcap/solver.js +995 -0
  90. package/dist/platforms/jd/web/jcap.js +150 -0
  91. package/dist/platforms/jd/web/js.js +420 -0
  92. package/dist/platforms/jd/web/login.js +775 -0
  93. package/dist/platforms/jd/web/normalize.js +297 -0
  94. package/dist/platforms/jd/web/profile.js +256 -0
  95. package/dist/platforms/jd/web/util.js +175 -0
  96. package/dist/platforms/jd/web/webm.js +133 -0
  97. package/dist/platforms/kuaishou/index.js +98 -0
  98. package/dist/platforms/kuaishou/web/api.js +694 -0
  99. package/dist/platforms/kuaishou/web/captcha-crypto.js +134 -0
  100. package/dist/platforms/kuaishou/web/captcha-fp.js +180 -0
  101. package/dist/platforms/kuaishou/web/captcha.js +273 -0
  102. package/dist/platforms/kuaishou/web/client.js +487 -0
  103. package/dist/platforms/kuaishou/web/commands.js +863 -0
  104. package/dist/platforms/kuaishou/web/gap.js +144 -0
  105. package/dist/platforms/kuaishou/web/gdfp.js +376 -0
  106. package/dist/platforms/kuaishou/web/gql.js +11 -0
  107. package/dist/platforms/kuaishou/web/live.js +125 -0
  108. package/dist/platforms/kuaishou/web/normalize.js +241 -0
  109. package/dist/platforms/kuaishou/web/oracle.js +329 -0
  110. package/dist/platforms/kuaishou/web/profile.js +259 -0
  111. package/dist/platforms/kuaishou/web/resolve.js +84 -0
  112. package/dist/platforms/kuaishou/web/session.js +592 -0
  113. package/dist/platforms/kuaishou/web/sign.js +568 -0
  114. package/dist/platforms/taobao/index.js +56 -0
  115. package/dist/platforms/taobao/web/api.js +96 -0
  116. package/dist/platforms/taobao/web/client.js +102 -0
  117. package/dist/platforms/taobao/web/commands.js +172 -0
  118. package/dist/platforms/taobao/web/im.js +68 -0
  119. package/dist/platforms/taobao/web/normalize.js +103 -0
  120. package/dist/platforms/taobao/web/profile.js +85 -0
  121. package/dist/platforms/taobao/web/resolve.js +51 -0
  122. package/dist/platforms/taobao/web/sign.js +35 -0
  123. package/dist/platforms/tiktok/index.js +120 -0
  124. package/dist/platforms/tiktok/web/api.js +1234 -0
  125. package/dist/platforms/tiktok/web/client.js +362 -0
  126. package/dist/platforms/tiktok/web/commands.js +821 -0
  127. package/dist/platforms/tiktok/web/im.js +133 -0
  128. package/dist/platforms/tiktok/web/jsrun.js +126 -0
  129. package/dist/platforms/tiktok/web/normalize.js +286 -0
  130. package/dist/platforms/tiktok/web/profile.js +169 -0
  131. package/dist/platforms/tiktok/web/resolve.js +156 -0
  132. package/dist/platforms/tiktok/web/sign.js +585 -0
  133. package/dist/platforms/tiktok/web/upload.js +805 -0
  134. package/dist/platforms/tiktok/web/wire.js +490 -0
  135. package/dist/platforms/weibo/index.js +91 -0
  136. package/dist/platforms/weibo/web/api.js +206 -0
  137. package/dist/platforms/weibo/web/client.js +155 -0
  138. package/dist/platforms/weibo/web/commands.js +202 -0
  139. package/dist/platforms/weibo/web/normalize.js +137 -0
  140. package/dist/platforms/weibo/web/profile.js +246 -0
  141. package/dist/platforms/weibo/web/resolve.js +60 -0
  142. package/dist/platforms/weibo/web/sign.js +85 -0
  143. package/dist/platforms/x/index.js +107 -0
  144. package/dist/platforms/x/web/api.js +443 -0
  145. package/dist/platforms/x/web/article.js +212 -0
  146. package/dist/platforms/x/web/client.js +202 -0
  147. package/dist/platforms/x/web/commands.js +363 -0
  148. package/dist/platforms/x/web/graphql.js +108 -0
  149. package/dist/platforms/x/web/normalize.js +235 -0
  150. package/dist/platforms/x/web/profile.js +99 -0
  151. package/dist/platforms/x/web/resolve.js +44 -0
  152. package/dist/platforms/x/web/transaction.js +171 -0
  153. package/dist/platforms/xhs/index.js +101 -0
  154. package/dist/platforms/xhs/web/api.js +278 -0
  155. package/dist/platforms/xhs/web/client.js +540 -0
  156. package/dist/platforms/xhs/web/commands.js +1046 -0
  157. package/dist/platforms/xhs/web/creator-api.js +609 -0
  158. package/dist/platforms/xhs/web/creator.js +203 -0
  159. package/dist/platforms/xhs/web/js.js +194 -0
  160. package/dist/platforms/xhs/web/login.js +230 -0
  161. package/dist/platforms/xhs/web/normalize.js +166 -0
  162. package/dist/platforms/xhs/web/profile.js +127 -0
  163. package/dist/platforms/xhs/web/push.js +361 -0
  164. package/dist/platforms/xhs/web/resolve.js +69 -0
  165. package/dist/platforms/xhs/web/state.js +368 -0
  166. package/dist/platforms/xianyu/index.js +59 -0
  167. package/dist/platforms/xianyu/web/api.js +312 -0
  168. package/dist/platforms/xianyu/web/client.js +134 -0
  169. package/dist/platforms/xianyu/web/commands.js +305 -0
  170. package/dist/platforms/xianyu/web/im.js +73 -0
  171. package/dist/platforms/xianyu/web/normalize.js +123 -0
  172. package/dist/platforms/xianyu/web/profile.js +146 -0
  173. package/dist/platforms/xianyu/web/resolve.js +48 -0
  174. package/dist/platforms/xianyu/web/sign.js +79 -0
  175. package/package.json +77 -4
  176. package/static/douyin/Live.proto +194 -0
  177. package/static/douyin/Request.proto +89 -0
  178. package/static/douyin/Response.proto +46 -0
  179. package/static/douyin/acrawler_runtime/ac_vm.js +2 -0
  180. package/static/douyin/acrawler_runtime/browser_window_shape.json +1 -0
  181. package/static/douyin/acrawler_runtime/canvas_actual_exact.json +1 -0
  182. package/static/douyin/acrawler_runtime/run_ac_node.js +1005 -0
  183. package/static/douyin/acrawler_runtime/runner_window_keys_baseline.txt +240 -0
  184. package/static/douyin/challenge_profile.json +801 -0
  185. package/static/douyin/challenge_template_runner.js +217 -0
  186. package/static/douyin/dtrait_profile.json +66 -0
  187. package/static/douyin/mstoken_common_profile.json +1 -0
  188. package/static/douyin/mstoken_profile.json +214 -0
  189. package/static/jd/h5st5_env.js +750 -0
  190. package/static/jd/h5st5_lib.js +5 -0
  191. package/static/jd/h5st5_server.js +49 -0
  192. package/static/jd/jcap/env/env_core.js +512 -0
  193. package/static/jd/jcap/env/run.js +1307 -0
  194. package/static/jd/jcap/run/jcap_ujb96b.js +39 -0
  195. package/static/jd/package.json +4 -0
  196. package/static/jd/pc_tk_lib.js +1 -0
  197. package/static/jd/pc_tk_server.js +63 -0
  198. package/static/jd/summer_cryptico_runner.js +50 -0
  199. package/static/jd/webm/env/run.js +453 -0
  200. package/static/jd/webm/run/jdwebm-riskhandle.js +49 -0
  201. package/static/kuaishou/bundles/weapon/kwf-0.0.2.2cee19b4b7dec496.js +1 -0
  202. package/static/kuaishou/bundles/weapon/kws-0-0.0.1-obfuscated.4b7f3de1ab0ddcc2.js +1 -0
  203. package/static/kuaishou/bundles/weapon/kws-1-0.0.1-obfuscated.346ebdb1c78df316.js +1 -0
  204. package/static/kuaishou/bundles/weapon/kws-10-0.0.1-obfuscated.a33db41897b983af.js +1 -0
  205. package/static/kuaishou/bundles/weapon/kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js +1 -0
  206. package/static/kuaishou/bundles/weapon/kws-12-0.0.1-obfuscated.20ea29345706ec8c.js +1 -0
  207. package/static/kuaishou/bundles/weapon/kws-13-0.0.1-obfuscated.6b74e9640ff18648.js +1 -0
  208. package/static/kuaishou/bundles/weapon/kws-14-0.0.1-obfuscated.551e4429d156bf66.js +1 -0
  209. package/static/kuaishou/bundles/weapon/kws-15-0.0.1-obfuscated.8625b087aaba4a60.js +1 -0
  210. package/static/kuaishou/bundles/weapon/kws-16-0.0.1-obfuscated.d5ffa4020f3be99a.js +1 -0
  211. package/static/kuaishou/bundles/weapon/kws-17-0.0.1-obfuscated.6d13e19b22f50a80.js +1 -0
  212. package/static/kuaishou/bundles/weapon/kws-18-0.0.1-obfuscated.6d31405044166289.js +1 -0
  213. package/static/kuaishou/bundles/weapon/kws-19-0.0.1-obfuscated.a9a8d925942f19e4.js +1 -0
  214. package/static/kuaishou/bundles/weapon/kws-2-0.0.1-obfuscated.b849a136ae4a6b3e.js +1 -0
  215. package/static/kuaishou/bundles/weapon/kws-3-0.0.1-obfuscated.f9e77ebf6186e2ac.js +1 -0
  216. package/static/kuaishou/bundles/weapon/kws-4-0.0.1-obfuscated.2c3c155aa52ceb0e.js +1 -0
  217. package/static/kuaishou/bundles/weapon/kws-5-0.0.1-obfuscated.4e553d17691b3c5a.js +1 -0
  218. package/static/kuaishou/bundles/weapon/kws-6-0.0.1-obfuscated.f73ddd3ff13e1217.js +1 -0
  219. package/static/kuaishou/bundles/weapon/kws-7-0.0.1-obfuscated.1628b8729beaab7e.js +1 -0
  220. package/static/kuaishou/bundles/weapon/kws-8-0.0.1-obfuscated.6da6baef7aa81fff.js +1 -0
  221. package/static/kuaishou/bundles/weapon/kws-9-0.0.1-obfuscated.8645d55e5323b743.js +1 -0
  222. package/static/kuaishou/fixtures/current_follow_script_813.network-response +2 -0
  223. package/static/kuaishou/fixtures/live_ws_maps.json +61 -0
  224. package/static/kuaishou/fixtures/live_ws_proto.json +1342 -0
  225. package/static/kuaishou/js/cp-kwf.js +1 -0
  226. package/static/kuaishou/tools/kwf_oracle.js +799 -0
  227. package/static/kuaishou/tools/kws_oracle.js +885 -0
  228. package/static/kuaishou/tools/like_token_oracle.js +70 -0
  229. package/static/taobao/taobao_js_20260407.js +570 -0
  230. package/static/tiktok/Tiktok_Request.json +597 -0
  231. package/static/tiktok/package.json +3 -0
  232. package/static/tiktok/shop_bsid/core.js +1 -0
  233. package/static/tiktok/shop_bsid/env_core.js +512 -0
  234. package/static/tiktok/shop_bsid/loader.js +1 -0
  235. package/static/tiktok/shop_bsid/run.js +753 -0
  236. package/static/tiktok/shop_bsid/sign.js +100 -0
  237. package/static/tiktok/shop_bsid/webmssdk.js +1 -0
  238. package/static/tiktok/shop_bsid/webmssdk_ex.js +1 -0
  239. package/static/tiktok/signing/env/env_core.js +512 -0
  240. package/static/tiktok/signing/env/sign.js +455 -0
  241. package/static/tiktok/signing/sdk_config.json +230 -0
  242. package/static/tiktok/signing/source/webmssdk.js +1 -0
  243. package/static/tiktok/signing/source/webmssdk_ex.js +1 -0
  244. package/static/tiktok/signing/source/webmssdk_legacy.js +1 -0
  245. package/static/x/graphql.json +7777 -0
  246. package/static/x/transaction_l1.json +12 -0
  247. package/static/xhs/package.json +3 -0
  248. package/static/xhs/xhs_core/js/b1.js +210 -0
  249. package/static/xhs/xhs_core/js/mns.js +340 -0
  250. package/static/xhs/xhs_core/js/mns_0101_keystream.json +5 -0
  251. package/static/xhs/xhs_core/js/mns_keystreams.json +1 -0
  252. package/static/xhs/xhs_core/js/sign.js +225 -0
  253. package/static/xhs/xhs_core/js/websectiga_cli.js +220 -0
  254. package/static/xhs/xhs_core/js/websectiga_env.js +512 -0
  255. package/static/xhs/xhs_core/js/xs_common.js +79 -0
  256. package/static/xhs/xhs_creator/js/profile.js +115 -0
  257. package/static/xhs/xhs_creator/js/rap_fingerprint_creator.json +6 -0
  258. package/static/xhs/xhs_creator/js/reference_profile.json +282 -0
  259. package/static/xhs/xhs_creator/js/xhs_creator_sign.js +29 -0
  260. package/static/xhs/xhs_creator/js/xhs_creator_signature.js +23 -0
  261. package/static/xhs/xhs_pc/js/aes_decrypt.js +22 -0
  262. package/static/xhs/xhs_pc/js/aes_encrypt.js +30 -0
  263. package/static/xhs/xhs_pc/js/b1.js +9 -0
  264. package/static/xhs/xhs_pc/js/deflate.js +320 -0
  265. package/static/xhs/xhs_pc/js/mns.js +4 -0
  266. package/static/xhs/xhs_pc/js/profile.js +141 -0
  267. package/static/xhs/xhs_pc/js/rap.js +173 -0
  268. package/static/xhs/xhs_pc/js/rap_cli.js +30 -0
  269. package/static/xhs/xhs_pc/js/rap_crypto.js +73 -0
  270. package/static/xhs/xhs_pc/js/rap_fingerprint_template.json +8 -0
  271. package/static/xhs/xhs_pc/js/reference_profile.json +262 -0
  272. package/static/xhs/xhs_pc/js/sign.js +6 -0
  273. package/static/xhs/xhs_pc/js/web_ssk.js +82 -0
  274. package/static/xhs/xhs_pc/js/xs_common.js +4 -0
  275. package/static/xianyu/et_f.js +2 -0
  276. package/static/xianyu/gen_tfstk.js +410 -0
  277. package/static/xianyu/goofish_js_version_2.js +568 -0
@@ -0,0 +1,821 @@
1
+ import { parseCookieInput } from '../../../core/cookies.js';
2
+ import { SpecificAIError } from '../../../core/errors.js';
3
+ import { downloadMedia, readMedia } from '../../../core/files.js';
4
+ import { cookieCredential, finishLogin, freshCredential, loginContext } from '../../../core/login.js';
5
+ import * as n from '../../../core/normalize.js';
6
+ import * as rand from '../../../core/rand.js';
7
+ import { openSocket, reconnecting } from '../../../core/stream.js';
8
+ import { paged } from '../../../core/toolkit.js';
9
+ import * as api from './api.js';
10
+ import { TikTok, tiktok } from './client.js';
11
+ import { frontierSign } from './jsrun.js';
12
+ import * as im from './im.js';
13
+ import * as norm from './normalize.js';
14
+ import { COOKIE_DOMAIN, ORIGIN } from './profile.js';
15
+ import { currentUser, enterRoom, isProductInput, isRoomId, parseUser, resolveItem, resolveProduct, resolveRoom, resolveSecUid, resolveUserInfo, userDetail } from './resolve.js';
16
+ import * as up from './upload.js';
17
+ import * as wire from './wire.js';
18
+ /** 直播的 live_id 在 TikTok web 端是常量 12。 */
19
+ const LIVE_ID = '12';
20
+ const bool = (v) => v === true || v === 1 || v === '1' || v === 'true';
21
+ const cursorOf = (ctx, fallback = '0') => ctx.cursor ?? fallback;
22
+ // ================================================================ auth
23
+ /**
24
+ * cookie 登录。`--cookie` 可以是 Cookie 字符串、浏览器导出的 cookie 数组,
25
+ * 也可以是上游 `.tiktok-runtime.json` 格式的会话 JSON(cookie + device_id / local_storage / ticket_guard_* 等),
26
+ * 后者是点赞、评论、发布、私信等需要 ticket-guard 的操作所必需的。
27
+ */
28
+ function loginCredential(ctx) {
29
+ const input = String(ctx.options.cookie ?? '').trim();
30
+ if (!input.startsWith('{'))
31
+ return cookieCredential(ctx, COOKIE_DOMAIN);
32
+ let session;
33
+ try {
34
+ session = JSON.parse(input);
35
+ }
36
+ catch {
37
+ throw new SpecificAIError('USAGE', '无法解析会话 JSON', { hint: '传 Cookie 字符串,或 {"cookie": "...", "device_id": "...", ...} 格式的会话 JSON' });
38
+ }
39
+ const { cookie, ...device } = session;
40
+ if (typeof cookie !== 'string' || !cookie.trim())
41
+ throw new SpecificAIError('USAGE', '会话 JSON 缺少 cookie 字段');
42
+ const credential = freshCredential(ctx, 'cookie');
43
+ credential.scopes.main.cookies = parseCookieInput(cookie, COOKIE_DOMAIN);
44
+ credential.device = device;
45
+ return credential;
46
+ }
47
+ /** 当前登录用户:首页 app-context.user,拿不到时用 multi_sids 的 uid 查私信卡片。 */
48
+ async function me(t) {
49
+ try {
50
+ const u = await currentUser(t);
51
+ return { id: String(u.uid ?? t.uid), name: u.nickName ?? null, handle: u.uniqueId ?? null, secUid: u.secUid ?? null };
52
+ }
53
+ catch (err) {
54
+ if (!t.uid)
55
+ throw err;
56
+ const r = await api.imUserProfile(t, [t.uid]);
57
+ const p = r.users?.[0]?.im_user_profile;
58
+ if (!p)
59
+ throw err;
60
+ return { id: t.uid, name: p.nickname ?? null, handle: p.unique_id ?? null, secUid: p.sec_uid ?? null };
61
+ }
62
+ }
63
+ export async function authLogin(ctx) {
64
+ const method = ctx.options.method;
65
+ if (method !== 'cookie')
66
+ throw new SpecificAIError('UNSUPPORTED', `TikTok 只支持 cookie 登录(上游没有 ${method} 登录)`, { hint: 'specific-ai tiktok auth login --cookie "<cookie>"' });
67
+ const credential = loginCredential(ctx);
68
+ const t = await tiktok(loginContext(ctx, credential));
69
+ if (!t.loggedIn)
70
+ throw new SpecificAIError('AUTH_REQUIRED', 'cookie 里没有登录态(缺少 sessionid / sid_tt / multi_sids)');
71
+ const u = await me(t);
72
+ return finishLogin(ctx, credential, norm.ref(u.id, u.name, u.handle));
73
+ }
74
+ export async function authStatus(ctx) {
75
+ const t = new TikTok(ctx);
76
+ const out = { logged_in: false, user: null, method: null, expires_at: null };
77
+ if (!t.loggedIn)
78
+ return out;
79
+ try {
80
+ const u = await me(t);
81
+ const sess = t.jar.cookies.find((c) => c.name === 'sessionid' || c.name === 'sid_tt');
82
+ return { logged_in: true, user: norm.ref(u.id, u.name, u.handle), method: ctx.credential.method, expires_at: sess?.expires ? n.time(sess.expires) : null };
83
+ }
84
+ catch (err) {
85
+ if (err instanceof SpecificAIError && (err.code === 'AUTH_EXPIRED' || err.code === 'AUTH_REQUIRED'))
86
+ return out;
87
+ throw err;
88
+ }
89
+ }
90
+ // ================================================================ user
91
+ export async function userGet(ctx) {
92
+ const t = await tiktok(ctx);
93
+ const u = await parseUser(t, ctx.args.user);
94
+ if (u.handle)
95
+ return norm.user(await userDetail(t, u.handle));
96
+ return norm.user(await resolveUserInfo(t, ctx.args.user));
97
+ }
98
+ function itemPage(list, cursor, hasMore) {
99
+ return paged((list ?? []).filter((v) => v?.id).map(norm.item), cursor == null ? null : String(cursor), bool(hasMore));
100
+ }
101
+ export async function userItems(ctx) {
102
+ const t = await tiktok(ctx);
103
+ const sec = await resolveSecUid(t, ctx.args.user);
104
+ const d = await api.userPosted(t, sec, cursorOf(ctx));
105
+ return itemPage(d.itemList, d.cursor, d.hasMore);
106
+ }
107
+ export async function userCollects(ctx) {
108
+ const t = await tiktok(ctx);
109
+ const d = await api.collectedItemList(t, await resolveSecUid(t, ctx.args.user), cursorOf(ctx));
110
+ return itemPage(d.itemList, d.cursor, d.hasMore);
111
+ }
112
+ export async function userReposts(ctx) {
113
+ const t = await tiktok(ctx);
114
+ const d = await api.repostList(t, await resolveSecUid(t, ctx.args.user), cursorOf(ctx));
115
+ return itemPage(d.itemList, d.cursor, d.hasMore);
116
+ }
117
+ async function userList(ctx, scene) {
118
+ const t = await tiktok(ctx);
119
+ const d = await api.profileUserList(t, await resolveSecUid(t, ctx.args.user), scene, { minCursor: cursorOf(ctx) });
120
+ return paged((d.userList ?? []).map(norm.user), d.minCursor, bool(d.hasMore));
121
+ }
122
+ export const userFollowers = (ctx) => userList(ctx, '67');
123
+ export const userFollowing = (ctx) => userList(ctx, '21');
124
+ async function follow(ctx, on) {
125
+ const t = await tiktok(ctx);
126
+ t.requireLogin();
127
+ const info = await resolveUserInfo(t, ctx.args.user);
128
+ const u = info.user ?? {};
129
+ const page = `${ORIGIN}/@${u.uniqueId}`;
130
+ await api.followUser(t, String(u.id), String(u.secUid), { actionType: on ? '1' : '0', referer: page, queryReferer: page });
131
+ return { id: String(u.id) };
132
+ }
133
+ export const userFollow = (ctx) => follow(ctx, true);
134
+ export const userUnfollow = (ctx) => follow(ctx, false);
135
+ // ================================================================ item
136
+ /** 视频页 hydration 的 itemStruct(上游 get_video_detail)。 */
137
+ async function itemStruct(t, input) {
138
+ const ref = await resolveItem(t, input);
139
+ return api.videoDetail(t, ref.url, ref.id);
140
+ }
141
+ export async function itemGet(ctx) {
142
+ const t = await tiktok(ctx);
143
+ return norm.item(await itemStruct(t, ctx.args.item));
144
+ }
145
+ export async function itemSearch(ctx) {
146
+ const t = await tiktok(ctx);
147
+ const d = await api.searchGeneral(t, ctx.args.keyword, { offset: cursorOf(ctx) });
148
+ const list = (d.data ?? []).filter((x) => x.type === 1 && x.item?.id).map((x) => norm.item(x.item));
149
+ return paged(list, d.cursor, bool(d.has_more));
150
+ }
151
+ export async function itemRelated(ctx) {
152
+ const t = await tiktok(ctx);
153
+ const ref = await resolveItem(t, ctx.args.item);
154
+ const d = await api.relatedItems(t, ref.id, { cursor: cursorOf(ctx), referer: ref.handle ? ref.url : undefined });
155
+ // 网页端把缺省的 hasMore 当作 true,下一页用回包的 cursor
156
+ const list = (d.itemList ?? []).filter((v) => v?.id);
157
+ return itemPage(list, d.cursor, list.length > 0 && (d.hasMore === undefined || bool(d.hasMore)));
158
+ }
159
+ /** 创作者中心的作品(字段名随 Studio 版本变化,取常见的几种)。 */
160
+ function studioItem(v) {
161
+ const id = n.id(v.item_id ?? v.aweme_id ?? v.id ?? v.itemId);
162
+ const privacy = Number(v.privacy_level ?? v.visibility_type ?? v.visibility ?? -1);
163
+ const review = Number(v.review_status ?? v.audit_status ?? v.status ?? -1);
164
+ return n.item({
165
+ id,
166
+ kind: v.image_post_info || v.aweme_type === 150 ? 'image' : 'video',
167
+ url: norm.itemUrl(v.author?.unique_id ?? v.unique_id, id),
168
+ text: n.str(v.desc ?? v.description ?? v.title),
169
+ created_at: n.time(v.create_time ?? v.createTime ?? v.post_time),
170
+ cover: n.url(v.cover_url ?? v.cover?.url_list?.[0] ?? v.cover),
171
+ stats: {
172
+ views: n.count(v.play_count ?? v.statistics?.play_count),
173
+ likes: n.count(v.like_count ?? v.digg_count ?? v.statistics?.digg_count),
174
+ comments: n.count(v.comment_count ?? v.statistics?.comment_count),
175
+ collects: n.count(v.favorite_count ?? v.collect_count ?? v.statistics?.collect_count),
176
+ shares: n.count(v.share_count ?? v.statistics?.share_count),
177
+ },
178
+ status: privacy === 1 ? 'private' : review === 0 || review === 1 ? 'reviewing' : review === 2 ? 'rejected' : 'published',
179
+ }, v);
180
+ }
181
+ export async function itemList(ctx) {
182
+ const t = await tiktok(ctx);
183
+ t.requireLogin();
184
+ const cursor = Number(cursorOf(ctx)) || 0;
185
+ const d = await api.creatorItemList(t, cursor);
186
+ const list = d.item_list ?? d.items ?? d.data?.item_list ?? [];
187
+ const next = d.cursor ?? d.data?.cursor ?? cursor + list.length;
188
+ return paged(list.map(studioItem), next, bool(d.has_more ?? d.hasMore ?? d.data?.has_more));
189
+ }
190
+ export async function itemMedia(ctx) {
191
+ const t = await tiktok(ctx);
192
+ return norm.itemMedia(await itemStruct(t, ctx.args.item), true);
193
+ }
194
+ export async function itemDownload(ctx) {
195
+ const t = await tiktok(ctx);
196
+ const s = await itemStruct(t, ctx.args.item);
197
+ const media = norm.itemMedia(s);
198
+ if (!media.length)
199
+ throw new SpecificAIError('UPSTREAM', '作品没有可下载的媒体');
200
+ return downloadMedia(ctx, t.http, String(s.id), media, {
201
+ headers: [
202
+ ['user-agent', t.ua],
203
+ ['referer', `${ORIGIN}/`],
204
+ ],
205
+ ext: (m) => (m.type === 'audio' ? 'mp3' : m.type === 'image' ? 'jpg' : 'mp4'),
206
+ });
207
+ }
208
+ async function digg(ctx, on) {
209
+ const t = await tiktok(ctx);
210
+ t.requireLogin();
211
+ const ref = await resolveItem(t, ctx.args.item);
212
+ await api.itemDigg(t, ref.id, on ? '1' : '0', ref.handle ? { referer: ref.url, queryReferer: `${ORIGIN}/@${ref.handle}` } : {});
213
+ return { id: ref.id };
214
+ }
215
+ export const itemLike = (ctx) => digg(ctx, true);
216
+ export const itemUnlike = (ctx) => digg(ctx, false);
217
+ async function collect(ctx, on) {
218
+ const t = await tiktok(ctx);
219
+ t.requireLogin();
220
+ const s = await itemStruct(t, ctx.args.item);
221
+ const handle = s.author?.uniqueId;
222
+ await api.itemCollect(t, String(s.id), String(s.author?.secUid ?? ''), on ? '1' : '0', handle ? { referer: norm.itemUrl(handle, s.id), queryReferer: `${ORIGIN}/@${handle}` } : {});
223
+ return { id: String(s.id) };
224
+ }
225
+ export const itemCollect = (ctx) => collect(ctx, true);
226
+ export const itemUncollect = (ctx) => collect(ctx, false);
227
+ const VISIBILITY = { public: 0, private: 1, friends: 2 };
228
+ /** 发布的互动开关(私有选项 --allow-*):on → 1、off → 0,不给时交给 body 构造用上游默认值。 */
229
+ export function publishToggles(o) {
230
+ const flag = (v) => (v == null ? undefined : v === 'on' ? 1 : 0);
231
+ return {
232
+ allowComment: flag(o.allowComment),
233
+ allowDuet: flag(o.allowDuet),
234
+ allowStitch: flag(o.allowStitch),
235
+ allowContentReuse: flag(o.allowContentReuse),
236
+ allowAiRemix: flag(o.allowAiRemix),
237
+ };
238
+ }
239
+ function captionOf(o) {
240
+ const tags = [...(o.tag ?? []), ...(o.topic ?? [])].map((x) => `#${x.replace(/^#/, '')}`);
241
+ const mentions = (o.mention ?? []).map((x) => `@${x.replace(/^@/, '')}`);
242
+ return [o.text ?? '', ...tags, ...mentions].filter(Boolean).join(' ');
243
+ }
244
+ export async function itemPublish(ctx) {
245
+ const t = await tiktok(ctx);
246
+ t.requireLogin();
247
+ const o = ctx.options;
248
+ if (!o.video && !o.image?.length)
249
+ throw new SpecificAIError('USAGE', '发布需要 --video 或 --image', { hint: 'specific-ai tiktok item publish --video a.mp4 --cover a.jpg --text "文案"' });
250
+ if (o.video && o.image?.length)
251
+ throw new SpecificAIError('USAGE', '--video 和 --image 只能选一个');
252
+ const text = captionOf(o);
253
+ const visibilityType = VISIBILITY[o.visibility ?? 'public'];
254
+ t.ticketGuard(up.PROJECT_POST_PATH);
255
+ if (o.video) {
256
+ if (!o.cover)
257
+ throw new SpecificAIError('USAGE', '发布视频需要 --cover(specific-ai 不带 ffmpeg,无法从视频里截首帧)', { hint: 'specific-ai tiktok item publish --video a.mp4 --cover a.jpg --text "文案"' });
258
+ const video = await readMedia(t.http, o.video);
259
+ const cover = await up.picture((await readMedia(t.http, o.cover)).data);
260
+ const meta = up.mp4Meta(video.data);
261
+ const auth = await up.uploadAuth(t, { referer: api.UPLOAD_REFERER });
262
+ const hosts = await up.probeUploadCandidates(t, await up.uploadCandidates(t, auth));
263
+ const uploaded = await up.uploadMediaBytes(t, video.data, { fileType: 'video', spaceName: 'tiktok', businessTag: 'tiktok_video_submission_web', filename: video.filename, auth, clientBestHosts: hosts });
264
+ await up.mediaOpenId(t);
265
+ await up.enableVideoTranscode(t, uploaded.video_id);
266
+ const creation = up.creationId();
267
+ await up.projectCreate(t, creation);
268
+ const fileKey = `file_${rand.now()}_${String(up.randbelow(1000000)).padStart(6, '0')}`;
269
+ const transcode = await up.waitVideoTranscode(t, uploaded.video_id, { width: meta.width, height: meta.height, durationMs: meta.durationMs, fileKey });
270
+ const playUrl = String(transcode.transcode_result?.[0]?.play_url ?? '');
271
+ if (!playUrl)
272
+ throw new SpecificAIError('UPSTREAM', '转码结果缺少 play_url');
273
+ await up.uploadMediaBytes(t, up.zipOne('0.jpeg', cover.jpeg), { fileType: 'image', spaceName: 'tiktok-ai-frame', auth: await up.uploadAuth(t) });
274
+ const poster = await up.uploadMediaBytes(t, cover.png, { fileType: 'image', spaceName: 'tiktok', scene: 'poster', businessTag: 'tiktok_video_cover_web', auth: await up.uploadAuth(t) });
275
+ const coverUri = String(poster.commit?.Result?.Results?.[0]?.Uri ?? '');
276
+ if (!coverUri)
277
+ throw new SpecificAIError('UPSTREAM', 'CommitUploadInner 缺少 Result.Results[0].Uri');
278
+ const body = up.buildVideoProjectBody({ creationId: creation, videoId: uploaded.video_id, text, coverUri, playUrl, filename: video.filename, ...meta, visibilityType, ...publishToggles(o) });
279
+ const r = await up.postProject(t, body);
280
+ return published(r, 'video', text, visibilityType);
281
+ }
282
+ const photos = [];
283
+ for (const input of o.image) {
284
+ const file = await readMedia(t.http, input);
285
+ photos.push({ data: file.data, pic: await up.picture(file.data) });
286
+ }
287
+ const referer = `${ORIGIN}/tiktokstudio/upload/post/photo`;
288
+ const imageAuth = await up.uploadAuth(t, { signed: false, referer });
289
+ const uploaded = [];
290
+ for (const p of photos)
291
+ uploaded.push(await up.uploadPhotoBytes(t, p.data, imageAuth, { referer }));
292
+ await up.uploadMediaBytes(t, up.zipOne('0.jpeg', photos[0].pic.jpeg), { fileType: 'image', spaceName: 'tiktok-ai-frame', auth: await up.uploadAuth(t, { referer }), referer });
293
+ const creation = up.photoCreationId();
294
+ const now = rand.now();
295
+ const rows = uploaded.map((u, i) => ({ id: `file_${now + i}_${up.randbelow(1000000)}`, uri: u.uri, width_px: u.width, height_px: u.height }));
296
+ const body = up.buildPhotoProjectBody({ creationId: creation, photos: rows, text, title: o.title ?? '', visibilityType, ...publishToggles(o) });
297
+ const r = await up.postProject(t, body, referer);
298
+ return published(r, 'image', text, visibilityType);
299
+ }
300
+ function published(r, kind, text, visibility) {
301
+ const resp = r.single_post_resp_list?.[0] ?? {};
302
+ const id = n.id(resp.item_id ?? resp.aweme_id ?? r.item_id ?? '');
303
+ return n.item({ id, kind, url: id ? norm.itemUrl('_', id, kind === 'image') : null, text, status: visibility === 1 ? 'private' : 'reviewing' }, r);
304
+ }
305
+ // ================================================================ media
306
+ export async function mediaUpload(ctx) {
307
+ const t = await tiktok(ctx);
308
+ t.requireLogin();
309
+ const file = await readMedia(t.http, ctx.args.file);
310
+ if (file.contentType.startsWith('video/')) {
311
+ const auth = await up.uploadAuth(t);
312
+ const r = await up.uploadMediaBytes(t, file.data, { fileType: 'video', spaceName: 'tiktok', businessTag: 'tiktok_video_submission_web', filename: file.filename, auth });
313
+ return n.media({ id: r.video_id, type: 'video', url: r.video_id }, r);
314
+ }
315
+ const referer = `${ORIGIN}/tiktokstudio/upload/post/photo`;
316
+ const r = await up.uploadPhotoBytes(t, file.data, await up.uploadAuth(t, { signed: false, referer }), { referer });
317
+ return n.media({ id: r.uri, type: 'image', url: r.uri, width: r.width, height: r.height }, r);
318
+ }
319
+ // ================================================================ product 与商品评价
320
+ async function productDetail(t, input) {
321
+ const { id, url } = resolveProduct(input);
322
+ return { id, url, data: await api.shopProductDetail(t, url, id) };
323
+ }
324
+ export async function productGet(ctx) {
325
+ const t = await tiktok(ctx);
326
+ return norm.product((await productDetail(t, ctx.args.product)).data);
327
+ }
328
+ async function productReviews(ctx, t) {
329
+ const page = Number(ctx.cursor ?? 1) || 1;
330
+ const { id, url, data } = await productDetail(t, ctx.args.item);
331
+ const r = page === 1 ? data.review_info : await api.shopReviewPage(t, url, id, page);
332
+ if (!r || !Array.isArray(r.product_reviews))
333
+ throw new SpecificAIError('UPSTREAM', 'TikTok Shop 评价缺少 product_reviews');
334
+ return paged(r.product_reviews.map((v) => norm.review(v, id)), page + 1, r.has_more === true);
335
+ }
336
+ // ================================================================ comment
337
+ export async function commentList(ctx) {
338
+ const t = await tiktok(ctx);
339
+ if (ctx.options.product || isProductInput(ctx.args.item))
340
+ return productReviews(ctx, t);
341
+ const ref = await resolveItem(t, ctx.args.item);
342
+ const d = await api.comments(t, ref.id, cursorOf(ctx));
343
+ return paged((d.comments ?? []).map((c) => norm.comment(c, ref.id)), d.cursor, bool(d.has_more));
344
+ }
345
+ export async function commentReplies(ctx) {
346
+ const t = await tiktok(ctx);
347
+ const ref = await resolveItem(t, ctx.args.item);
348
+ // 上游默认从 cursor=1 开始(跳过列表里已预览的一条),specific-ai 从 0 开始取全部回复
349
+ const d = await api.commentReplies(t, ref.id, ctx.args.comment, { cursor: cursorOf(ctx), referer: ref.url, rootReferer: ref.url });
350
+ return paged((d.comments ?? []).map((c) => norm.comment(c, ref.id)), d.cursor, bool(d.has_more));
351
+ }
352
+ export async function commentAdd(ctx) {
353
+ const t = await tiktok(ctx);
354
+ t.requireLogin();
355
+ const ref = await resolveItem(t, ctx.args.item);
356
+ const replyTo = ctx.options.replyTo;
357
+ const d = await api.postComment(t, ref.id, ctx.args.text, replyTo ? { replyId: replyTo } : {});
358
+ if (d.comment)
359
+ return norm.comment(d.comment, ref.id);
360
+ return n.comment({ id: n.id(d.cid ?? ''), item_id: ref.id, text: ctx.args.text, parent_id: replyTo ?? null, created_at: n.time(rand.nowSeconds()) }, d);
361
+ }
362
+ // ================================================================ feed
363
+ export async function feedList(ctx) {
364
+ const t = await tiktok(ctx);
365
+ if ((ctx.options.kind ?? 'recommend') === 'following') {
366
+ t.requireLogin();
367
+ const d = await api.followingItemList(t, cursorOf(ctx));
368
+ return itemPage(d.itemList, d.cursor, d.hasMore);
369
+ }
370
+ // 推荐流没有游标,每次请求返回新的一批
371
+ const page = Number(ctx.cursor ?? 0) || 0;
372
+ const d = await api.recommendFeed(t);
373
+ return itemPage(d.itemList, page + 1, d.hasMore ?? true);
374
+ }
375
+ // ================================================================ live
376
+ /**
377
+ * 直播间信息。只给房间号时先查是否在播(上游 check_live_rooms),已下播就不进房,直接返回 offline;
378
+ * 在播时进房(上游 enter_live_room)取完整的房间对象。
379
+ */
380
+ export async function liveGet(ctx) {
381
+ const t = await tiktok(ctx);
382
+ const input = ctx.args.room;
383
+ if (isRoomId(input)) {
384
+ const roomId = input.trim();
385
+ try {
386
+ const d = await api.checkLiveRooms(t, roomId);
387
+ const row = (Array.isArray(d.data) ? d.data : []).find((x) => String(x?.room_id_str ?? x?.room_id) === roomId);
388
+ if (row && row.alive === false)
389
+ return n.live({ id: roomId, status: 'offline' }, d);
390
+ }
391
+ catch (err) {
392
+ // 只是省一次进房;查不到就直接进房
393
+ if (err instanceof SpecificAIError && (err.code === 'AUTH_REQUIRED' || err.code === 'AUTH_EXPIRED'))
394
+ throw err;
395
+ ctx.log.debug(`check_alive 失败,直接进房:${err.message}`);
396
+ }
397
+ return norm.webcastRoom((await enterRoom(t, roomId)).webcast);
398
+ }
399
+ const r = await resolveRoom(t, input, { needHost: true });
400
+ return norm.liveRoom(r.data);
401
+ }
402
+ /** 拉流地址:/api-live/user/room 的 liveRoom.streamData,只给房间号时用进房结果的 stream_url。 */
403
+ export async function liveMedia(ctx) {
404
+ const t = await tiktok(ctx);
405
+ const r = await resolveRoom(t, ctx.args.room, { needHost: true });
406
+ const media = r.webcast ? norm.liveStreams(r.webcast.stream_url) : norm.liveStreams(r.data?.liveRoom?.streamData);
407
+ if (!media.length)
408
+ throw new SpecificAIError('UPSTREAM', '没有拉流地址(主播可能没有在直播)', { detail: { room: r.roomId } });
409
+ return media;
410
+ }
411
+ /** 发弹幕、点赞的页面:主播的直播页,没有主播用户名时退回直播广场。 */
412
+ const livePage = (r) => norm.liveUrl(r.handle) ?? `${ORIGIN}/live`;
413
+ export async function liveList(ctx) {
414
+ const t = await tiktok(ctx);
415
+ const d = await api.webcastFeed(t);
416
+ const rooms = (Array.isArray(d.data) ? d.data : []).map((x) => x?.data ?? x).filter((r) => r?.id_str || r?.id);
417
+ return paged(rooms.map(norm.webcastRoom), null, false);
418
+ }
419
+ export async function liveSearch(ctx) {
420
+ const t = await tiktok(ctx);
421
+ const d = await api.searchLiveRoom(t, ctx.args.keyword);
422
+ return paged((d.data?.room_list ?? []).map(norm.webcastRoom), null, false);
423
+ }
424
+ export async function liveCategories(ctx) {
425
+ const t = await tiktok(ctx);
426
+ const d = await api.webcastDrawerTabs(t);
427
+ return norm.categories(d.data ?? []);
428
+ }
429
+ export async function liveHistory(ctx) {
430
+ const t = await tiktok(ctx);
431
+ const r = await resolveRoom(t, ctx.args.room);
432
+ const resp = wire.decodeLiveResponse(await api.webcastImFetch(t, LIVE_ID, r.roomId));
433
+ return resp.events.filter((e) => e.type === 'chat').map((e) => norm.liveEvent(e));
434
+ }
435
+ export async function liveSend(ctx) {
436
+ if (ctx.options.gift != null)
437
+ throw new SpecificAIError('NOT_IMPLEMENTED', 'tiktok 的 live send --gift 尚未实现(上游不含送礼)', { detail: { upstream: 'none' } });
438
+ const t = await tiktok(ctx);
439
+ t.requireLogin();
440
+ const r = await resolveRoom(t, ctx.args.room, { needHost: true });
441
+ await api.postLiveChat(t, r.roomId, ctx.args.text, { referer: livePage(r) });
442
+ return { id: r.roomId };
443
+ }
444
+ export async function liveLike(ctx) {
445
+ const t = await tiktok(ctx);
446
+ t.requireLogin();
447
+ const r = await resolveRoom(t, ctx.args.room, { needHost: true });
448
+ await api.postLiveLike(t, r.hostId, r.roomId, { referer: livePage(r) });
449
+ return { id: r.roomId };
450
+ }
451
+ export async function liveRank(ctx) {
452
+ const t = await tiktok(ctx);
453
+ const r = await resolveRoom(t, ctx.args.room, { needHost: true });
454
+ const d = await api.webcastRankList(t, r.hostId, r.roomId);
455
+ const ranks = d.data?.ranks ?? d.data?.rank_list ?? d.data?.list ?? [];
456
+ return ranks.map(norm.rank).filter((x) => x != null);
457
+ }
458
+ export async function liveGifts(ctx) {
459
+ const t = await tiktok(ctx);
460
+ const r = await resolveRoom(t, ctx.args.room);
461
+ const d = await api.liveGiftList(t, r.roomId);
462
+ return (d.data?.gifts ?? []).map(norm.gift);
463
+ }
464
+ /**
465
+ * 弹幕长连的原始事件(上游 iter_live_ws_events):HTTP 拉一次拿 cursor → frontierSign → WS 心跳 + 进房 →
466
+ * 收帧、按需 ACK;首批历史只在第一次连接时输出,按 (method, message_id) 去重。
467
+ */
468
+ export function liveEvents(ctx, t, roomId, page) {
469
+ const seen = new Set();
470
+ let first = true;
471
+ return reconnecting(ctx, async function* () {
472
+ const initial = wire.decodeLiveResponse(await api.webcastImFetch(t, LIVE_ID, roomId, { referer: page }));
473
+ if (!initial.cursor)
474
+ throw new SpecificAIError('UPSTREAM', '直播初始 protobuf 缺少 WS cursor');
475
+ const marker = await frontierSign({ cookie: t.device.document_cookie || t.cookieStr, userAgent: t.ua, referer: page, metrics: t.metrics });
476
+ const socket = await openSocket(api.liveWsUrl(t, LIVE_ID, roomId, marker), {
477
+ headers: { 'User-Agent': t.ua, Origin: ORIGIN, Cookie: t.cookieStr },
478
+ signal: ctx.signal,
479
+ });
480
+ const heartbeat = () => socket.send(wire.encodeHeartbeat(roomId)).catch(() => { });
481
+ await socket.send(wire.encodeHeartbeat(roomId));
482
+ await socket.send(wire.encodeEnterRoom(roomId, LIVE_ID, initial.cursor));
483
+ const beat = setInterval(heartbeat, 10_000).unref();
484
+ const fresh = function* (events) {
485
+ for (const e of events) {
486
+ const key = `${e.method}:${e.message_id}`;
487
+ if (seen.has(key))
488
+ continue;
489
+ seen.add(key);
490
+ if (e.type !== 'other')
491
+ yield e;
492
+ }
493
+ };
494
+ try {
495
+ if (first)
496
+ yield* fresh(initial.events);
497
+ else
498
+ initial.events.forEach((e) => seen.add(`${e.method}:${e.message_id}`));
499
+ first = false;
500
+ for await (const raw of socket.messages) {
501
+ if (typeof raw === 'string')
502
+ continue;
503
+ const frame = wire.decodePushFrame(raw);
504
+ const resp = frame.response;
505
+ ctx.log.debug(`ws ${frame.payload_type} ${resp ? resp.events.map((e) => e.method).join(',') : `${frame.payload_length} 字节`}`);
506
+ if (!resp)
507
+ continue;
508
+ if (resp.need_ack)
509
+ await socket.send(wire.encodeFrame('ack', new TextEncoder().encode(resp.internal_ext), frame.log_id)).catch(() => { });
510
+ yield* fresh(resp.events);
511
+ }
512
+ }
513
+ finally {
514
+ clearInterval(beat);
515
+ socket.close();
516
+ }
517
+ });
518
+ }
519
+ export function liveListen(ctx) {
520
+ return (async function* () {
521
+ const t = await tiktok(ctx);
522
+ const r = await resolveRoom(t, ctx.args.room);
523
+ for await (const e of liveEvents(ctx, t, r.roomId, livePage(r))) {
524
+ const ev = norm.liveEvent(e);
525
+ if (ev)
526
+ yield ev;
527
+ }
528
+ })();
529
+ }
530
+ // ================================================================ keyword
531
+ export async function keywordSuggest(ctx) {
532
+ const t = await tiktok(ctx);
533
+ const d = await api.searchSuggest(t, ctx.args.prefix);
534
+ return (d.data ?? d.sug_list ?? []).map((x) => n.keyword({ text: String(x.word ?? x.content ?? '') }, x)).filter((k) => k.text);
535
+ }
536
+ // ================================================================ notice
537
+ /** 通知分组:500 是动态(赞、评论、@、关注),661 是系统通知(网页收件箱的 System notifications)。 */
538
+ const NOTICE_ACTIVITY = 500;
539
+ const NOTICE_SYSTEM = 661;
540
+ const DONE = '-';
541
+ function noticeGroup(d, group) {
542
+ const lists = d.notice_lists ?? d.notice_list_v2 ?? [];
543
+ return lists.find((g) => Number(g?.group) === group) ?? lists[0] ?? {};
544
+ }
545
+ /**
546
+ * 动态(notice/multi,group 500)和系统通知(inbox/notice_list,group 661)合在一起,按时间倒序。
547
+ * 游标是两组各自的 max_time,写成 `<动态>:<系统>`,翻完的一组记作 `-`,不再请求。
548
+ * 系统通知这一路失败时只返回动态通知,游标里保留它原来的位置,下一页再试。
549
+ */
550
+ export async function noticeList(ctx) {
551
+ const t = await tiktok(ctx);
552
+ t.requireLogin();
553
+ const [activity = '0', system = DONE] = (ctx.cursor ?? '0:0').split(':');
554
+ const raw = [];
555
+ const next = { activity: DONE, system: DONE };
556
+ const group = (n, maxTime) => [{ count: 20, is_mark_read: 0, group: n, max_time: Number(maxTime) || 0, min_time: 0 }];
557
+ if (activity !== DONE) {
558
+ const g = noticeGroup(await api.noticeMulti(t, group(NOTICE_ACTIVITY, activity)), NOTICE_ACTIVITY);
559
+ raw.push(...(g.notice_list ?? []).map((v) => ({ v, system: false })));
560
+ if (bool(g.has_more) && g.max_time != null)
561
+ next.activity = String(g.max_time);
562
+ }
563
+ if (system !== DONE) {
564
+ try {
565
+ const g = noticeGroup(await api.inboxNoticeList(t, group(NOTICE_SYSTEM, system)), NOTICE_SYSTEM);
566
+ raw.push(...(g.notice_list ?? []).map((v) => ({ v, system: true })));
567
+ if (bool(g.has_more) && g.max_time != null)
568
+ next.system = String(g.max_time);
569
+ }
570
+ catch (err) {
571
+ // 系统通知取不到时不影响动态通知;只有它失败、动态也翻完时游标不变,由翻页循环停下
572
+ if (err instanceof SpecificAIError && (err.code === 'AUTH_REQUIRED' || err.code === 'AUTH_EXPIRED'))
573
+ throw err;
574
+ next.system = system;
575
+ ctx.log.warn(`TikTok 系统通知获取失败,本次只返回动态通知:${err.message}`);
576
+ }
577
+ }
578
+ raw.sort((a, b) => (Number(b.v?.create_time) || 0) - (Number(a.v?.create_time) || 0));
579
+ const more = next.activity !== DONE || next.system !== DONE;
580
+ return paged(raw.map((x) => norm.notice(x.v, x.system)), more ? `${next.activity}:${next.system}` : null, more);
581
+ }
582
+ /** notice_count 的分组(网页收件箱的枚举):661 系统通知,2 评论、3 / 505 赞、6 @、7 关注(500 是动态的合计)。 */
583
+ const COUNT_GROUPS = { 661: 'system', 2: 'comment', 3: 'like', 505: 'like', 6: 'mention', 7: 'follow' };
584
+ export async function noticeCount(ctx) {
585
+ const t = await tiktok(ctx);
586
+ t.requireLogin();
587
+ const d = await api.noticeCount(t);
588
+ const groups = d.notice_count ?? [];
589
+ const parts = {};
590
+ for (const g of groups) {
591
+ const key = COUNT_GROUPS[Number(g.group)];
592
+ if (key)
593
+ parts[key] = (parts[key] ?? 0) + (Number(g.count) || 0);
594
+ }
595
+ return n.noticeCount({ total: groups.reduce((s, g) => s + (Number(g.count) || 0), 0), ...parts }, d);
596
+ }
597
+ // ================================================================ msg
598
+ function cacheConversations(t, w) {
599
+ const cache = im.convCache(t);
600
+ for (const c of wire.pulledConversations(w))
601
+ cache[c.conversation_id] = { short_id: c.conversation_short_id, type: c.conversation_type };
602
+ return w;
603
+ }
604
+ async function initWire(t) {
605
+ return cacheConversations(t, wire.decodeWire(await im.pullInit(t)));
606
+ }
607
+ /** 会话的对方:`0:1:<uid>:<uid>` 里不是自己的那个。 */
608
+ function peerOf(t, conversationId) {
609
+ const parts = conversationId.split(':').slice(2);
610
+ return parts.find((p) => p !== t.uid) ?? parts[0] ?? null;
611
+ }
612
+ /** 会话列表:私信页的 get_by_user_init,下一页用回包的 next_cursor。 */
613
+ export async function msgList(ctx) {
614
+ const t = await tiktok(ctx);
615
+ t.requireLogin();
616
+ const raw = await im.pullInit(t, cursorOf(ctx));
617
+ const w = cacheConversations(t, wire.decodeWire(raw));
618
+ const messages = wire.pulledMessages(w);
619
+ const list = wire.pulledConversations(w).map(({ conversation_id: id }) => {
620
+ const last = messages.filter((m) => m.conversation_id === id).sort((a, b) => Number(b.create_time) - Number(a.create_time))[0];
621
+ return norm.conversation(id, peerOf(t, id), last, { conversation_id: id, ...im.convCache(t)[id] });
622
+ });
623
+ const page = wire.imPullPage(raw, 203);
624
+ return paged(list, page.cursor, page.hasMore);
625
+ }
626
+ async function conversationInfo(t, id) {
627
+ let info = im.convCache(t)[id];
628
+ if (!info) {
629
+ await initWire(t);
630
+ info = im.convCache(t)[id];
631
+ }
632
+ if (!info)
633
+ throw new SpecificAIError('USAGE', `找不到会话 ${id}`, { hint: 'specific-ai tiktok msg list' });
634
+ return info;
635
+ }
636
+ export async function msgHistory(ctx) {
637
+ const t = await tiktok(ctx);
638
+ t.requireLogin();
639
+ const id = ctx.args.conversation;
640
+ const info = await conversationInfo(t, id);
641
+ const raw = await im.pullConversation(t, { conversationId: id, shortId: info.short_id, type: info.type, anchorIndex: cursorOf(ctx) });
642
+ const page = wire.imPullPage(raw, 301);
643
+ return paged(wire.pulledMessages(wire.decodeWire(raw)).map(norm.imMessage), page.cursor, page.hasMore);
644
+ }
645
+ export async function msgSend(ctx) {
646
+ const o = ctx.options;
647
+ if (o.item)
648
+ throw new SpecificAIError('UNSUPPORTED', 'TikTok 没有联系商品卖家的私信', { hint: '用 --to <用户> 或 --conversation <会话 ID>' });
649
+ if (o.image || o.video)
650
+ throw new SpecificAIError('NOT_IMPLEMENTED', 'tiktok 的 msg send 只支持文本(上游只有文本)', { detail: { upstream: 'partial' } });
651
+ if (ctx.args.text == null)
652
+ throw new SpecificAIError('USAGE', '需要消息内容 <text>');
653
+ const t = await tiktok(ctx);
654
+ t.requireLogin();
655
+ let id = o.conversation;
656
+ if (!id) {
657
+ const target = await parseUser(t, o.to);
658
+ const uid = target.id ?? String((await resolveUserInfo(t, o.to)).user?.id ?? '');
659
+ if (!Object.keys(im.convCache(t)).some((c) => c.split(':').includes(uid)))
660
+ await initWire(t);
661
+ id = Object.keys(im.convCache(t)).find((c) => c.split(':').slice(2).includes(uid));
662
+ if (!id)
663
+ throw new SpecificAIError('USAGE', '还没有和该用户的会话(上游不支持新建会话)', { hint: '先在 TikTok 里和对方开始会话,再用 specific-ai tiktok msg list 查看会话 ID' });
664
+ }
665
+ const info = await conversationInfo(t, id);
666
+ const built = await im.buildSendFrame(t, { conversationId: id, shortId: info.short_id, type: info.type, text: ctx.args.text });
667
+ const socket = await openSocket(await im.imWsUrl(t), { headers: { 'User-Agent': t.ua, Origin: ORIGIN, Cookie: t.cookieStr }, signal: ctx.signal });
668
+ const deadline = rand.now() + ctx.config.timeout * 1000;
669
+ try {
670
+ await socket.send(built.frame);
671
+ const timer = setTimeout(() => socket.close(), Math.max(1000, deadline - rand.now()));
672
+ try {
673
+ for await (const raw of socket.messages) {
674
+ let r;
675
+ try {
676
+ r = wire.decodeImSendResponse(raw);
677
+ }
678
+ catch {
679
+ continue;
680
+ }
681
+ if (r.heartbeat || r.cmd !== 100)
682
+ continue;
683
+ if (r.status_code)
684
+ throw new SpecificAIError('UPSTREAM', `私信发送失败:${r.error_desc || r.status_code}`, { detail: r });
685
+ return n.message({ id: String(r.server_message_id ?? built.clientMessageId), conversation_id: id, from: n.userRef({ id: t.uid }), type: 'text', text: ctx.args.text, created_at: n.time(rand.now()) }, r);
686
+ }
687
+ }
688
+ finally {
689
+ clearTimeout(timer);
690
+ }
691
+ }
692
+ finally {
693
+ socket.close();
694
+ }
695
+ throw new SpecificAIError('NETWORK', '私信 WebSocket 没有收到 command-100 回包', { detail: { kind: 'timeout' } });
696
+ }
697
+ /** 新消息推送(上游 iter_im_ws_messages):只处理文本,心跳 hi 原样回。 */
698
+ export function msgListen(ctx) {
699
+ return (async function* () {
700
+ const t = await tiktok(ctx);
701
+ t.requireLogin();
702
+ const seen = new Set();
703
+ yield* reconnecting(ctx, async function* () {
704
+ const socket = await openSocket(await im.imWsUrl(t), { headers: { 'User-Agent': t.ua, Origin: ORIGIN, Cookie: t.cookieStr }, signal: ctx.signal });
705
+ try {
706
+ for await (const raw of socket.messages) {
707
+ if (raw === 'hi') {
708
+ await socket.send('hi').catch(() => { });
709
+ continue;
710
+ }
711
+ let m;
712
+ try {
713
+ m = wire.decodeImNotification(raw);
714
+ }
715
+ catch (err) {
716
+ ctx.log.debug(`私信推送解码失败:${err.message}`);
717
+ continue;
718
+ }
719
+ if (!m)
720
+ continue;
721
+ const key = `${m.conversation_id}:${m.server_message_id}`;
722
+ if (seen.has(key))
723
+ continue;
724
+ seen.add(key);
725
+ yield norm.imMessage(m);
726
+ }
727
+ }
728
+ finally {
729
+ socket.close();
730
+ }
731
+ });
732
+ })();
733
+ }
734
+ // ================================================================ folder / series
735
+ export async function folderList(ctx) {
736
+ const t = await tiktok(ctx);
737
+ const d = await api.collectionList(t, await resolveSecUid(t, ctx.args.user ?? 'me'), cursorOf(ctx));
738
+ return paged((d.collectionList ?? []).map(norm.folder), d.cursor, bool(d.hasMore));
739
+ }
740
+ export async function folderItems(ctx) {
741
+ const t = await tiktok(ctx);
742
+ const d = await api.collectionItemList(t, ctx.args.folder, cursorOf(ctx));
743
+ return itemPage(d.itemList, d.cursor, d.hasMore);
744
+ }
745
+ /** 收藏夹的 --visibility(public / private,注册表已校验)→ collectionStatus。 */
746
+ function folderStatus(visibility) {
747
+ return visibility == null ? undefined : api.COLLECTION_STATUS[visibility];
748
+ }
749
+ export async function folderCreate(ctx) {
750
+ const status = folderStatus(ctx.options.visibility) ?? api.COLLECTION_STATUS.private;
751
+ const t = await tiktok(ctx);
752
+ t.requireLogin();
753
+ const name = ctx.args.name;
754
+ await api.checkPlaylistName(t, name);
755
+ const d = await api.collectionCreate(t, name, status);
756
+ const c = d.collection ?? d.collectionInfo ?? d;
757
+ const id = n.id(c.collectionId ?? c.collection_id ?? d.collectionId);
758
+ if (!id)
759
+ throw new SpecificAIError('UPSTREAM', '新建收藏夹没有返回收藏夹 id', { detail: { body: d } });
760
+ return n.folder({ id, name, count: 0 }, d);
761
+ }
762
+ /**
763
+ * 修改名字或公开状态:modify_info 两个字段都要带,只给了一个时先查收藏夹详情补上另一个的原值
764
+ * (上游默认 collectionStatus=1,只改名会把公开收藏夹改成私密)。
765
+ */
766
+ export async function folderUpdate(ctx) {
767
+ let status = folderStatus(ctx.options.visibility);
768
+ let name = ctx.options.name;
769
+ const t = await tiktok(ctx);
770
+ t.requireLogin();
771
+ const id = ctx.args.folder;
772
+ let info = null;
773
+ if (name == null || status == null) {
774
+ const d = await api.collectionDetail(t, id);
775
+ info = d.collectionInfo ?? d.collection ?? {};
776
+ name ??= info.name != null ? String(info.name) : undefined;
777
+ status ??= info.status != null ? String(info.status) : undefined;
778
+ if (name == null || status == null) {
779
+ throw new SpecificAIError('UPSTREAM', '收藏夹详情里没有名字或公开状态,无法只改其中一项', { hint: `同时给出 --name 和 --visibility:specific-ai tiktok folder update ${id} --name <名字> --visibility private` });
780
+ }
781
+ }
782
+ const d = await api.collectionModifyInfo(t, id, name, status);
783
+ const owner = info?.userName;
784
+ return n.folder({ id, name, count: info ? n.count(info.total) : null, url: owner ? `${ORIGIN}/@${owner}/collection/${encodeURIComponent(name)}-${id}` : null }, d);
785
+ }
786
+ /**
787
+ * 把视频加入收藏夹(上游 post_collection_modify_items)。TikTok 只能把已收藏的视频放进收藏夹:
788
+ * 视频页显示还没收藏(itemStruct.collected 为 false)时,先照浏览器的顺序收藏一次。
789
+ */
790
+ export async function folderAdd(ctx) {
791
+ const t = await tiktok(ctx);
792
+ t.requireLogin();
793
+ const s = await itemStruct(t, ctx.args.item);
794
+ const id = String(s.id);
795
+ const handle = s.author?.uniqueId;
796
+ if (!handle)
797
+ throw new SpecificAIError('UPSTREAM', '视频页没有作者用户名,无法构造收藏夹写入的页面地址');
798
+ const page = norm.itemUrl(handle, id);
799
+ const profile = `${ORIGIN}/@${handle}`;
800
+ if (s.collected === false)
801
+ await api.itemCollect(t, id, String(s.author?.secUid ?? ''), '1', { referer: page, queryReferer: profile });
802
+ await api.collectionModifyItems(t, ctx.args.folder, id, { referer: page, profileUrl: profile });
803
+ return { id };
804
+ }
805
+ export async function seriesList(ctx) {
806
+ const t = await tiktok(ctx);
807
+ const d = await api.userPlaylist(t, await resolveSecUid(t, ctx.args.user ?? 'me'), cursorOf(ctx));
808
+ return paged((d.playList ?? []).map(norm.series), d.cursor, bool(d.hasMore));
809
+ }
810
+ // ================================================================ poi
811
+ /** 上游的地点列表没有关键词字段:取推荐地点后按关键词在本地过滤。 */
812
+ export async function poiSearch(ctx) {
813
+ const t = await tiktok(ctx);
814
+ t.requireLogin();
815
+ const page = Number(ctx.cursor ?? 1) || 1;
816
+ const d = await api.creatorPoiList(t, { pageNum: page });
817
+ const list = d.poi_list ?? d.data?.poi_list ?? d.pois ?? [];
818
+ const kw = ctx.args.keyword.toLowerCase();
819
+ const pois = list.map(norm.poi).filter((p) => `${p.name} ${p.address ?? ''}`.toLowerCase().includes(kw));
820
+ return paged(pois, page + 1, bool(d.has_more ?? d.data?.has_more));
821
+ }