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,1046 @@
1
+ import { SpecificAIError } from '../../../core/errors.js';
2
+ import { imageSize } from '../../../core/image.js';
3
+ import { downloadMedia, readMedia } from '../../../core/files.js';
4
+ import { cookieCredential, finishLogin, freshCredential, interactive, loginContext, poll, prompt, showQrcode, smsLogin } from '../../../core/login.js';
5
+ import * as n from '../../../core/normalize.js';
6
+ import { jsonLoads } from '../../../core/py.js';
7
+ import * as rand from '../../../core/rand.js';
8
+ import { reconnecting } from '../../../core/stream.js';
9
+ import { authError, isGuest, paged } from '../../../core/toolkit.js';
10
+ import { GUEST, readCredential } from '../../../core/auth-store.js';
11
+ import * as api from './api.js';
12
+ import { isAuthFailure, Pc } from './client.js';
13
+ import { Creator } from './creator.js';
14
+ import * as capi from './creator-api.js';
15
+ import { CreatorLogin } from './creator-api.js';
16
+ import * as login from './login.js';
17
+ import * as norm from './normalize.js';
18
+ import { COOKIE_DOMAIN, WEB } from './profile.js';
19
+ import * as push from './push.js';
20
+ import { resolveNote, resolveRoom, resolveUser } from './resolve.js';
21
+ // ================================================================ 会话
22
+ /**
23
+ * 主站会话。游客(AGENTS 5.2):凭证里没有访客会话时走一遍匿名设备初始化(a1 / webId / websectiga / gid /
24
+ * 访客 web_session),缓存在 guest.json;访客会话失效时自动重建一次。
25
+ */
26
+ async function pc(ctx) {
27
+ const p = new Pc(ctx);
28
+ if (isGuest(ctx) && !p.isLogin)
29
+ await guestInit(p);
30
+ return p;
31
+ }
32
+ async function guestInit(p) {
33
+ p.ctx.log.info('正在初始化小红书游客设备(首次需要几秒)');
34
+ await login.initAnonymous(p);
35
+ await login.webprofile(p);
36
+ p.ctx.credential.extra.user_id = '';
37
+ p.save();
38
+ }
39
+ /** 跑一条主站命令:访客会话被判定为未登录时重建一次;结束时保存签名状态。 */
40
+ async function run(ctx, fn) {
41
+ const p = await pc(ctx);
42
+ try {
43
+ return await fn(p);
44
+ }
45
+ catch (err) {
46
+ if (!(isGuest(ctx) && err instanceof SpecificAIError && err.code === 'AUTH_REQUIRED' && !ctx.credential.extra.guest_retry))
47
+ throw err;
48
+ ctx.credential.extra.guest_retry = true;
49
+ await guestInit(p);
50
+ return await fn(p);
51
+ }
52
+ finally {
53
+ delete ctx.credential.extra.guest_retry;
54
+ p.save();
55
+ }
56
+ }
57
+ /** 主站已登录(有 web_session),可以用来换取创作者中心的登录态。 */
58
+ function canBridge(ctx) {
59
+ if (isGuest(ctx))
60
+ return false;
61
+ return (ctx.credential.scopes.main?.cookies ?? []).some((c) => c.name === 'web_session' && c.value);
62
+ }
63
+ /**
64
+ * 创作者中心的会话。上游 XHSUnifiedAuth.creator_auth 每个进程都从主站会话重新桥接(XHSCreatorAuth.from_pc_auth);
65
+ * specific-ai 把桥接结果存在 creator scope 里复用:
66
+ * - creator scope 为空、主站已登录:先桥接;
67
+ * - creator scope 已有 cookie,但创作者接口报登录失效:用主站登录态重新桥接一次,再重试。
68
+ * 主站本身也失效时,桥接的 user/info 验收会报 AUTH_EXPIRED,照常报错。
69
+ * 桥接只在全部成功后才改凭证(见 creatorFromPc),失败时 creator scope 保持原样,下次还会再桥接。
70
+ */
71
+ async function creator(ctx, fn) {
72
+ let c;
73
+ const bridged = canBridge(ctx) && !ctx.credential.scopes.creator?.cookies.length;
74
+ if (bridged) {
75
+ ctx.log.info('第一次使用创作者中心:用主站的登录态初始化,不需要再扫码');
76
+ c = await capi.creatorFromPc(ctx);
77
+ }
78
+ else
79
+ c = new Creator(ctx);
80
+ c.requireScope();
81
+ try {
82
+ return await fn(c);
83
+ }
84
+ catch (err) {
85
+ const expired = err instanceof SpecificAIError && (err.code === 'AUTH_REQUIRED' || err.code === 'AUTH_EXPIRED');
86
+ if (!expired || bridged || !canBridge(ctx))
87
+ throw err;
88
+ ctx.log.info('创作者中心的登录态失效了:用主站的登录态重新初始化');
89
+ c = await capi.creatorFromPc(ctx);
90
+ c.requireScope();
91
+ return await fn(c);
92
+ }
93
+ finally {
94
+ c.save();
95
+ }
96
+ }
97
+ const cursorPage = (ctx) => Number(ctx.cursor ?? 1) || 1;
98
+ // ================================================================ auth
99
+ function loginCtx(ctx, method) {
100
+ return { ...ctx, account: GUEST, credential: freshCredential(ctx, method) };
101
+ }
102
+ /** 主站登录成功后:user/me 校验、user 写入凭证、落盘。 */
103
+ async function completeLogin(ctx, p) {
104
+ const me = await login.loginUserMe(p);
105
+ if (!me || me.guest !== false || !me.user_id)
106
+ throw new SpecificAIError('AUTH_REQUIRED', '登录没有成功:拿到的仍是访客会话');
107
+ p.save();
108
+ delete p.credential.extra.user_id;
109
+ return finishLogin(ctx, p.credential, norm.ref(me));
110
+ }
111
+ async function loginQrcode(ctx) {
112
+ const p = new Pc(loginCtx(ctx, 'qrcode'));
113
+ await login.initAnonymous(p);
114
+ const qr = await login.qrcodeCreate(p);
115
+ // 浏览器顺序:建码 → 第一次匿名轮询 → webprofile
116
+ const first = await login.qrcodeStatus(p, qr.qrId, qr.code);
117
+ if (first !== 0)
118
+ throw new SpecificAIError('AUTH_REQUIRED', `二维码状态异常:${first}`);
119
+ await login.webprofile(p);
120
+ await showQrcode(ctx, qr.url, '请用小红书 App 扫码并确认');
121
+ let last = -1;
122
+ await poll(async () => {
123
+ const s = await login.qrcodeStatus(p, qr.qrId, qr.code);
124
+ if (s === 2)
125
+ return true;
126
+ if (s === 3)
127
+ throw new SpecificAIError('AUTH_REQUIRED', '二维码已过期', { hint: '重新执行 specific-ai xhs auth login' });
128
+ if (s === 1 && last !== 1)
129
+ ctx.log.info('已扫码,请在手机上确认');
130
+ last = s;
131
+ return undefined;
132
+ });
133
+ return completeLogin(ctx, p);
134
+ }
135
+ async function loginSms(ctx) {
136
+ const p = new Pc(loginCtx(ctx, 'sms'));
137
+ return smsLogin(ctx, {
138
+ send: async (phone) => {
139
+ await login.initAnonymous(p);
140
+ await login.webprofile(p);
141
+ await login.sendSmsCode(p, phone);
142
+ p.save();
143
+ return { phone, cookies: [...p.jar.cookies], device: p.credential.device.pc, extra: { pc_dsl: p.credential.extra.pc_dsl } };
144
+ },
145
+ verify: async (state, code) => {
146
+ if (!p.shared().a1) {
147
+ p.jar.cookies.splice(0, p.jar.cookies.length, ...state.cookies);
148
+ p.credential.device.pc = state.device;
149
+ Object.assign(p.credential.extra, state.extra);
150
+ const again = new Pc(p.ctx);
151
+ again.webprofileReported = true;
152
+ await login.smsLoginCode(again, String(state.phone), code);
153
+ return completeLogin(ctx, again);
154
+ }
155
+ p.webprofileReported = true;
156
+ await login.smsLoginCode(p, String(state.phone), code);
157
+ return completeLogin(ctx, p);
158
+ },
159
+ });
160
+ }
161
+ async function loginCookie(ctx, scopeName) {
162
+ const credential = cookieCredential(ctx, COOKIE_DOMAIN);
163
+ if (scopeName === 'creator')
164
+ return creatorCookie(ctx, credential);
165
+ const p = new Pc(loginContext(ctx, credential));
166
+ const a1 = p.shared().a1 ?? '';
167
+ if (!a1 || !p.shared().web_session)
168
+ throw new SpecificAIError('USAGE', 'cookie 里需要有 a1 和 web_session', { hint: '从浏览器请求头复制完整的 Cookie' });
169
+ if (a1.length !== 52)
170
+ throw new SpecificAIError('USAGE', `cookie 里的 a1 长度不对(${a1.length},应为 52)`, { hint: '从浏览器请求头复制完整的 Cookie' });
171
+ const me = p.check(await api.userMe(p));
172
+ if (!me?.user_id || me.guest === true)
173
+ throw new SpecificAIError('AUTH_REQUIRED', '登录没有成功:cookie 无效或已过期');
174
+ p.save();
175
+ return finishLogin(ctx, credential, norm.ref(me));
176
+ }
177
+ /** 创作者中心 scope:导入到已有账号(或新账号)的 creator 分区。 */
178
+ async function creatorCookie(ctx, imported) {
179
+ const target = await existingOrFresh(ctx, 'cookie');
180
+ target.scopes.creator = { cookies: imported.scopes.main.cookies, tokens: {} };
181
+ return finishCreator(ctx, target);
182
+ }
183
+ async function existingOrFresh(ctx, method) {
184
+ const account = ctx.account ?? 'default';
185
+ return (await readCredential(ctx.platform.id, ctx.endpoint, account).catch(() => null)) ?? freshCredential(ctx, method);
186
+ }
187
+ async function finishCreator(ctx, credential) {
188
+ const c = new Creator(loginContext(ctx, credential));
189
+ c.requireScope();
190
+ const info = c.check(await capi.userInfo(c));
191
+ if (!info?.userId)
192
+ throw new SpecificAIError('AUTH_REQUIRED', '创作者中心登录没有成功');
193
+ c.save();
194
+ const user = credential.user ?? norm.ref({ user_id: info.userId, nickname: info.userName });
195
+ if (credential.user && credential.user.id !== String(info.userId)) {
196
+ throw new SpecificAIError('USAGE', `创作者中心登录的是另一个用户(${info.userName ?? info.userId})`, { hint: '用 -a <新名字> 登录,或者先 logout' });
197
+ }
198
+ return finishLogin(ctx, credential, user);
199
+ }
200
+ /** 创作者中心扫码 / 短信登录(上游 XHSCreatorLoginApi,406 时整包重建匿名设备重试)。 */
201
+ async function loginCreator(ctx, method) {
202
+ const credential = await existingOrFresh(ctx, method);
203
+ const saved = credential.scopes.creator;
204
+ const temp = freshCredential(ctx, method);
205
+ const c = new Creator({ ...ctx, account: GUEST, credential: temp });
206
+ const l = new CreatorLogin(c);
207
+ let qr = null;
208
+ const phone = method === 'sms' ? (ctx.options.phone ?? (interactive() ? await prompt('手机号:') : undefined)) : undefined;
209
+ if (method === 'sms' && !phone)
210
+ throw new SpecificAIError('USAGE', '短信登录需要 --phone', { hint: 'specific-ai xhs auth login --scope creator --method sms --phone <手机号>' });
211
+ // 上游:探测到可复用的会话时跳过扫码 / 短信,直接验收
212
+ let active = false;
213
+ let sent = false;
214
+ let reason = '';
215
+ for (let attempt = 1; attempt <= 16; attempt++) {
216
+ await l.initCookies();
217
+ await l.bootstrap();
218
+ const session = await l.probeSession();
219
+ await l.completeSecurity();
220
+ if ((active = session.active))
221
+ break;
222
+ if (method === 'qrcode') {
223
+ qr = await l.qrcode();
224
+ if (qr)
225
+ break;
226
+ }
227
+ else {
228
+ const r = await l.sendCode(phone);
229
+ if ((sent = r.ok))
230
+ break;
231
+ reason = r.message;
232
+ }
233
+ ctx.log.warn(`当前设备会话被拒绝,重建匿名设备重试(${attempt}/16)`);
234
+ }
235
+ if (active)
236
+ ctx.log.info('检测到可复用的创作者中心会话,跳过扫码 / 短信');
237
+ else if (method === 'qrcode') {
238
+ if (!qr)
239
+ throw new SpecificAIError('RISK_CONTROL', '创作者中心获取二维码失败', { detail: { kind: 'blocked' } });
240
+ await showQrcode(ctx, qr.url, '请用小红书 App 扫码并确认(创作者中心)');
241
+ let last = null;
242
+ await poll(async () => {
243
+ const s = await l.qrcodeStatus(qr.id);
244
+ if (s === 1)
245
+ return true;
246
+ if (s === 0 || s === 4 || s == null)
247
+ throw new SpecificAIError('AUTH_REQUIRED', s === 4 ? '二维码已过期' : '二维码状态异常', { hint: '重新执行 specific-ai xhs auth login --scope creator' });
248
+ if (s === 3 && last !== 3)
249
+ ctx.log.info('已扫码,请在手机上确认');
250
+ last = s;
251
+ return undefined;
252
+ }, { interval: 1000 });
253
+ }
254
+ else {
255
+ if (!sent)
256
+ throw new SpecificAIError('RISK_CONTROL', `创作者中心发送验证码失败${reason ? `:${reason}` : ''}`, { detail: { kind: 'blocked' } });
257
+ const code = ctx.options.code ?? (interactive() ? (await prompt('验证码:')).trim() : undefined);
258
+ if (!code)
259
+ throw new SpecificAIError('USAGE', '创作者中心短信登录需要在终端里输入验证码,或加 --code', { hint: 'specific-ai xhs auth login --scope creator --method sms --phone <手机号> --code <验证码>' });
260
+ await l.loginByCode(phone, code);
261
+ }
262
+ const info = await l.userInfo();
263
+ if (!info) {
264
+ if (saved)
265
+ credential.scopes.creator = saved;
266
+ throw new SpecificAIError('AUTH_REQUIRED', '创作者中心用户信息验收失败');
267
+ }
268
+ c.save();
269
+ credential.scopes.creator = temp.scopes.creator;
270
+ credential.device.creator = temp.device.creator;
271
+ Object.assign(credential.extra, { creator_ds: temp.extra.creator_ds });
272
+ const user = credential.user ?? norm.ref({ user_id: info.userId, nickname: info.userName });
273
+ return finishLogin(ctx, credential, user);
274
+ }
275
+ export async function authLogin(ctx) {
276
+ const method = ctx.options.method;
277
+ const scopeName = ctx.options.scope ?? 'main';
278
+ if (method === 'cookie')
279
+ return loginCookie(ctx, scopeName);
280
+ if (scopeName === 'creator') {
281
+ if (method !== 'qrcode' && method !== 'sms')
282
+ throw new SpecificAIError('USAGE', `创作者中心不支持 ${method} 登录`);
283
+ return loginCreator(ctx, method);
284
+ }
285
+ if (method === 'qrcode')
286
+ return loginQrcode(ctx);
287
+ if (method === 'sms')
288
+ return loginSms(ctx);
289
+ throw new SpecificAIError('USAGE', `不支持的登录方式:${method}`);
290
+ }
291
+ export async function authStatus(ctx) {
292
+ const p = new Pc(ctx);
293
+ if (isGuest(ctx) || !p.isLogin)
294
+ return { logged_in: false, user: null, method: null, expires_at: null };
295
+ const r = await api.userMe(p);
296
+ const me = r?.data;
297
+ const ok = r?.success !== false && me?.user_id && me?.guest !== true;
298
+ const sess = p.jar.cookies.find((c) => c.name === 'web_session');
299
+ return { logged_in: Boolean(ok), user: ok ? norm.ref(me) : null, method: ok ? ctx.credential.method : null, expires_at: ok && sess?.expires ? n.time(sess.expires) : null };
300
+ }
301
+ // ================================================================ user
302
+ export async function userGet(ctx) {
303
+ return run(ctx, async (p) => {
304
+ if (ctx.args.user === 'me')
305
+ return norm.me(p.check(await api.userMe(p)));
306
+ const u = await resolveUser(p, ctx.args.user);
307
+ return norm.profile(p.check(await api.userInfo(p, u.id)), u.id, u.token);
308
+ });
309
+ }
310
+ export async function userSearch(ctx) {
311
+ return run(ctx, async (p) => {
312
+ const page = cursorPage(ctx);
313
+ const d = p.check(await api.searchUsers(p, ctx.args.keyword, page));
314
+ return paged((d?.users ?? []).map(norm.searchUser), page + 1, Boolean(d?.has_more));
315
+ });
316
+ }
317
+ function notesOf(kind) {
318
+ const fn = kind === 'items' ? api.userNotes : kind === 'likes' ? api.userLikes : api.userCollects;
319
+ const defaultSource = kind === 'likes' ? 'pc_user' : 'pc_search';
320
+ return (ctx) => run(ctx, async (p) => {
321
+ const u = await resolveUser(p, ctx.args.user ?? 'me');
322
+ const d = p.check(await fn(p, u.id, ctx.cursor ?? '', u.token, u.source || defaultSource));
323
+ const list = (d?.notes ?? []).map(norm.card);
324
+ return paged(list, d?.cursor, Boolean(d?.has_more) && list.length > 0);
325
+ });
326
+ }
327
+ export const userItems = notesOf('items');
328
+ export const userLikes = notesOf('likes');
329
+ export const userCollects = notesOf('collects');
330
+ const FOLLOWING_SIZE = 200;
331
+ /** 关注列表:上游只有私信页取当前账号关注列表的接口(get_following),别人的关注列表报 UNSUPPORTED。 */
332
+ export async function userFollowing(ctx) {
333
+ return run(ctx, async (p) => {
334
+ const who = ctx.args.user ?? 'me';
335
+ if (who !== 'me' && (await resolveUser(p, who)).id !== p.credential.user?.id) {
336
+ throw new SpecificAIError('UNSUPPORTED', 'xhs 只能查看自己的关注列表', { hint: 'specific-ai xhs user following' });
337
+ }
338
+ const page = cursorPage(ctx);
339
+ const d = p.check(await api.following(p, page, FOLLOWING_SIZE));
340
+ const raw = d?.follow_user_d_t_o_list ?? d?.users ?? [];
341
+ return paged(raw.map(norm.followingUser), page + 1, Boolean(d?.has_more) || raw.length >= FOLLOWING_SIZE);
342
+ });
343
+ }
344
+ // ================================================================ item
345
+ async function noteDetail(p, input) {
346
+ const ref = await resolveNote(p, input);
347
+ const d = p.check(await api.noteInfo(p, ref.id, ref.token, ref.source));
348
+ const raw = d?.items?.[0];
349
+ if (!raw)
350
+ throw new SpecificAIError('UPSTREAM', '笔记不存在或需要 xsec_token(传完整的笔记链接)', { detail: { id: ref.id } });
351
+ return { ref, item: norm.note(raw, ref.token) };
352
+ }
353
+ export async function itemGet(ctx) {
354
+ return run(ctx, async (p) => (await noteDetail(p, ctx.args.item)).item);
355
+ }
356
+ const SORT = { general: 'general', latest: 'time_descending', popular: 'popularity_descending', comments: 'comment_descending', collects: 'collect_descending' };
357
+ const TYPE = { all: 0, video: 1, image: 2 };
358
+ /** 上游 note_time:0 不限、1 一天内、2 一周内、3 半年内。 */
359
+ const TIME = { all: 0, day: 1, week: 2, half_year: 3 };
360
+ export async function itemSearch(ctx) {
361
+ return run(ctx, async (p) => {
362
+ // cursor 里带着第一页的 search_id(上游 search_some_note 翻页时复用同一个 root_search_id)
363
+ let page = 1;
364
+ let sid;
365
+ if (ctx.cursor) {
366
+ const [pg, s] = ctx.cursor.split(':');
367
+ page = Number(pg) || 1;
368
+ sid = s || undefined;
369
+ }
370
+ sid ??= api.searchId();
371
+ const o = ctx.options;
372
+ const d = p.check(await api.searchNotes(p, ctx.args.keyword, page, SORT[o.sort ?? 'general'], TYPE[o.type ?? 'all'], sid, TIME[o.time ?? 'all']));
373
+ const list = (d?.items ?? []).filter((x) => x.model_type ? x.model_type === 'note' : true).map(norm.card);
374
+ return paged(list, `${page + 1}:${sid}`, Boolean(d?.has_more));
375
+ });
376
+ }
377
+ export async function itemList(ctx) {
378
+ return creator(ctx, async (c) => {
379
+ const page = Number(ctx.cursor ?? 0) || 0;
380
+ const d = c.check(await capi.postedNotes(c, page, 0, page > 0));
381
+ const next = Number(d?.page ?? -1);
382
+ return paged((d?.notes ?? []).map(norm.postedNote), next, next !== -1);
383
+ });
384
+ }
385
+ async function noteMedia(p, input) {
386
+ const { ref, item } = await noteDetail(p, input);
387
+ let media = norm.mediaOf(item);
388
+ if (item.kind === 'video' && !media.length) {
389
+ const url = await api.noteVideo(p, ref.id);
390
+ if (url)
391
+ media = [n.media({ type: 'video', url })];
392
+ }
393
+ return { id: ref.id, media };
394
+ }
395
+ export async function itemMedia(ctx) {
396
+ return run(ctx, async (p) => (await noteMedia(p, ctx.args.item)).media);
397
+ }
398
+ export async function itemDownload(ctx) {
399
+ return run(ctx, async (p) => {
400
+ const { id, media } = await noteMedia(p, ctx.args.item);
401
+ return downloadMedia(ctx, p.http, id, media, {
402
+ headers: [
403
+ ['user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36'],
404
+ ['referer', `${WEB}/`],
405
+ ],
406
+ ext: (m) => (m.type === 'image' ? 'jpg' : 'mp4'),
407
+ });
408
+ });
409
+ }
410
+ const uploadImage = (c, file) => capi.uploadImage(c, file.data, file.filename);
411
+ export async function itemPublish(ctx) {
412
+ const o = ctx.options;
413
+ if (!o.image?.length && !o.video)
414
+ throw new SpecificAIError('USAGE', '小红书发布需要 --image 或 --video', { hint: 'specific-ai xhs item publish --title <标题> --text <正文> --image <图片>' });
415
+ if (o.image?.length && o.video)
416
+ throw new SpecificAIError('USAGE', '--image 和 --video 只能用一个');
417
+ // 上游用 opencv 截视频首帧当封面;specific-ai 不带视频解码器,封面要自己给
418
+ if (o.video && !o.cover)
419
+ throw new SpecificAIError('USAGE', '小红书视频发布需要 --cover(specific-ai 不解码视频,截不了首帧)', { hint: 'specific-ai xhs item publish --video a.mp4 --cover a.jpg --title <标题>' });
420
+ return creator(ctx, async (c) => {
421
+ const postTime = o.schedule ? Date.parse(o.schedule) : null;
422
+ let postLoc = null;
423
+ if (o.poi != null || o.poiName != null) {
424
+ // 地点搜索只认名称(按 id 搜不到那个地点),所以用 --poi-name 搜;同时给了 --poi 时取 id 相同的那个,否则取名称完全相同的。
425
+ // 上游拿地点名搜索后直接取第一个;这里要求精确匹配,免得发到别的地点上
426
+ if (o.poiName == null)
427
+ throw new SpecificAIError('USAGE', '小红书按名称搜地点,--poi 要和 --poi-name 一起用', { hint: 'specific-ai xhs poi search <名称> 查到名称和 id 后,用 --poi-name <名称> [--poi <id>]' });
428
+ const keyword = String(o.poiName);
429
+ const pois = c.check(await capi.searchPoi(c, keyword))?.poi_list ?? [];
430
+ const loc = o.poi != null ? pois.find((x) => String(x.poi_id) === o.poi) : pois.find((x) => String(x.name ?? '') === keyword);
431
+ if (!loc) {
432
+ const near = pois.slice(0, 3).map((x) => `${x.name}(${x.poi_id})`).join('、');
433
+ throw new SpecificAIError('USAGE', `没有找到地点 ${o.poi ?? keyword}${near ? `,相近的有:${near}` : ''}`, { hint: `specific-ai xhs poi search ${keyword}` });
434
+ }
435
+ postLoc = { name: loc.name, subname: loc.full_address, poi_id: loc.poi_id, poi_type: loc.poi_type };
436
+ }
437
+ const note = { title: o.title ?? '', desc: o.text ?? '', postTime, postLoc, privacy: o.visibility === 'private' ? 1 : 0 };
438
+ let data;
439
+ if (o.video) {
440
+ const video = await readMedia(c.http, o.video);
441
+ const cover = await readMedia(c.http, o.cover);
442
+ if (!imageSize(cover.data))
443
+ throw new SpecificAIError('USAGE', `无法识别的图片:${cover.filename}`);
444
+ data = await capi.videoNoteData(c, note, video.data, cover.data);
445
+ }
446
+ else {
447
+ const images = [];
448
+ for (const input of o.image)
449
+ images.push(await uploadImage(c, await readMedia(c.http, input)));
450
+ data = capi.imageNoteData(note, images);
451
+ }
452
+ for (const topic of [...(o.topic ?? []), ...(o.tag ?? [])]) {
453
+ const t = c.check(await capi.searchTopic(c, topic))?.topic_info_dtos?.[0];
454
+ if (!t)
455
+ throw new SpecificAIError('USAGE', `未找到话题:${topic}`);
456
+ data.common.hash_tag.push({ id: t.id, link: t.link, name: t.name, type: 'topic' });
457
+ data.common.desc += ` #${t.name}[话题]# `;
458
+ }
459
+ const body = await capi.postNote(c, data);
460
+ if (!body?.success && isAuthFailure(body))
461
+ throw authError(ctx, String(body?.msg ?? body?.message ?? '') || undefined);
462
+ if (!body?.success)
463
+ throw new SpecificAIError('UPSTREAM', String(body?.msg ?? body?.message ?? '发布失败'), { detail: { code: body?.code, result: body?.result } });
464
+ const id = String(body?.data?.id ?? body?.data?.note_id ?? '');
465
+ return n.item({ id, kind: o.video ? 'video' : 'image', url: id ? norm.noteUrl(id) : null, title: o.title ?? null, text: o.text ?? null, status: 'reviewing' }, body);
466
+ });
467
+ }
468
+ // ================================================================ comment
469
+ export async function commentList(ctx) {
470
+ if (ctx.options.product)
471
+ throw new SpecificAIError('NOT_IMPLEMENTED', 'xhs 的商品评价(--product)尚未实现', { detail: { upstream: 'none' } });
472
+ return run(ctx, async (p) => {
473
+ const ref = await resolveNote(p, ctx.args.item);
474
+ const d = p.check(await api.comments(p, ref.id, ctx.cursor ?? '', ref.token));
475
+ const list = (d?.comments ?? []).map((x) => norm.comment(x, ref.id));
476
+ return paged(list, d?.cursor, Boolean(d?.has_more));
477
+ });
478
+ }
479
+ export async function commentReplies(ctx) {
480
+ return run(ctx, async (p) => {
481
+ const ref = await resolveNote(p, ctx.args.item);
482
+ const d = p.check(await api.subComments(p, ref.id, ctx.args.comment, ctx.cursor ?? '', ref.token));
483
+ const list = (d?.comments ?? []).map((x) => norm.comment(x, ref.id, ctx.args.comment));
484
+ return paged(list, d?.cursor, Boolean(d?.has_more));
485
+ });
486
+ }
487
+ // ================================================================ feed
488
+ export async function feedList(ctx) {
489
+ if (ctx.options.kind === 'following')
490
+ throw new SpecificAIError('NOT_IMPLEMENTED', 'xhs 的 feed list --kind following 尚未实现', { detail: { upstream: 'none' } });
491
+ return run(ctx, async (p) => {
492
+ // cursor:note_index:cursor_score:频道
493
+ let [index, score, category] = [0, '', ctx.options.category ?? 'homefeed_recommend'];
494
+ if (ctx.cursor) {
495
+ const parts = ctx.cursor.split(':');
496
+ index = Number(parts[0]) || 0;
497
+ score = parts[1] ?? '';
498
+ category = parts.slice(2).join(':') || category;
499
+ }
500
+ const d = p.check(await api.homefeed(p, category, score, index ? 3 : 1, index));
501
+ const list = (d?.items ?? []).filter((x) => !x.model_type || x.model_type === 'note').map(norm.card);
502
+ return paged(list, `${index + 20}:${d?.cursor_score ?? ''}:${category}`, list.length > 0);
503
+ });
504
+ }
505
+ export async function feedCategories(ctx) {
506
+ return run(ctx, async (p) => {
507
+ const d = p.check(await api.homefeedCategories(p));
508
+ return (d?.categories ?? []).map((x) => n.category({ id: String(x.id), name: String(x.name ?? x.id) }, x));
509
+ });
510
+ }
511
+ // ================================================================ keyword / notice
512
+ /** 搜索联想词(上游 get_search_keyword,响应的 sug_items)。 */
513
+ export async function keywordSuggest(ctx) {
514
+ return run(ctx, async (p) => {
515
+ const d = p.check(await api.searchKeyword(p, ctx.args.prefix));
516
+ return (d?.sug_items ?? [])
517
+ .map((x) => n.keyword({ text: String(x?.text ?? x?.search_word ?? '') }, x))
518
+ .filter((k) => k.text);
519
+ });
520
+ }
521
+ export async function keywordHot(ctx) {
522
+ return run(ctx, async (p) => {
523
+ const d = p.check(await api.trendingQueries(p));
524
+ return (d?.queries ?? d?.hint_words ?? []).map((x) => n.keyword({ text: String(x.search_word ?? x.title ?? x.name ?? ''), heat: n.count(x.hot_value ?? x.heat ?? x.score) }, x));
525
+ });
526
+ }
527
+ export async function noticeCount(ctx) {
528
+ return run(ctx, async (p) => {
529
+ const d = p.check(await api.unreadCount(p));
530
+ return n.noticeCount({ total: n.count(d?.unread_count) ?? undefined, comment: null, mention: n.count(d?.mentions), like: n.count(d?.likes), follow: n.count(d?.connections), system: null }, d);
531
+ });
532
+ }
533
+ const NOTICE_KINDS = { mentions: 'comment', likes: 'like', connections: 'follow' };
534
+ function noticeOf(kind, v) {
535
+ const type = kind === 'mentions' ? (v.type === 'mention' || /at|mention/.test(String(v.type ?? '')) ? 'mention' : 'comment') : NOTICE_KINDS[kind];
536
+ const target = v.item_info ?? v.comment_info?.target_note ?? null;
537
+ return n.notice({
538
+ id: String(v.id),
539
+ type,
540
+ user: norm.ref(v.user_info),
541
+ target: target?.id ? { id: String(target.id), url: norm.noteUrl(String(target.id), target.xsec_token) } : null,
542
+ text: n.str(v.comment_info?.content ?? v.title),
543
+ created_at: n.time(v.time),
544
+ }, v);
545
+ }
546
+ /** 通知:评论和 @、赞和收藏、新增关注三类,cursor 是三个游标(第一页三类都取)。 */
547
+ export async function noticeList(ctx) {
548
+ return run(ctx, async (p) => {
549
+ const kinds = Object.keys(NOTICE_KINDS);
550
+ let cursors = Object.fromEntries(kinds.map((k) => [k, '']));
551
+ if (ctx.cursor)
552
+ cursors = JSON.parse(ctx.cursor);
553
+ const out = [];
554
+ const next = {};
555
+ for (const k of kinds) {
556
+ if (cursors[k] == null) {
557
+ next[k] = null;
558
+ continue;
559
+ }
560
+ const d = p.check(await api.youMessages(p, k, cursors[k]));
561
+ out.push(...(d?.message_list ?? []).map((x) => noticeOf(k, x)));
562
+ next[k] = d?.has_more && d?.cursor != null ? String(d.cursor) : null;
563
+ }
564
+ out.sort((a, b) => String(b.created_at ?? '').localeCompare(String(a.created_at ?? '')));
565
+ const more = Object.values(next).some((v) => v != null);
566
+ return paged(out, JSON.stringify(next), more);
567
+ });
568
+ }
569
+ // ================================================================ folder / topic / poi
570
+ export async function folderList(ctx) {
571
+ return run(ctx, async (p) => {
572
+ const u = await resolveUser(p, ctx.args.user ?? 'me');
573
+ const page = cursorPage(ctx);
574
+ const d = p.check(await api.userBoards(p, u.id, page));
575
+ const list = (d?.boards ?? []).map((b) => n.folder({ id: String(b.id), name: String(b.name ?? ''), count: n.count(b.total), url: `${WEB}/board/${b.id}` }, b));
576
+ return paged(list, page + 1, Boolean(d?.has_more));
577
+ });
578
+ }
579
+ export async function topicSearch(ctx) {
580
+ return creator(ctx, async (c) => {
581
+ const d = c.check(await capi.searchTopic(c, ctx.args.keyword));
582
+ const list = (d?.topic_info_dtos ?? []).map((t) => n.topic({ id: String(t.id), name: String(t.name ?? ''), url: n.url(t.link), stats: { views: n.count(t.view_num) } }, t));
583
+ return paged(list, null, false);
584
+ });
585
+ }
586
+ export async function poiSearch(ctx) {
587
+ return creator(ctx, async (c) => {
588
+ const d = c.check(await capi.searchPoi(c, ctx.args.keyword));
589
+ const list = (d?.poi_list ?? []).map((x) => n.poi({ id: String(x.poi_id), name: String(x.name ?? ''), address: n.str(x.full_address ?? x.address) }, x));
590
+ return paged(list, null, false);
591
+ });
592
+ }
593
+ // ================================================================ media upload
594
+ /**
595
+ * 上传到创作者中心的素材库(spectrum)。平台只回 fileId、不回 CDN 地址,url 是文件 PUT 到的 ROS 绝对地址;
596
+ * 发布时引用的是 id(fileId)。
597
+ */
598
+ export async function mediaUpload(ctx) {
599
+ return creator(ctx, async (c) => {
600
+ const file = await readMedia(c.http, ctx.args.file);
601
+ if (file.contentType.startsWith('video/')) {
602
+ const up = await capi.uploadMedia(c, file.data, 'video');
603
+ return n.media({ id: up.fileId, type: 'video', url: up.url }, up);
604
+ }
605
+ const img = await uploadImage(c, file);
606
+ return n.media({ id: img.fileId, type: 'image', url: img.url ?? null, width: img.width, height: img.height }, img);
607
+ });
608
+ }
609
+ // ================================================================ live
610
+ async function room(p, input) {
611
+ const roomId = await resolveRoom(p, input);
612
+ if (!p.userId)
613
+ await p.bootstrap();
614
+ const d = p.check(await api.liveRoomInfo(p, roomId, p.userId));
615
+ return { roomId, d };
616
+ }
617
+ function liveOf(roomId, d) {
618
+ const info = d?.room_info ?? d ?? {};
619
+ const host = d?.host_info ?? info.host_info ?? {};
620
+ return n.live({
621
+ id: roomId,
622
+ url: `${WEB}/livestream/${roomId}`,
623
+ title: n.str(info.room_title ?? info.title ?? d?.room_title),
624
+ status: Number(info.room_status ?? info.status ?? d?.room_status ?? 0) === 0 || info.live_status === 'living' || d?.live_status === 1 ? 'live' : 'offline',
625
+ host: norm.ref(host),
626
+ cover: n.url(info.room_cover ?? info.cover),
627
+ stats: { viewers: n.count(info.members_count ?? d?.members_count) },
628
+ }, d);
629
+ }
630
+ export async function liveGet(ctx) {
631
+ return run(ctx, async (p) => {
632
+ const { roomId, d } = await room(p, ctx.args.room);
633
+ return liveOf(roomId, d);
634
+ });
635
+ }
636
+ export async function liveList(ctx) {
637
+ return run(ctx, async (p) => {
638
+ const d = p.check(await api.liveSquare(p, ctx.options.category ?? ''));
639
+ const list = (d?.feeds ?? [])
640
+ .map((x) => x.live)
641
+ .filter((l) => l?.t_room_info)
642
+ .map((l) => {
643
+ const r = l.t_room_info;
644
+ const id = String(r.room_id_str ?? r.room_id);
645
+ const host = l.t_live_host_info ?? {};
646
+ return n.live({
647
+ id,
648
+ url: `${WEB}/livestream/${id}`,
649
+ title: n.str(r.name),
650
+ status: 'live',
651
+ host: norm.ref(host),
652
+ cover: n.url(r.cover ?? r.cover_info?.cover_image),
653
+ stats: { viewers: n.count(r.member_count ?? r.display_count) },
654
+ }, l);
655
+ });
656
+ return paged(list, null, false);
657
+ });
658
+ }
659
+ export async function liveCategories(ctx) {
660
+ return run(ctx, async (p) => {
661
+ const d = p.check(await api.liveCategories(p));
662
+ return (Array.isArray(d) ? d : (d?.categories ?? [])).map((x) => n.category({ id: String(x.id), name: String(x.desc ?? x.name ?? x.id) }, x));
663
+ });
664
+ }
665
+ const hostOf = (d) => String(d?.host_info?.user_id ?? d?.room_info?.host_id ?? d?.host_id ?? '');
666
+ export async function liveGifts(ctx) {
667
+ return run(ctx, async (p) => {
668
+ const { roomId, d } = await room(p, ctx.args.room);
669
+ const g = p.check(await api.liveGiftPanel(p, hostOf(d), roomId));
670
+ const gifts = (g?.tabs ?? []).flatMap((t) => t.gifts ?? t.gift_list ?? []).concat(g?.gifts ?? g?.gift_list ?? []);
671
+ const seen = new Set();
672
+ return gifts
673
+ .filter((x) => !seen.has(String(x.gift_id ?? x.id)) && seen.add(String(x.gift_id ?? x.id)))
674
+ .map((x) => n.gift({ id: String(x.gift_id ?? x.id), name: String(x.name ?? x.gift_name ?? ''), price: x.price != null ? { amount: Number(x.price), currency: 'XHS_COIN' } : null }, x));
675
+ });
676
+ }
677
+ export async function liveProducts(ctx) {
678
+ return run(ctx, async (p) => {
679
+ const { roomId, d } = await room(p, ctx.args.room);
680
+ const b = p.check(await api.liveBusiness(p, roomId, hostOf(d)));
681
+ const goods = b?.goods_info?.goods_list ?? b?.goods_list ?? b?.goods ?? [];
682
+ return goods.map((x) => n.item({ id: String(x.goods_id ?? x.id), kind: 'goods', url: n.url(x.link ?? x.url), title: n.str(x.title ?? x.name), cover: n.url(x.image ?? x.cover), price: n.price(x.price ?? x.sale_price) }, x));
683
+ });
684
+ }
685
+ /**
686
+ * 发弹幕。上游有两条通道:HTTP 的 send_comment 和长连的 send_room_text,注释说浏览器「按房间状态」二选一,
687
+ * 但没有写出判断条件。这里先走 HTTP;HTTP 报业务错误(UPSTREAM)时改走直播间长连。
688
+ */
689
+ export async function liveSend(ctx) {
690
+ if (ctx.options.gift)
691
+ throw new SpecificAIError('NOT_IMPLEMENTED', 'xhs 的 live send --gift 尚未实现(上游只发文本弹幕)', { detail: { upstream: 'partial' } });
692
+ return run(ctx, async (p) => {
693
+ const { roomId, d } = await room(p, ctx.args.room);
694
+ try {
695
+ p.check(await api.liveSendComment(p, roomId, ctx.args.text, hostOf(d)));
696
+ }
697
+ catch (err) {
698
+ if (!(err instanceof SpecificAIError && err.code === 'UPSTREAM'))
699
+ throw err;
700
+ ctx.log.info(`HTTP 发弹幕失败(${err.message}),改走直播间长连`);
701
+ await sendRoomText(p, roomId, ctx.args.text);
702
+ }
703
+ return { id: roomId };
704
+ });
705
+ }
706
+ /**
707
+ * 长连发弹幕(上游 send_room_text):连上并进房间,发 sendMessage 帧,等同一个 m 的回执(c=0 为成功)。
708
+ * 帧发出后没等到回执时报 NETWORK(结果不确定),不当成功。
709
+ */
710
+ async function sendRoomText(p, roomId, text) {
711
+ const me = p.check(await api.userMe(p));
712
+ const userId = String(me?.user_id ?? p.userId);
713
+ const conn = await push.connectPush(p, p.ctx.signal, roomId);
714
+ try {
715
+ const [frame, mid] = push.roomTextFrame({ roomId, nickname: String(me?.nickname ?? ''), avatar: String(me?.images ?? me?.imageb ?? ''), userId, content: text });
716
+ await conn.send(frame);
717
+ const a = await push.awaitFrame(conn, push.PUSH_TIMEOUTS.ack, (f) => (f?.m === mid ? (f?.b?.a ?? {}) : undefined));
718
+ if (!a) {
719
+ throw new SpecificAIError('NETWORK', `弹幕已经从直播间长连发出,但 ${push.PUSH_TIMEOUTS.ack / 1000} 秒内没有收到回执,不确定是否发出去了`, {
720
+ hint: '到直播间确认后再决定要不要重发',
721
+ detail: { kind: 'timeout', sent: true },
722
+ });
723
+ }
724
+ if (a.c != null && a.c !== 0)
725
+ throw new SpecificAIError('UPSTREAM', `直播间长连拒绝了弹幕:c=${a.c} ${a.m ?? ''}`.trim(), { detail: { code: a.c, message: a.m } });
726
+ }
727
+ finally {
728
+ conn.close();
729
+ }
730
+ }
731
+ /** 直播间事件:customData.type → Event.type。 */
732
+ export function roomEvent(payload) {
733
+ const c = payload?.customData;
734
+ if (!c || typeof c !== 'object')
735
+ return null;
736
+ const t = String(c.type ?? '');
737
+ const user = norm.ref(c.profile);
738
+ const time = n.time(payload.ts ?? c.current_time) ?? undefined;
739
+ switch (t) {
740
+ case 'text':
741
+ return n.event({ type: 'chat', time, user, text: n.str(c.desc) }, payload);
742
+ case 'gift':
743
+ return n.event({ type: 'gift', time, user, gift: { name: String(c.gift?.name ?? c.gift_info?.name ?? c.desc ?? ''), count: Number(c.gift?.count ?? c.gift_info?.count ?? c.count ?? 1) } }, payload);
744
+ case 'praise':
745
+ case 'light':
746
+ return n.event({ type: 'like', time, user }, payload);
747
+ case 'come':
748
+ case 'enter':
749
+ case 'enter_room':
750
+ return n.event({ type: 'enter', time, user }, payload);
751
+ case 'follow':
752
+ return n.event({ type: 'follow', time, user }, payload);
753
+ case 'viewer_heart':
754
+ case 'audience_num':
755
+ return null;
756
+ default:
757
+ return n.event({ type: 'other', time, user, text: n.str(c.desc) }, payload);
758
+ }
759
+ }
760
+ export function liveListen(ctx) {
761
+ return (async function* () {
762
+ const p = await pc(ctx);
763
+ try {
764
+ const { roomId } = await room(p, ctx.args.room);
765
+ yield* reconnecting(ctx, async function* () {
766
+ const conn = await push.connectPush(p, ctx.signal, roomId);
767
+ try {
768
+ for await (const frame of conn.frames) {
769
+ for (const payload of push.decodeRoomPush(frame)) {
770
+ const e = roomEvent(payload);
771
+ if (e)
772
+ yield e;
773
+ }
774
+ }
775
+ }
776
+ finally {
777
+ conn.close();
778
+ }
779
+ });
780
+ }
781
+ finally {
782
+ p.save();
783
+ }
784
+ })();
785
+ }
786
+ // ================================================================ msg
787
+ /**
788
+ * 会话 id:单聊是对方的用户 id(24 位十六进制);群聊是 `group:<群 id>`。
789
+ * 两种 id 都能直接传给 msg history;群聊不支持 msg read / revoke / delete / send(上游没有对应的群聊接口)。
790
+ */
791
+ const GROUP_PREFIX = 'group:';
792
+ const groupIdOf = (conversation) => (conversation.startsWith(GROUP_PREFIX) ? conversation.slice(GROUP_PREFIX.length) || null : null);
793
+ function privateOnly(conversation, what) {
794
+ if (conversation.startsWith(GROUP_PREFIX))
795
+ throw new SpecificAIError('UNSUPPORTED', `xhs 的群聊会话不支持${what}`, { hint: '群聊会话只支持 msg list、msg history' });
796
+ }
797
+ /** 单聊的会话 id:user_id 是当前账号自己,对方是 chat_user_id(对方资料在 info 里)。 */
798
+ const peerOf = (v) => v.info ?? v.user_info ?? v.chat_user ?? {};
799
+ export const chatIdOf = (v) => String(v.chat_user_id ?? peerOf(v).user_id ?? v.chat_id ?? v.id);
800
+ const lastMessageOf = (v) => n.str(push.innerText(v.last_msg_content ?? v.last_message?.content ?? ''));
801
+ const updatedAtOf = (v) => n.time(v.last_msg_ts ?? v.last_msg_time ?? v.update_time);
802
+ /** 单聊会话列表的一项(v3/chats 的 chat_list[i])。 */
803
+ export function conversationOf(v) {
804
+ const peer = peerOf(v);
805
+ const peerId = v.chat_user_id ?? peer.user_id;
806
+ return n.conversation({
807
+ id: chatIdOf(v),
808
+ peer: norm.ref({ user_id: peerId, nickname: peer.nickname ?? peer.user_name ?? v.nickname }),
809
+ unread: n.count(v.unread_count),
810
+ last_message: lastMessageOf(v),
811
+ updated_at: updatedAtOf(v),
812
+ }, v);
813
+ }
814
+ /** 群聊的群 id(chats/group 的列表项;字段名按私信列表的习惯兼容几种写法)。 */
815
+ const groupIdOfChat = (v) => v?.group_id ?? v?.group_info?.group_id ?? v?.chat_id ?? v?.id;
816
+ /** 群聊会话列表的一项:没有单个对方,peer 为 null(群名在 --raw 里)。 */
817
+ export function groupConversationOf(v) {
818
+ return n.conversation({ id: `${GROUP_PREFIX}${groupIdOfChat(v)}`, peer: null, unread: n.count(v.unread_count), last_message: lastMessageOf(v), updated_at: updatedAtOf(v) }, v);
819
+ }
820
+ const groupList = (d) => (Array.isArray(d) ? d : (d?.group_chat_list ?? d?.group_chats ?? d?.chat_list ?? d?.chats ?? d?.groups ?? []));
821
+ /**
822
+ * 会话列表:单聊(get_chats)和群聊(get_group_chats)合在一起,按最后一条消息的时间排。
823
+ * cursor 是「单聊页码:群聊页码」,没有更多的一方写 -。群聊列表取不到时只列单聊,并在 stderr 提示。
824
+ */
825
+ export async function msgList(ctx) {
826
+ return run(ctx, async (p) => {
827
+ const page = (s) => (s == null || s === '-' ? null : Number(s) || 0);
828
+ const [chatPage, groupPage] = ctx.cursor ? [page(ctx.cursor.split(':')[0]), page(ctx.cursor.split(':')[1])] : [0, 0];
829
+ const out = [];
830
+ let nextChat = null;
831
+ let nextGroup = null;
832
+ if (chatPage != null) {
833
+ const d = p.check(await api.chats(p, chatPage));
834
+ out.push(...(d?.chat_list ?? d?.chats ?? []).map(conversationOf));
835
+ if (d?.has_more)
836
+ nextChat = chatPage + 1;
837
+ }
838
+ if (groupPage != null) {
839
+ try {
840
+ const d = p.check(await api.groupChats(p, groupPage));
841
+ out.push(...groupList(d).filter((v) => groupIdOfChat(v) != null).map(groupConversationOf));
842
+ if (d?.has_more)
843
+ nextGroup = groupPage + 1;
844
+ }
845
+ catch (err) {
846
+ if (!(err instanceof SpecificAIError && err.code === 'UPSTREAM'))
847
+ throw err;
848
+ ctx.log.warn(`群聊列表取不到,只列单聊:${err.message}`);
849
+ }
850
+ }
851
+ out.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')));
852
+ const more = nextChat != null || nextGroup != null;
853
+ return paged(out, `${nextChat ?? '-'}:${nextGroup ?? '-'}`, more);
854
+ });
855
+ }
856
+ const HISTORY_LIMIT = 30;
857
+ /** 消息类型:顶层(数字的 type / content_type)没有时在 content 里(content 是一段 JSON 字符串:{content, content_type, ...})。 */
858
+ function contentType(v) {
859
+ const top = [v?.type, v?.content_type].find((x) => x != null && x !== '' && Number.isFinite(Number(x)));
860
+ if (top != null)
861
+ return Number(top);
862
+ try {
863
+ return Number(jsonLoads(v?.content)?.content_type ?? 1);
864
+ }
865
+ catch {
866
+ return 1;
867
+ }
868
+ }
869
+ /** content_type → Message.type:1 文本、2 图片、3 卡片、4 视频。 */
870
+ function messageType(v) {
871
+ const type = contentType(v);
872
+ return type === 1 ? 'text' : type === 2 ? 'image' : type === 4 ? 'video' : type === 3 ? 'card' : 'other';
873
+ }
874
+ /**
875
+ * 一条消息。单聊里没有 sender 时按 is_self 在自己和对方之间取;群聊没有「对方」,取不到发送者时为 null。
876
+ */
877
+ export function messageOf(v, conversation, self) {
878
+ const peer = groupIdOf(conversation) == null ? conversation : null;
879
+ const sender = v.sender_id ?? v.sender ?? (v.is_self ? self : peer);
880
+ const info = v.sender_info ?? v.user_info ?? {};
881
+ return n.message({
882
+ id: String(v.message_id ?? v.id ?? v.mid),
883
+ conversation_id: conversation,
884
+ from: norm.ref({ user_id: sender, nickname: info.nickname ?? v.sender_nickname ?? v.nickname }),
885
+ type: messageType(v),
886
+ text: n.str(push.innerText(v.content ?? '')),
887
+ created_at: n.time(v.created_at ?? v.create_time ?? v.ts),
888
+ }, v);
889
+ }
890
+ /**
891
+ * 消息记录:单聊走 get_message_history,群聊(`group:<群 id>`)走 get_group_message_history。
892
+ * 从新到旧排列;cursor 是这一页最早一条的 store_id(下一页的 last_id)。响应里没有 has_more,取满一页就认为还有更早的消息。
893
+ */
894
+ export async function msgHistory(ctx) {
895
+ return run(ctx, async (p) => {
896
+ const conv = ctx.args.conversation;
897
+ const group = groupIdOf(conv);
898
+ const lastId = Number(ctx.cursor ?? 0) || 0;
899
+ const d = p.check(group != null ? await api.groupMessageHistory(p, group, lastId, HISTORY_LIMIT) : await api.messageHistory(p, conv, lastId, HISTORY_LIMIT));
900
+ const raw = d?.out_message_list ?? d?.message_list ?? d?.messages ?? [];
901
+ const list = raw.map((m) => messageOf(m, conv, p.userId));
902
+ const ids = raw.map((m) => Number(m.store_id ?? m.id)).filter(Number.isFinite);
903
+ const next = ids.length ? Math.min(...ids) : null;
904
+ // 游标不前进(边界那条被重复返回)时到头了
905
+ return paged(list, next, (Boolean(d?.has_more) || raw.length >= HISTORY_LIMIT) && next != null && next !== lastId);
906
+ });
907
+ }
908
+ export async function msgSend(ctx) {
909
+ if (ctx.options.image || ctx.options.video)
910
+ throw new SpecificAIError('UNSUPPORTED', 'xhs 私信目前只支持文本(上游只发文本)');
911
+ if (ctx.options.item)
912
+ throw new SpecificAIError('UNSUPPORTED', 'xhs 没有商品客服私信', { hint: '用 --to <user> 或 --conversation <id>' });
913
+ const text = ctx.args.text;
914
+ if (!text)
915
+ throw new SpecificAIError('USAGE', '需要 <text>');
916
+ if (ctx.options.conversation)
917
+ privateOnly(String(ctx.options.conversation), '发消息(上游只发单聊私信)');
918
+ return run(ctx, async (p) => {
919
+ if (!p.userId)
920
+ await p.bootstrap();
921
+ const receiver = ctx.options.to ? (await resolveUser(p, String(ctx.options.to))).id : String(ctx.options.conversation);
922
+ const mid = rand.uuid4();
923
+ const chat = push.encodeChatMessage({ mid, ts: rand.now(), sender: p.userId, receiver, content: text, contentType: 1 });
924
+ const sent = (id, at, raw) => n.message({ id, conversation_id: receiver, from: norm.ref({ user_id: p.userId }), text, created_at: n.time(at) }, raw);
925
+ // 优先走长连(上游 send_private_message)。只有私信还没发出去(连不上、握手失败、帧没写出去)时才走 HTTP 短链兜底
926
+ // (send_short_link_message);帧已经发出、只是没等到回执时不再重发,免得对方收到两条
927
+ const conn = await push.connectPush(p, ctx.signal).catch((err) => {
928
+ if (ctx.signal.aborted)
929
+ throw err;
930
+ ctx.log.debug(`私信长连连不上,改走 HTTP:${err.message}`);
931
+ return null;
932
+ });
933
+ if (conn) {
934
+ try {
935
+ const written = await conn.send(push.imFrame(chat)).then(() => true, (err) => (ctx.log.debug(`私信长连写不出去,改走 HTTP:${err.message}`), false));
936
+ if (written) {
937
+ const ack = await push.awaitFrame(conn, push.PUSH_TIMEOUTS.ack, (f) => push.decodeImFrame(f).find((d) => d.ack?.mid === mid)?.ack);
938
+ if (!ack) {
939
+ throw new SpecificAIError('NETWORK', `私信已经从长连发出,但 ${push.PUSH_TIMEOUTS.ack / 1000} 秒内没有收到回执,不确定是否送达;为免重复,没有改走 HTTP 重发`, {
940
+ hint: `先用 specific-ai xhs msg history ${receiver} 确认,再决定要不要重发`,
941
+ detail: { kind: 'timeout', mid, sent: true },
942
+ });
943
+ }
944
+ if (ack.code !== 0)
945
+ throw new SpecificAIError('UPSTREAM', ack.msg || `私信发送失败:${ack.code}`, { detail: { code: ack.code } });
946
+ return sent(ack.messageId || mid, ack.ts, ack);
947
+ }
948
+ }
949
+ finally {
950
+ conn.close();
951
+ }
952
+ }
953
+ const r = p.check(await api.sendShortLink(p, push.rsaShortLink(chat)));
954
+ return sent(String(r?.message_id ?? mid), rand.now(), r);
955
+ });
956
+ }
957
+ /** 长连推来的一条私信:会话 id 是对方(自己发出的取接收方),类型与 msg history 相同,按 content_type 取。 */
958
+ export function inboundMessageOf(m, self) {
959
+ const sender = String(m.json?.sender ?? m.json?.sender_id ?? '');
960
+ const receiver = String(m.json?.receiver ?? m.json?.receiver_id ?? '');
961
+ const peer = sender && sender !== self ? sender : receiver || sender;
962
+ return n.message({
963
+ id: m.messageId || m.mid,
964
+ conversation_id: peer,
965
+ from: norm.ref({ user_id: sender || null }),
966
+ type: messageType(m.json ?? { content: m.payload }),
967
+ text: n.str(push.innerText(m.json ?? m.payload)),
968
+ created_at: n.time(m.ts),
969
+ }, m);
970
+ }
971
+ export function msgListen(ctx) {
972
+ return (async function* () {
973
+ const p = await pc(ctx);
974
+ try {
975
+ if (!p.userId)
976
+ await p.bootstrap();
977
+ const seen = new Set();
978
+ yield* reconnecting(ctx, async function* () {
979
+ const conn = await push.connectPush(p, ctx.signal);
980
+ const beat = setInterval(() => void conn.send(push.stateSyncFrame()).catch(() => { }), 60_000).unref();
981
+ try {
982
+ for await (const frame of conn.frames) {
983
+ for (const d of push.decodeImFrame(frame)) {
984
+ const m = d.message;
985
+ if (!m || seen.has(m.messageId || m.mid))
986
+ continue;
987
+ seen.add(m.messageId || m.mid);
988
+ yield inboundMessageOf(m, p.userId);
989
+ }
990
+ }
991
+ }
992
+ finally {
993
+ clearInterval(beat);
994
+ conn.close();
995
+ }
996
+ });
997
+ }
998
+ finally {
999
+ p.save();
1000
+ }
1001
+ })();
1002
+ }
1003
+ /** 找会话最多翻几页(每页 100 个)。 */
1004
+ const READ_MAX_PAGES = 20;
1005
+ /** 在单聊会话列表里逐页找这个会话,找不到时为 null。 */
1006
+ async function findChat(p, conv) {
1007
+ for (let page = 0; page < READ_MAX_PAGES; page++) {
1008
+ const d = p.check(await api.chats(p, page));
1009
+ const list = d?.chat_list ?? d?.chats ?? [];
1010
+ const chat = list.find((c) => chatIdOf(c) === conv);
1011
+ if (chat)
1012
+ return chat;
1013
+ if (!d?.has_more || !list.length)
1014
+ return null;
1015
+ }
1016
+ return null;
1017
+ }
1018
+ /**
1019
+ * 标记已读:从会话列表里找到这个会话(会话 id 与 msg list 相同,是对方的 chat_user_id),带上它的 store_id 与未读数。
1020
+ * 翻遍会话列表也找不到时报 USAGE,不发已读请求。
1021
+ */
1022
+ export async function msgRead(ctx) {
1023
+ const conv = ctx.args.conversation;
1024
+ privateOnly(conv, '标记已读');
1025
+ return run(ctx, async (p) => {
1026
+ const chat = await findChat(p, conv);
1027
+ if (!chat)
1028
+ throw new SpecificAIError('USAGE', `会话列表里没有这个会话:${conv}`, { hint: 'specific-ai xhs msg list' });
1029
+ p.check(await api.markRead(p, [{ chat_id: conv, read_store_id: Number(chat.last_store_id ?? chat.store_id ?? 0), unread_count: Number(chat.unread_count ?? 0), type: 1, need_rm_offline: true }]));
1030
+ return { id: conv };
1031
+ });
1032
+ }
1033
+ export async function msgRevoke(ctx) {
1034
+ privateOnly(ctx.args.conversation, '撤回消息');
1035
+ return run(ctx, async (p) => {
1036
+ p.check(await api.revokeMessage(p, { chat_user_id: ctx.args.conversation, message_id: ctx.args.message }));
1037
+ return { id: ctx.args.message };
1038
+ });
1039
+ }
1040
+ export async function msgDelete(ctx) {
1041
+ privateOnly(ctx.args.conversation, '删除会话');
1042
+ return run(ctx, async (p) => {
1043
+ p.check(await api.deleteMessage(p, [['chat_user_id', ctx.args.conversation]]));
1044
+ return { id: ctx.args.conversation };
1045
+ });
1046
+ }