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,712 @@
1
+ import { SpecificAIError } from '../../../core/errors.js';
2
+ import { downloadMedia, readMedia } from '../../../core/files.js';
3
+ import { cookieCredential, finishLogin, freshCredential, showQrcode, smsLogin } from '../../../core/login.js';
4
+ import * as n from '../../../core/normalize.js';
5
+ import { jsonLoads } from '../../../core/py.js';
6
+ import * as rand from '../../../core/rand.js';
7
+ import { authError, paged } from '../../../core/toolkit.js';
8
+ import * as api from './api.js';
9
+ import { check, Douyin, douyin } from './client.js';
10
+ import * as creator from './creator.js';
11
+ import { buildBlob, DTRAIT_BROKEN, DTRAIT_HINT } from './dtrait.js';
12
+ import * as im from './im.js';
13
+ import * as live from './live.js';
14
+ import * as norm from './normalize.js';
15
+ import { Passport } from './passport.js';
16
+ import { PROFILE, WWW, WWW_ONLY } from './profile.js';
17
+ import { resolveItem, resolveProduct, resolveRoom, resolveShare, resolveUser } from './resolve.js';
18
+ const cursor = (ctx, dflt = '0') => ctx.cursor ?? dflt;
19
+ const more = (v) => v === 1 || v === true;
20
+ const isAuthError = (err) => err instanceof SpecificAIError && (err.code === 'AUTH_REQUIRED' || err.code === 'AUTH_EXPIRED');
21
+ // ================================================================ auth
22
+ /**
23
+ * 在线校验:只有 query/user 表明没登录(AUTH_*:没有 user_uid 或为 0)才算未登录;
24
+ * 风控(空响应、acrawler、Uifid)、网络、业务错误原样抛出,不误报成未登录。
25
+ */
26
+ export async function authStatus(ctx) {
27
+ const loggedOut = { logged_in: false, user: null, method: null, expires_at: null };
28
+ const d = await douyin(ctx);
29
+ if (!d.isLogin)
30
+ return loggedOut;
31
+ let uid;
32
+ try {
33
+ uid = await api.myUid(d);
34
+ }
35
+ catch (err) {
36
+ if (isAuthError(err))
37
+ return loggedOut;
38
+ throw err;
39
+ }
40
+ d.tokens.uid = uid;
41
+ // sec_uid 取不到(创作者接口与主页 HTML 都没给)时沿用凭证里的用户,登录态本身已经由 uid 确认
42
+ const me = await api.mySecUid(d).catch((err) => {
43
+ if (err instanceof SpecificAIError)
44
+ throw err;
45
+ ctx.log.debug(`取 sec_uid 失败:${err.message}`);
46
+ return null;
47
+ });
48
+ const sess = d.jar.find((c) => c.name === 'sessionid');
49
+ const user = me ? norm.ref(me.secUid, me.user?.nickname ?? ctx.credential.user?.name) : ctx.credential.user;
50
+ return { logged_in: true, user, method: ctx.credential.method, expires_at: sess?.expires ? n.time(sess.expires) : null };
51
+ }
52
+ // ================================================================ user
53
+ /** 用户资料(profile/other);me 先经创作者中心取自己的 sec_uid。 */
54
+ async function profile(d, input) {
55
+ const secUid = await resolveUser(d, input);
56
+ const body = check(d.ctx, await api.userInfo(d, secUid));
57
+ if (!body.user)
58
+ throw new SpecificAIError('UPSTREAM', `没有找到用户:${input}`, { detail: { status_code: body.status_code } });
59
+ return body.user;
60
+ }
61
+ export async function userGet(ctx) {
62
+ const d = await douyin(ctx);
63
+ return norm.user(await profile(d, ctx.args.user));
64
+ }
65
+ const USER_TYPE = { common: 'common_user', enterprise: 'enterprise_user', personal: 'personal_user' };
66
+ export async function userSearch(ctx) {
67
+ const d = await douyin(ctx);
68
+ const offset = cursor(ctx);
69
+ const fans = ctx.options.fans ?? '';
70
+ const userType = USER_TYPE[ctx.options.userType ?? ''] ?? '';
71
+ const body = check(ctx, await api.searchUser(d, ctx.args.keyword, offset, '25', fans, userType));
72
+ const list = (body.user_list ?? []).map((x) => norm.user(x.user_info ?? x));
73
+ // 游标是自己算的偏移:服务端给 has_more=1 却返回空列表时要停下,否则 --all 一直翻
74
+ return paged(list, Number(offset) + 25, more(body.has_more) && list.length > 0);
75
+ }
76
+ export async function userItems(ctx) {
77
+ const d = await douyin(ctx);
78
+ const secUid = await resolveUser(d, ctx.args.user);
79
+ const body = check(ctx, await api.userWorks(d, secUid, cursor(ctx), secUid === ctx.credential.user?.id));
80
+ return paged((body.aweme_list ?? []).map(norm.aweme), body.max_cursor, more(body.has_more));
81
+ }
82
+ /**
83
+ * 缺 UIFID 时自动补上,再把整条命令重跑一次:UIFID 由服务端在推荐流的响应里下发(实测 2026-09),扫码 / 短信登录后
84
+ * 凭证里还没有它,个别接口(如 user likes)会报 "Uifid Not Found"。正常路径不多发请求,与上游一致。
85
+ * 整条重跑只对读取类命令安全,注册表只给它们套上(index.ts);写操作重跑会把已经做完的步骤再做一遍。
86
+ */
87
+ export function retryWithUifid(handler) {
88
+ return (ctx) => {
89
+ const result = handler(ctx);
90
+ // 长连接的 handler 同步返回 AsyncIterable,原样交回
91
+ if (!(result instanceof Promise))
92
+ return result;
93
+ return result.catch(async (err) => {
94
+ const reason = err instanceof SpecificAIError ? err.detail?.reason : undefined;
95
+ if (reason !== 'uifid')
96
+ throw err;
97
+ const d = await douyin(ctx);
98
+ if (d.cookie('UIFID'))
99
+ throw err;
100
+ ctx.log.info('凭证里缺 UIFID,先请求一次推荐流取回,然后重试');
101
+ await api.feed(d);
102
+ if (!d.cookie('UIFID'))
103
+ throw err;
104
+ return handler(ctx);
105
+ });
106
+ };
107
+ }
108
+ export async function userLikes(ctx) {
109
+ const d = await douyin(ctx);
110
+ const secUid = await resolveUser(d, ctx.args.user ?? 'me');
111
+ const body = check(ctx, await api.userFavorite(d, secUid, cursor(ctx)));
112
+ return paged((body.aweme_list ?? []).map(norm.aweme), body.max_cursor, more(body.has_more));
113
+ }
114
+ async function relations(ctx, kind) {
115
+ const d = await douyin(ctx);
116
+ const u = await profile(d, ctx.args.user ?? 'me');
117
+ const maxTime = ctx.cursor ?? (kind === 'followers' ? '' : '0');
118
+ const body = check(ctx, await (kind === 'followers' ? api.followers(d, n.id(u.uid), u.sec_uid, maxTime) : api.following(d, n.id(u.uid), u.sec_uid, maxTime)));
119
+ const list = (body[kind === 'followers' ? 'followers' : 'followings'] ?? []).map(norm.user);
120
+ return paged(list, body.min_time, more(body.has_more));
121
+ }
122
+ export const userFollowers = (ctx) => relations(ctx, 'followers');
123
+ export const userFollowing = (ctx) => relations(ctx, 'following');
124
+ // ================================================================ item
125
+ async function detail(d, input) {
126
+ const id = await resolveItem(d, input);
127
+ const body = check(d.ctx, await api.workInfo(d, id));
128
+ if (!body.aweme_detail)
129
+ throw new SpecificAIError('UPSTREAM', `作品不存在或不可见:${id}`, { detail: { status_code: body.status_code, filter: body.filter_detail ?? null } });
130
+ return body.aweme_detail;
131
+ }
132
+ export async function itemGet(ctx) {
133
+ const d = await douyin(ctx);
134
+ return norm.aweme(await detail(d, ctx.args.item));
135
+ }
136
+ /** 搜索筛选的标准取值 → 上游参数(AGENTS 4.9 / 4.7)。 */
137
+ const SEARCH_SORT = { general: '0', popular: '1', latest: '2' };
138
+ const SEARCH_TIME = { all: '0', day: '1', week: '7', half_year: '180' };
139
+ const SEARCH_LENGTH = { all: '', short: '0-1', medium: '1-5', long: '5-10000' };
140
+ const SEARCH_RANGE = { seen: '1', unseen: '2', following: '3' };
141
+ const VIDEO_PAGE = 16;
142
+ /**
143
+ * `item search` 的筛选:综合频道(上游 search_general_work)把筛选值放进 filter_selected,`--type image` 是它的 content_type 2(图文);
144
+ * `--type video` 走视频频道(search_video_work)。
145
+ */
146
+ export function searchFilters(o, video) {
147
+ const range = SEARCH_RANGE[o.range] ?? '';
148
+ return {
149
+ sortType: SEARCH_SORT[o.sort ?? 'general'] ?? '0',
150
+ publishTime: SEARCH_TIME[o.time ?? 'all'] ?? '0',
151
+ filterDuration: SEARCH_LENGTH[o.length ?? 'all'] ?? '',
152
+ searchRange: video ? range || '0' : range,
153
+ contentType: video ? undefined : o.type === 'image' ? '2' : '',
154
+ };
155
+ }
156
+ export async function itemSearch(ctx) {
157
+ const d = await douyin(ctx);
158
+ const keyword = ctx.args.keyword;
159
+ if (ctx.options.type === 'video') {
160
+ // 视频频道:cursor 是「偏移,上一页的 X-Tt-Logid」,翻页时 search_id 带上它(上游 search_some_video_work)
161
+ const [offset = '0', searchId = ''] = (ctx.cursor ?? '0').split(',');
162
+ const [next, raw] = await api.searchVideo(d, keyword, offset, String(VIDEO_PAGE), searchFilters(ctx.options, true), searchId);
163
+ const body = check(ctx, raw);
164
+ const data = body.data ?? [];
165
+ const list = data.map((x) => x.aweme_info ?? x).filter((x) => x?.aweme_id).map(norm.aweme);
166
+ return paged(list, `${Number(offset) + VIDEO_PAGE},${next}`, more(body.has_more) && data.length > 0);
167
+ }
168
+ const offset = cursor(ctx);
169
+ const body = check(ctx, await api.searchGeneral(d, keyword, offset, '', searchFilters(ctx.options, false)));
170
+ const data = body.data ?? [];
171
+ const list = data.filter((x) => x.aweme_info).map((x) => norm.aweme(x.aweme_info));
172
+ return paged(list, Number(offset) + data.length, more(body.has_more) && data.length > 0);
173
+ }
174
+ /** 自己的作品(创作者中心的作品预览 work_list),带审核 / 私密 / 定时状态。 */
175
+ export async function itemList(ctx) {
176
+ const d = await douyin(ctx);
177
+ d.requireLogin();
178
+ await creator.bootstrap(d);
179
+ const body = check(ctx, await creator.workList(d, cursor(ctx)));
180
+ return paged((body.aweme_list ?? []).map(norm.work), body.max_cursor, more(body.has_more));
181
+ }
182
+ export async function itemMedia(ctx) {
183
+ const d = await douyin(ctx);
184
+ return norm.awemeMedia(await detail(d, ctx.args.item));
185
+ }
186
+ export async function itemDownload(ctx) {
187
+ const d = await douyin(ctx);
188
+ const v = await detail(d, ctx.args.item);
189
+ return downloadMedia(ctx, d.http, n.id(v.aweme_id), norm.awemeMedia(v), {
190
+ headers: [
191
+ ['user-agent', PROFILE.ua],
192
+ ['referer', `${WWW}/`],
193
+ ],
194
+ ext: (m) => (m.type === 'image' ? 'jpg' : 'mp4'),
195
+ });
196
+ }
197
+ // ================================================================ comment
198
+ export async function commentList(ctx) {
199
+ const d = await douyin(ctx);
200
+ const input = ctx.args.item;
201
+ if (ctx.options.product || /jinritemai|haohuo|product_id=|promotion_id=/.test(input))
202
+ return productComments(ctx, d, input);
203
+ if (ctx.options.label != null)
204
+ throw new SpecificAIError('USAGE', '--label 只用于商品评价', { hint: 'specific-ai douyin comment list <商品 url> --product --label <标签>' });
205
+ const id = await resolveItem(d, input);
206
+ const body = check(ctx, await api.comments(d, id, cursor(ctx)));
207
+ const list = (body.comments ?? []).map((c) => norm.comment(c, id));
208
+ return paged(list, body.cursor, more(body.has_more));
209
+ }
210
+ /**
211
+ * 商品评价。带 `--label` 时游标是「服务端游标,tag_id」:标签只在第一页解析一次(comment/counter),翻页时从游标里取。
212
+ */
213
+ async function productComments(ctx, d, input) {
214
+ const p = resolveProduct(input);
215
+ if (!p.productId || !p.shopId) {
216
+ throw new SpecificAIError('USAGE', '商品评价需要 product_id 和 shop_id', { hint: '传 specific-ai douyin live products 输出的商品 url(带 id 和 shop_id 参数)' });
217
+ }
218
+ const labeled = ctx.options.label != null;
219
+ let [offset = '0', tagId = ''] = labeled ? (ctx.cursor ?? '').split(',') : [cursor(ctx)];
220
+ if (labeled && !tagId)
221
+ tagId = await productLabel(ctx, d, p.productId, p.shopId, String(ctx.options.label));
222
+ offset ||= '0';
223
+ const body = check(ctx, await api.productComments(d, p.productId, p.shopId, offset, '10', '0', tagId));
224
+ const data = body.data ?? {};
225
+ const list = (data.Comments ?? data.comments ?? []).map((c) => norm.productComment(c, p.promotionId));
226
+ const next = data.Cursor ?? data.cursor ?? Number(offset) + list.length;
227
+ return paged(list, labeled ? `${next},${tagId}` : next, Boolean(data.HasMore ?? data.has_more));
228
+ }
229
+ /** `--label`:先取评价的分类计数(comment/counter),按名字或 id 找到 tag_id。 */
230
+ async function productLabel(ctx, d, productId, shopId, label) {
231
+ const labels = norm.commentLabels(check(ctx, await api.productCommentCounter(d, productId, shopId)));
232
+ const hit = labels.find((x) => x.id === label || x.name === label);
233
+ if (hit)
234
+ return hit.id;
235
+ if (/^\d+$/.test(label))
236
+ return label;
237
+ throw new SpecificAIError('USAGE', `没有这个评价标签:${label}`, { hint: labels.length ? `可选:${labels.map((x) => x.name).join('、')}` : '这个商品没有返回评价标签' });
238
+ }
239
+ export async function commentReplies(ctx) {
240
+ const d = await douyin(ctx);
241
+ const id = await resolveItem(d, ctx.args.item);
242
+ const body = check(ctx, await api.replies(d, id, ctx.args.comment, cursor(ctx), '10'));
243
+ const list = (body.comments ?? []).map((c) => norm.comment(c, id));
244
+ return paged(list, body.cursor, more(body.has_more));
245
+ }
246
+ // ================================================================ feed / notice / folder
247
+ export async function feedList(ctx) {
248
+ const kind = ctx.options.kind ?? 'recommend';
249
+ if (kind !== 'recommend')
250
+ throw new SpecificAIError('NOT_IMPLEMENTED', `douyin 的 feed list --kind ${kind} 尚未实现`, { detail: { upstream: 'none' } });
251
+ const d = await douyin(ctx);
252
+ const index = cursor(ctx, '2');
253
+ const body = check(ctx, await api.feed(d, '20', index));
254
+ // 推荐流现在放在 cards[].aweme(JSON 串)里,老的 aweme_list 兜底
255
+ const cards = (body.cards ?? []).map((c) => (typeof c.aweme === 'string' ? jsonLoads(c.aweme) : c.aweme)).filter((a) => a?.aweme_id);
256
+ const list = [...(body.aweme_list ?? []), ...cards].map(norm.aweme);
257
+ return paged(list, Number(index) + 1, body.has_more == null ? list.length > 0 : more(body.has_more));
258
+ }
259
+ /** 通知分组(上游 get_notice_list 的 notice_group);不带时用上游默认的 960。 */
260
+ const NOTICE_GROUP = { all: '700', fans: '401', mention: '601', comment: '2', like: '3', danmaku: '520' };
261
+ export async function noticeList(ctx) {
262
+ const d = await douyin(ctx);
263
+ const [minTime = '0', maxTime = '0'] = (ctx.cursor ?? '0,0').split(',');
264
+ const group = NOTICE_GROUP[ctx.options.group ?? ''] ?? '960';
265
+ const body = check(ctx, await api.notices(d, minTime, maxTime, '10', group));
266
+ const list = (body.notice_list_v2 ?? body.notice_list ?? []).map(norm.notice);
267
+ return paged(list, `${body.min_time ?? 0},${body.max_time ?? 0}`, more(body.has_more));
268
+ }
269
+ export async function folderList(ctx) {
270
+ const d = await douyin(ctx);
271
+ if (ctx.args.user && ctx.args.user !== 'me' && (await resolveUser(d, ctx.args.user)) !== (await resolveUser(d, 'me'))) {
272
+ throw new SpecificAIError('UNSUPPORTED', '抖音只能查看自己的收藏夹', { hint: 'specific-ai douyin folder list' });
273
+ }
274
+ const body = check(ctx, await api.collectList(d, cursor(ctx)));
275
+ const raw = body.collects_list ?? [];
276
+ const list = raw.map((f) => n.folder({ id: folderId(f), name: String(f.collects_name ?? ''), count: n.count(f.total_number ?? f.item_num) }, f));
277
+ return paged(list, body.cursor, more(body.has_more) && raw.length > 0);
278
+ }
279
+ const folderId = (f) => n.id(f.collects_id_str ?? f.collects_id);
280
+ /** 收藏夹最多翻几页(每页 20 个)找 `--folder`,防服务端游标不前进时死循环。 */
281
+ const FOLDER_PAGES = 50;
282
+ // ================================================================ product
283
+ export async function productGet(ctx) {
284
+ const d = await douyin(ctx);
285
+ const p = resolveProduct(ctx.args.product);
286
+ const body = check(ctx, await api.productDetail(d, 'https://live.douyin.com/', p.promotionId));
287
+ return norm.productDetail(body, p.promotionId);
288
+ }
289
+ // ================================================================ live
290
+ async function room(d, input) {
291
+ const webRid = await resolveRoom(d, input);
292
+ const info = await api.liveInfo(d, webRid);
293
+ if (!info)
294
+ throw live.roomNotFound(webRid);
295
+ return { webRid, ...info };
296
+ }
297
+ export async function liveGet(ctx) {
298
+ const d = await douyin(ctx);
299
+ const webRid = await resolveRoom(d, ctx.args.room);
300
+ const body = check(ctx, await api.liveRoomEnter(d, webRid));
301
+ return norm.roomEnter(body, webRid);
302
+ }
303
+ export async function liveSearch(ctx) {
304
+ const d = await douyin(ctx);
305
+ const offset = cursor(ctx);
306
+ const body = check(ctx, await api.searchLive(d, ctx.args.keyword, offset));
307
+ const data = body.data ?? [];
308
+ const list = data.map(norm.searchLive).filter(Boolean);
309
+ // 同 user search:偏移是自己算的,空页时停下
310
+ return paged(list, Number(offset) + 15, more(body.has_more) && data.length > 0);
311
+ }
312
+ /** 直播间榜单:默认贡献榜(get_live_contribution_rank),`--ranking thousand` 为千票榜(get_live_thousand_ticket_rank)。 */
313
+ export async function liveRank(ctx) {
314
+ const d = await douyin(ctx);
315
+ const r = await room(d, ctx.args.room);
316
+ if (ctx.options.ranking === 'thousand')
317
+ return norm.rankRows(check(ctx, await api.liveThousandRank(d, r.room_id, r.webRid)));
318
+ const body = check(ctx, await api.liveRank(d, r.room_id, r.anchor_id, r.sec_uid, r.webRid));
319
+ return (body.data?.ranks ?? []).map(norm.rankRow);
320
+ }
321
+ /** 最近的弹幕:进房时 im/fetch 带回的历史消息(need_persist_msg_count=15),只取聊天。 */
322
+ export async function liveHistory(ctx) {
323
+ const d = await douyin(ctx);
324
+ const r = await room(d, ctx.args.room);
325
+ const events = live.fetchEvents(await api.webcastFetch(d, r.user_id, r.room_id, norm.liveUrl(r.webRid)));
326
+ return events.filter((e) => e.type === 'chat');
327
+ }
328
+ /** 拉流地址:房间资料(room/web/enter)里的 stream_url。 */
329
+ export async function liveMedia(ctx) {
330
+ const d = await douyin(ctx);
331
+ const webRid = await resolveRoom(d, ctx.args.room);
332
+ return norm.liveStreams(check(ctx, await api.liveRoomEnter(d, webRid)));
333
+ }
334
+ export async function liveProducts(ctx) {
335
+ const d = await douyin(ctx);
336
+ const r = await room(d, ctx.args.room);
337
+ const body = check(ctx, await api.liveProduction(d, norm.liveUrl(r.webRid), r.room_id, r.anchor_id));
338
+ return (body.promotions ?? []).map(norm.promotion);
339
+ }
340
+ // ================================================================ 登录
341
+ /** 设备绑定的 dtrait 素材:内层 blob、设备档案、成品头(上游 DY_DTRAIT_BLOB / DY_DTRAIT_PROFILE / DY_SESSION_DTRAIT)。 */
342
+ const DTRAIT_KEYS = ['dtrait_blob', 'dtrait_profile', 'session_dtrait'];
343
+ /**
344
+ * 登录用的临时会话:凭证是新建的;设备绑定的 dtrait 素材从同名账号继承(上游 save_credential 把它和 cookie 一起落盘)。
345
+ */
346
+ function loginSession(ctx, method) {
347
+ const credential = freshCredential(ctx, method);
348
+ for (const key of DTRAIT_KEYS)
349
+ if (ctx.credential.device[key])
350
+ credential.device[key] = ctx.credential.device[key];
351
+ return new Douyin({ ...ctx, credential });
352
+ }
353
+ /** 登录后校验:query/user 取 uid,创作者中心取 sec_uid 与昵称。 */
354
+ async function completeLogin(ctx, d) {
355
+ d.deleteCookie('msToken');
356
+ let uid;
357
+ try {
358
+ uid = await api.myUid(d);
359
+ }
360
+ catch (err) {
361
+ // 只有 query/user 表明没登录才是 cookie 无效;风控、网络错误原样抛出
362
+ if (isAuthError(err))
363
+ throw new SpecificAIError('AUTH_REQUIRED', '登录没有成功:cookie 无效或已过期');
364
+ throw err;
365
+ }
366
+ d.tokens.uid = uid;
367
+ const { secUid, user } = await api.mySecUid(d);
368
+ return finishLogin(ctx, d.credential, norm.ref(secUid, user?.nickname));
369
+ }
370
+ /**
371
+ * `--cookie` 除了 cookie 串 / 浏览器导出的 JSON 数组,还接受一个 JSON 对象,用来一并导入写操作需要的
372
+ * bd-ticket-guard 素材(上游 DouyinAuth.from_cookie 的参数),以及可选的 dtrait 素材:
373
+ * `{"cookie": "...", "ticket": "...", "ts_sign": "...", "client_cert": "...", "private_key": "-----BEGIN ...", "dtrait_blob": "..."}`
374
+ *
375
+ * dtrait 素材不给时,按随包的设备档案现算。想用自己浏览器的设备指纹时再导入:`dtrait_blob` 是抓到的内层 blob,
376
+ * `dtrait_profile` 是设备档案对象(结构同 static/douyin/dtrait_profile.json),两者都给时 dtrait_blob 优先。
377
+ * JSON 里出现任何一个 dtrait 键时这一组整体替换,值为 null 表示清掉;一个都没有时沿用同名账号已有的。
378
+ */
379
+ function cookieLogin(ctx) {
380
+ const input = String(ctx.options.cookie ?? '').trim();
381
+ let extra = {};
382
+ let cookieInput = input;
383
+ if (input.startsWith('{')) {
384
+ try {
385
+ extra = JSON.parse(input);
386
+ }
387
+ catch {
388
+ throw new SpecificAIError('USAGE', '--cookie 的 JSON 对象解析失败');
389
+ }
390
+ const c = extra.cookie ?? extra.cookies;
391
+ cookieInput = typeof c === 'string' ? c : JSON.stringify(c ?? '');
392
+ }
393
+ const credential = cookieCredential({ ...ctx, options: { ...ctx.options, cookie: cookieInput } }, '.douyin.com');
394
+ for (const c of credential.scopes.main.cookies)
395
+ c.domain = WWW_ONLY.has(c.name) ? 'www.douyin.com' : '.douyin.com';
396
+ const tokens = credential.scopes.main.tokens;
397
+ for (const key of ['ticket', 'ts_sign', 'client_cert'])
398
+ if (typeof extra[key] === 'string')
399
+ tokens[key] = extra[key];
400
+ if (typeof extra.private_key === 'string')
401
+ credential.device.private_key = extra.private_key;
402
+ const given = DTRAIT_KEYS.some((k) => k in extra);
403
+ for (const key of DTRAIT_KEYS) {
404
+ const v = given ? extra[key] : ctx.credential.device[key];
405
+ if (v == null || v === '')
406
+ continue;
407
+ if (given && key === 'dtrait_profile')
408
+ buildBlob(v); // 导入时就校验档案(上游 load_dtrait_profile),坏档案报 USAGE
409
+ else if (given && typeof v !== 'string')
410
+ throw new SpecificAIError('USAGE', `--cookie 的 ${key} 要是字符串`);
411
+ credential.device[key] = v;
412
+ }
413
+ return new Douyin({ ...ctx, credential });
414
+ }
415
+ export async function authLogin(ctx) {
416
+ const method = ctx.options.method;
417
+ if (ctx.options.sso && method !== 'sms')
418
+ throw new SpecificAIError('USAGE', '--sso 只用于 --method sms', { hint: 'specific-ai douyin auth login --method sms --sso' });
419
+ if (method === 'cookie') {
420
+ const d = cookieLogin(ctx);
421
+ await d.init();
422
+ // 粘贴的 cookie 常带 __ac_nonce 却没有 __ac_signature:执行一次页面 acrawler 补上(上游 from_cookie,非严格)
423
+ if (d.cookie('__ac_nonce') && !d.cookie('__ac_signature'))
424
+ await new Passport(d).applyAcSignature();
425
+ return completeLogin(ctx, d);
426
+ }
427
+ if (method === 'qrcode') {
428
+ const d = loginSession(ctx, 'qrcode');
429
+ await new Passport(d).qrcodeLogin((url) => showQrcode(ctx, url, '请用抖音 App 扫码并确认').then(() => { }));
430
+ return completeLogin(ctx, d);
431
+ }
432
+ if (method === 'sms') {
433
+ const d = loginSession(ctx, 'sms');
434
+ const p = new Passport(d);
435
+ return smsLogin(ctx, {
436
+ send: async (phone) => {
437
+ // --sso:改走 login.douyin.com 页的 SSO 链(上游 DY_PHONE_LOGIN_PROFILE=sso,是显式开关,不是自动退路)
438
+ if (ctx.options.sso) {
439
+ await p.bootstrapSso();
440
+ await p.sendSmsCodeSso(phone);
441
+ }
442
+ else {
443
+ await p.bootstrap(true);
444
+ await p.sendSmsCode(phone);
445
+ }
446
+ return { phone, session: p.snapshot() };
447
+ },
448
+ verify: async (state, code) => {
449
+ if (!p.smsSentAt)
450
+ p.restore(state.session);
451
+ if (p.sso)
452
+ await p.phoneLoginSso(String(state.phone), code);
453
+ else
454
+ await p.phoneLogin(String(state.phone), code);
455
+ return completeLogin(ctx, d);
456
+ },
457
+ });
458
+ }
459
+ throw new SpecificAIError('USAGE', `不支持的登录方式:${method}`);
460
+ }
461
+ // ================================================================ 写操作
462
+ async function diggItem(ctx, type) {
463
+ const d = await douyin(ctx);
464
+ d.requireLogin();
465
+ const id = await resolveItem(d, ctx.args.item);
466
+ check(ctx, await api.digg(d, id, type));
467
+ return { id };
468
+ }
469
+ export const itemLike = (ctx) => diggItem(ctx, '1');
470
+ export const itemUnlike = (ctx) => diggItem(ctx, '0');
471
+ /** `--folder`:收藏夹 ID 或名字 → (id, name)(移动接口两个都要)。逐页找,每页里 ID 优先于名字。 */
472
+ async function findFolder(ctx, d, input) {
473
+ let at = '0';
474
+ for (let page = 0; page < FOLDER_PAGES; page++) {
475
+ const body = check(ctx, await api.collectList(d, at));
476
+ const list = body.collects_list ?? [];
477
+ const f = list.find((x) => folderId(x) === input) ?? list.find((x) => x.collects_name === input);
478
+ if (f)
479
+ return { id: folderId(f), name: String(f.collects_name ?? '') };
480
+ const next = body.cursor == null ? '' : String(body.cursor);
481
+ if (!more(body.has_more) || !list.length || !next || next === at)
482
+ break;
483
+ at = next;
484
+ }
485
+ throw new SpecificAIError('USAGE', `没有这个收藏夹:${input}`, { hint: 'specific-ai douyin folder list --all' });
486
+ }
487
+ /**
488
+ * 收藏 / 取消收藏。带 `--folder` 时:收藏后移进这个收藏夹(上游 move_collect_aweme,要求先收藏);
489
+ * 取消时只从这个收藏夹移出,仍保留收藏(remove_collect_aweme)。
490
+ */
491
+ async function collectItem(ctx, action) {
492
+ const d = await douyin(ctx);
493
+ d.requireLogin();
494
+ const id = await resolveItem(d, ctx.args.item);
495
+ const folder = ctx.options.folder;
496
+ if (!folder) {
497
+ check(ctx, await api.collect(d, id, action));
498
+ return { id };
499
+ }
500
+ const f = await findFolder(ctx, d, folder);
501
+ if (action === '1') {
502
+ check(ctx, await api.collect(d, id, '1'));
503
+ check(ctx, await api.collectMove(d, id, f.name, f.id));
504
+ }
505
+ else
506
+ check(ctx, await api.collectRemove(d, id, f.name, f.id));
507
+ return { id };
508
+ }
509
+ export const itemCollect = (ctx) => collectItem(ctx, '1');
510
+ export const itemUncollect = (ctx) => collectItem(ctx, '0');
511
+ /** 评论发布前的安全素材检查(上游 publish_comment 的前置条件)。 */
512
+ function requireCommentSecurity(d) {
513
+ if (!d.ticketMatchesSession())
514
+ throw authError(d.ctx, '发评论需要与 cookie 同一次登录的 ticket / ts_sign,请用扫码登录或导入配套凭证');
515
+ // 导入的成品头 session_dtrait 不算:它不能按当前 path 重算
516
+ if (!d.dtraitBlob()) {
517
+ throw new SpecificAIError('AUTH_REQUIRED', `发评论需要可按 path 重算的 dtrait 设备素材(${DTRAIT_BROKEN}),导入的成品 session_dtrait 不能用`, { hint: DTRAIT_HINT });
518
+ }
519
+ }
520
+ export async function commentAdd(ctx) {
521
+ const d = await douyin(ctx);
522
+ d.requireLogin();
523
+ requireCommentSecurity(d);
524
+ const id = await resolveItem(d, ctx.args.item);
525
+ const replyTo = ctx.options.replyTo ?? '';
526
+ const body = check(ctx, await api.publishComment(d, id, ctx.args.text, replyTo));
527
+ const c = body.comment;
528
+ return c ? norm.comment(c, id) : n.comment({ id: n.id(body.comment_id ?? ''), item_id: id, text: ctx.args.text, parent_id: replyTo || null }, body);
529
+ }
530
+ const VISIBILITY = { public: 0, private: 1, friends: 2 };
531
+ /** 正文:--text 后面接 #话题(--tag / --topic)和 @用户(--mention),按上游说明作为纯文本写进描述。 */
532
+ export function publishDesc(o) {
533
+ const tags = [...(o.tag ?? []), ...(o.topic ?? [])].map((x) => `#${x.replace(/^#/, '')}`);
534
+ const mentions = (o.mention ?? []).map((x) => `@${x.replace(/^@/, '')}`);
535
+ return [o.text ?? '', ...tags, ...mentions].filter(Boolean).join(' ');
536
+ }
537
+ /** 发布选项 → creator.PublishOptions(图文封面在 itemPublish 里另算)。 */
538
+ export function publishOptions(o) {
539
+ return {
540
+ title: o.title,
541
+ desc: publishDesc(o),
542
+ visibility: VISIBILITY[o.visibility ?? 'public'],
543
+ timing: o.schedule ? Math.floor(Date.parse(o.schedule) / 1000) : undefined,
544
+ allowDownload: !o.noDownload,
545
+ poi: o.poi != null ? { poi_id: String(o.poi), poi_name: String(o.poiName ?? '') } : undefined,
546
+ mixId: o.series,
547
+ hotSpot: o.hotspot != null ? { word: String(o.hotspot) } : undefined,
548
+ };
549
+ }
550
+ export async function itemPublish(ctx) {
551
+ const o = ctx.options;
552
+ const images = o.image ?? [];
553
+ if (!o.video && !images.length)
554
+ throw new SpecificAIError('USAGE', '发布需要 --image(图文)或 --video(视频)', { hint: 'specific-ai douyin item publish --video <文件> --title <标题> --text <描述>' });
555
+ if (o.video && images.length)
556
+ throw new SpecificAIError('USAGE', '--image 与 --video 只能二选一');
557
+ if (o.poiName != null && o.poi == null)
558
+ throw new SpecificAIError('USAGE', '--poi-name 要和 --poi <地点 id> 一起用');
559
+ const cover = o.cover;
560
+ const coverIndex = cover != null && !o.video ? images.indexOf(cover) : -1;
561
+ if (cover != null && !o.video && coverIndex < 0 && !cover.startsWith('tos-')) {
562
+ throw new SpecificAIError('USAGE', '图文的 --cover 必须是 --image 里的一张(或已上传的 tos- uri)');
563
+ }
564
+ const d = await douyin(ctx);
565
+ d.requireLogin();
566
+ const options = publishOptions(o);
567
+ let body;
568
+ if (o.video) {
569
+ const coverFile = cover == null ? null : cover.startsWith('tos-') ? cover : await readMedia(d.http, cover);
570
+ body = await creator.postVideo(d, await readMedia(d.http, o.video), coverFile, options);
571
+ }
572
+ else {
573
+ if (coverIndex >= 0)
574
+ options.coverIndex = coverIndex;
575
+ else if (cover)
576
+ options.coverUri = cover;
577
+ const files = [];
578
+ for (const img of images)
579
+ files.push(await readMedia(d.http, img));
580
+ body = await creator.postImages(d, files, options);
581
+ }
582
+ check(ctx, body);
583
+ if (!body.item_id)
584
+ throw new SpecificAIError('UPSTREAM', `发布失败:${body.status_msg ?? ''}`, { detail: { status_code: body.status_code ?? null } });
585
+ const id = n.id(body.item_id);
586
+ return n.item({ id, kind: o.video ? 'video' : 'image', url: norm.itemUrl(id, !o.video), title: n.str(o.title), text: n.str(options.desc), status: 'reviewing' }, body);
587
+ }
588
+ export async function mediaUpload(ctx) {
589
+ const d = await douyin(ctx);
590
+ d.requireLogin();
591
+ const file = await readMedia(d.http, ctx.args.file);
592
+ await creator.bootstrap(d);
593
+ if (file.contentType.startsWith('video/')) {
594
+ const sts = await creator.uploadAuth(d);
595
+ const v = await creator.uploadVideo(d, sts, file, await api.myUid(d).catch(() => ''));
596
+ // VOD 只给 vid,没有可访问的地址;Media.url 在 schemas 里不能为 null,这里留空(id 就是 vid)
597
+ return n.media({ id: v.vid, type: 'video', url: null, width: v.width || null, height: v.height || null, duration: v.duration || null }, v.raw);
598
+ }
599
+ const sts = await creator.uploadAuth(d, creator.POST_IMAGE_REFERER);
600
+ const info = await creator.uploadImage(d, sts, file, '');
601
+ return n.media({ id: info.uri, type: 'image', url: await creator.mediaUrl(d, info.uri), width: info.width || null, height: info.height || null }, info);
602
+ }
603
+ // ================================================================ 直播:发弹幕、点赞、监听
604
+ export async function liveSend(ctx) {
605
+ if (ctx.options.gift)
606
+ throw new SpecificAIError('NOT_IMPLEMENTED', 'douyin 的 live send --gift 尚未实现', { detail: { upstream: 'none' } });
607
+ const d = await douyin(ctx);
608
+ d.requireLogin();
609
+ const r = await room(d, ctx.args.room);
610
+ check(ctx, await api.liveChat(d, r.room_id, ctx.args.text, r.webRid));
611
+ return { id: r.webRid };
612
+ }
613
+ export async function liveLike(ctx) {
614
+ const d = await douyin(ctx);
615
+ d.requireLogin();
616
+ const r = await room(d, ctx.args.room);
617
+ check(ctx, await api.liveLike(d, r.room_id, String(ctx.options.count ?? 1)));
618
+ return { id: r.webRid };
619
+ }
620
+ export function liveListen(ctx) {
621
+ return (async function* () {
622
+ const d = await douyin(ctx);
623
+ const webRid = await resolveRoom(d, ctx.args.room);
624
+ yield* live.listenLive(ctx, d, webRid);
625
+ })();
626
+ }
627
+ // ================================================================ 私信
628
+ /**
629
+ * 会话 ID 形如 0:1:<uid>:<uid>:取另一方的 uid 重新建会话,拿到 short_id 与 ticket。
630
+ * `--to` 除了用户(sec_uid / 主页 / 分享链接)也接受数字 uid:`msg listen` 输出的 from.id 就是它,直接建会话。
631
+ */
632
+ async function conversationFor(ctx, d) {
633
+ const o = ctx.options;
634
+ if (o.item)
635
+ throw new SpecificAIError('UNSUPPORTED', '抖音没有商品客服私信,--item 不适用', { hint: '用 --to <用户>' });
636
+ if (o.conversation) {
637
+ const parts = String(o.conversation).split(':');
638
+ const me = await d.uid();
639
+ const peer = parts.length === 4 ? parts.slice(2).find((x) => x !== me) : undefined;
640
+ if (!peer)
641
+ throw new SpecificAIError('USAGE', `无法识别的会话:${o.conversation}`, { hint: '单聊会话 ID 形如 0:1:<uid>:<uid>' });
642
+ return im.createConversation(d, peer);
643
+ }
644
+ const to = String(o.to).trim();
645
+ if (/^\d+$/.test(to))
646
+ return im.createConversation(d, to);
647
+ const u = await profile(d, to);
648
+ return im.createConversation(d, n.id(u.uid));
649
+ }
650
+ /** `--share` 的卡片:作品(视频 type 8 / 图文 type 77,uid 是自己)、用户名片(type 25)、网页(type 26)。 */
651
+ async function shareCard(d, input) {
652
+ const target = await resolveShare(d, input);
653
+ if (target.kind === 'web')
654
+ return [im.IM_SHARE_WEB, im.shareWebContent(target.url)];
655
+ if (target.kind === 'user') {
656
+ const u = await profile(d, target.secUid);
657
+ return [im.IM_SHARE_USER, im.userCardContent({ uid: n.id(u.uid), secUid: n.id(u.sec_uid), name: String(u.nickname ?? ''), avatar: u.avatar_larger ?? u.avatar_thumb ?? '' })];
658
+ }
659
+ const v = await detail(d, target.id);
660
+ const uid = await d.uid().catch(() => '');
661
+ return norm.isImage(v) ? [im.IM_SHARE_PHOTOS, im.sharePhotosContent(v, uid)] : [im.IM_SHARE_AWEME, im.shareAwemeContent(v, uid)];
662
+ }
663
+ /**
664
+ * 私信:先建会话,再把每条消息准备好(上传图片 / 视频 / 文件、取分享卡片),最后逐条发送。
665
+ * 准备阶段出错(上传失败、分享的作品不可见、撞上风控)时一条都还没发出,不会只发出一半。返回最后一条。
666
+ */
667
+ export async function msgSend(ctx) {
668
+ const o = ctx.options;
669
+ const images = o.image ?? [];
670
+ if (o.video && !images.length) {
671
+ throw new SpecificAIError('USAGE', '发视频私信需要封面:用 --image 给出一张封面图(与 --video 一起时不单独发送)', { hint: 'specific-ai douyin msg send --to <用户> --video <视频> --image <封面>' });
672
+ }
673
+ const d = await douyin(ctx);
674
+ d.requireLogin();
675
+ if (!d.privateKey)
676
+ throw authError(ctx, '私信需要扫码登录时生成的 bd-ticket-guard 私钥');
677
+ const conv = await conversationFor(ctx, d);
678
+ const outbox = [];
679
+ if (ctx.args.text)
680
+ outbox.push({ messageType: im.IM_TEXT, content: im.textContent(ctx.args.text), type: 'text', text: ctx.args.text });
681
+ if (o.video) {
682
+ const content = await im.uploadVideo(d, await readMedia(d.http, o.video), await readMedia(d.http, images[0]));
683
+ outbox.push({ messageType: im.IM_STORY_VIDEO, content, type: 'video', text: null });
684
+ }
685
+ else {
686
+ for (const img of images)
687
+ outbox.push({ messageType: im.IM_STORY_PICTURE, content: await im.uploadImage(d, await readMedia(d.http, img)), type: 'image', text: null });
688
+ }
689
+ if (o.file) {
690
+ const file = await readMedia(d.http, o.file);
691
+ // 上游 _source_name:本地文件取文件名,URL 与字节一律叫 file.bin
692
+ if (/^https?:\/\//i.test(o.file))
693
+ file.filename = 'file.bin';
694
+ outbox.push({ messageType: im.IM_FILE, content: await im.uploadFile(d, file), type: 'other', text: file.filename });
695
+ }
696
+ if (o.share) {
697
+ const [messageType, content] = await shareCard(d, String(o.share));
698
+ outbox.push({ messageType, content, type: 'card', text: null });
699
+ }
700
+ let id = '';
701
+ for (const m of outbox)
702
+ id = await im.sendMessage(d, conv, m.messageType, m.content);
703
+ const last = outbox.at(-1);
704
+ return n.message({ id, conversation_id: conv.conversationId, from: d.credential.user ?? null, type: last.type, text: last.text, created_at: n.time(rand.now()) });
705
+ }
706
+ export function msgListen(ctx) {
707
+ return (async function* () {
708
+ const d = await douyin(ctx);
709
+ d.requireLogin();
710
+ yield* live.listenMessages(ctx, d);
711
+ })();
712
+ }