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,202 @@
1
+ import { SpecificAIError } from '../../../core/errors.js';
2
+ import { parseJson } from '../../../core/http.js';
3
+ import { jsonLoads } from '../../../core/py.js';
4
+ import { authError, httpClient } from '../../../core/toolkit.js';
5
+ import { GraphQLOperation } from './graphql.js';
6
+ import { BROWSER, buildHeaders, COOKIE_DOMAIN, PROFILE, PUBLIC_BEARER, X_HOST } from './profile.js';
7
+ import { userIdFromTwid } from './resolve.js';
8
+ import { transaction } from './transaction.js';
9
+ /**
10
+ * X web 端会话,移植自上游 builder/auth.py(XAuth)+ x_apis 里的 graphql_get / graphql_post_query /
11
+ * graphql_post / _rest_post。
12
+ *
13
+ * XAuth 持有的动态状态(cookie 含 ct0)在 specific-ai 里都存进凭证,由 core 落盘。web 端不支持游客态
14
+ * (AGENTS 5.2),上游的 guest token 不移植。
15
+ * XCTID 素材用打包进仓库的 static/x/transaction_l1.json(上游 from_cached):上游优先
16
+ * from_network 抓 /i/flow/login 的 app shell,但它现在 307 到 jetfuel 登录页、拿不到素材,
17
+ * 上游实际也是回退到这份缓存。
18
+ */
19
+ /** 会话过期 / 未授权(上游 main.py 的 _AUTH_FAILURE_CODES,加上 353 = ct0 不匹配)。 */
20
+ const AUTH_CODES = new Set([32, 89, 215, 326, 353]);
21
+ /** GraphQL 错误码是否表示未登录 / 会话失效。 */
22
+ export const isAuthCode = (code) => AUTH_CODES.has(Number(code));
23
+ /** 限流 / 风控(88 = rate limit,226 / 344 = 自动化 / 日上限)。 */
24
+ const RISK_CODES = new Set([88, 226, 344]);
25
+ export class GraphQLError extends Error {
26
+ operation;
27
+ errors;
28
+ res;
29
+ constructor(operation, errors, res) {
30
+ super(`${operation}: ${errors.map((e) => e?.message ?? e).join('; ')}`);
31
+ this.operation = operation;
32
+ this.errors = errors;
33
+ this.res = res;
34
+ }
35
+ get code() {
36
+ return this.errors[0]?.code;
37
+ }
38
+ }
39
+ export class XClient {
40
+ ctx;
41
+ http;
42
+ jar;
43
+ constructor(ctx) {
44
+ this.ctx = ctx;
45
+ this.http = httpClient(ctx, { browser: BROWSER, os: 'windows' });
46
+ this.jar = this.http.jar;
47
+ }
48
+ get isLoggedIn() {
49
+ return Boolean(this.jar.get('auth_token'));
50
+ }
51
+ get ct0() {
52
+ return this.jar.get('ct0') ?? '';
53
+ }
54
+ /** `x-twitter-client-language` 与 `lang` cookie 一致(上游 with_client_language)。 */
55
+ get lang() {
56
+ return (this.jar.get('lang') || PROFILE.clientLanguage).toLowerCase();
57
+ }
58
+ /** twid cookie 里的数字用户 id(上游 XAuth.user_id)。 */
59
+ get userId() {
60
+ return userIdFromTwid(this.jar.get('twid'));
61
+ }
62
+ /**
63
+ * 建立会话(上游 XAuth.prepare_auth):已登录会话的 ct0 由服务端签发,缺了只能报错;补上 lang cookie。
64
+ * 上游未登录时本地生成 ct0 给游客用,web 端没有游客态,不移植。
65
+ */
66
+ async init() {
67
+ if (this.isLoggedIn && !this.ct0)
68
+ throw authError(this.ctx, 'cookie 里有 auth_token 但缺 ct0,请把浏览器 cookie 完整复制过来');
69
+ if (!this.jar.has('lang'))
70
+ this.jar.set('lang', PROFILE.clientLanguage, COOKIE_DOMAIN);
71
+ }
72
+ view() {
73
+ return { bearer: PUBLIC_BEARER, ct0: this.ct0, lang: this.lang, loggedIn: this.isLoggedIn };
74
+ }
75
+ xctid(method, path) {
76
+ return transaction().generate(method.toUpperCase(), path);
77
+ }
78
+ // ---------------------------------------------------------------- GraphQL
79
+ /** query 型 GraphQL 读接口(GET)。读接口存在「部分成功」,有 data 时不抛。上游 graphql_get。 */
80
+ async graphqlGet(operationName, variables, options = {}) {
81
+ const op = new GraphQLOperation(operationName);
82
+ const res = await this.http.request({
83
+ method: 'GET',
84
+ url: op.url(),
85
+ query: op.queryParams(variables),
86
+ headers: this.graphqlHeaders(op.url(), 'GET', op.path(), options.referer),
87
+ });
88
+ return this.checkGraphQL(res, operationName, false);
89
+ }
90
+ /** POST 发送的 query 型操作(HomeTimeline 带 seenTweetIds、SearchTimeline)。上游 graphql_post_query。 */
91
+ async graphqlPostQuery(operationName, variables, options = {}) {
92
+ const op = new GraphQLOperation(operationName);
93
+ const res = await this.http.request({
94
+ method: 'POST',
95
+ url: op.url(),
96
+ json: op.jsonBody(variables),
97
+ headers: this.graphqlHeaders(op.url(), 'POST', op.path(), options.referer),
98
+ });
99
+ return this.checkGraphQL(res, operationName, false);
100
+ }
101
+ /** mutation 型写接口。上游 graphql_post:有 errors 就算失败。 */
102
+ async graphqlPost(operationName, variables, options = {}) {
103
+ const op = new GraphQLOperation(operationName);
104
+ const res = await this.http.request({
105
+ method: 'POST',
106
+ url: op.url(),
107
+ json: op.jsonBody(variables),
108
+ headers: this.graphqlHeaders(op.url(), 'POST', op.path(), options.referer),
109
+ });
110
+ return this.checkGraphQL(res, operationName, true);
111
+ }
112
+ graphqlHeaders(url, method, path, referer = `${X_HOST}/home`) {
113
+ return buildHeaders('GRAPHQL', url, this.view(), { xctid: this.xctid(method, path), referer });
114
+ }
115
+ /** 老 REST 表单接口(friendships 等)。上游 _rest_post。 */
116
+ async restPost(api, data) {
117
+ const url = `${X_HOST}/i/api/1.1${api}`;
118
+ const res = await this.http.request({
119
+ method: 'POST',
120
+ url,
121
+ form: data,
122
+ headers: buildHeaders('FORM', url, this.view(), { xctid: this.xctid('POST', `/i/api/1.1${api}`), referer: `${X_HOST}/home` }),
123
+ });
124
+ return this.checkJson(res, `/1.1${api}`);
125
+ }
126
+ /** 自定义请求(媒体上传、X Chat)。 */
127
+ request(req) {
128
+ return this.http.request(req);
129
+ }
130
+ buildHeaders(type, url, options = {}) {
131
+ return buildHeaders(type, url, this.view(), options);
132
+ }
133
+ /** 给不走 graphql* 的请求算 XCTID(媒体元数据、X Chat)。 */
134
+ transactionId(method, path) {
135
+ return this.xctid(method, path);
136
+ }
137
+ /** HTTP 状态不是 2xx 时报错(上游 raise_for_status),否则解析 JSON。operation 用在错误信息里。 */
138
+ async checkJson(res, operation = 'request') {
139
+ if (!res.ok)
140
+ throw this.httpError(res.status, await res.text(), operation);
141
+ return parseJson(res);
142
+ }
143
+ /** 只校验 HTTP 状态(响应体为空的接口)。 */
144
+ async checkOk(res, operation = 'request') {
145
+ if (!res.ok)
146
+ throw this.httpError(res.status, await res.text(), operation);
147
+ }
148
+ /**
149
+ * X 的 GraphQL 失败不一定用 HTTP 状态码表达:会话过期、限流都可能是 200 + errors。
150
+ * 读接口存在「部分成功」,有 data 时不抛;mutation 有 errors 就抛(上游 graphql_get / graphql_post)。
151
+ */
152
+ async checkGraphQL(res, operation, mutation) {
153
+ const body = await this.checkJson(res, operation);
154
+ const errors = body?.errors;
155
+ if (errors?.length && (mutation || !body.data))
156
+ throw this.mapErrors(new GraphQLError(operation, errors, body));
157
+ return body;
158
+ }
159
+ /**
160
+ * 非 2xx。X 的错误响应体多半也是 `{errors: [{code, message}]}`:REST 的业务拒绝(158 不能关注自己、
161
+ * 160 已发出关注请求、161 关注数到上限、162 被对方屏蔽)和 353(ct0 不匹配)都是 403,
162
+ * 所以先按错误码映射;没有错误码时,401 / 403 才当作登录态失效。
163
+ */
164
+ httpError(status, text, operation) {
165
+ let errors;
166
+ try {
167
+ errors = jsonLoads(text)?.errors;
168
+ }
169
+ catch { }
170
+ if (Array.isArray(errors) && errors[0]?.code != null)
171
+ return this.mapErrors(new GraphQLError(operation, errors, text.slice(0, 300)), status);
172
+ if (status === 401 || status === 403)
173
+ return authError(this.ctx);
174
+ if (status === 429)
175
+ return new SpecificAIError('RISK_CONTROL', `X 限流(HTTP ${status})`, { detail: { kind: 'rate_limit', status } });
176
+ return new SpecificAIError('UPSTREAM', `X 返回 HTTP ${status}`, { detail: { status, body: text.slice(0, 300) } });
177
+ }
178
+ /** errors 里的错误码映射成 specific-ai 错误(AGENTS 6.4);status 是 HTTP 状态(200 + errors 时不给)。 */
179
+ mapErrors(err, status) {
180
+ const code = err.code;
181
+ if (code != null && AUTH_CODES.has(code))
182
+ return authError(this.ctx, err.message);
183
+ if (code != null && RISK_CODES.has(code)) {
184
+ return new SpecificAIError('RISK_CONTROL', err.message, { detail: { kind: code === 88 ? 'rate_limit' : 'blocked', code, ...(status ? { status } : {}) } });
185
+ }
186
+ return new SpecificAIError('UPSTREAM', err.message, { detail: { code: code ?? null, operation: err.operation, errors: err.errors, ...(status ? { status } : {}) } });
187
+ }
188
+ /** 确认 cookie 里有 auth_token / ct0(registry 已挡住未登录,这里防 cookie 不全)。 */
189
+ requireLogin() {
190
+ if (!this.isLoggedIn || !this.ct0)
191
+ throw authError(this.ctx, '当前账号缺少 auth_token / ct0,请重新登录');
192
+ }
193
+ }
194
+ /** 建立 X 会话。 */
195
+ export async function xclient(ctx) {
196
+ const x = new XClient(ctx);
197
+ await x.init();
198
+ return x;
199
+ }
200
+ export function multipartMedia(chunk) {
201
+ return { name: 'media', data: chunk, contentType: 'application/octet-stream', filename: 'blob' };
202
+ }
@@ -0,0 +1,363 @@
1
+ import { dirname, isAbsolute, resolve } from 'node:path';
2
+ import { SpecificAIError, toSpecificAIError } from '../../../core/errors.js';
3
+ import { downloadMedia, readMedia } from '../../../core/files.js';
4
+ import { cookieCredential, finishLogin, loginContext } from '../../../core/login.js';
5
+ import * as n from '../../../core/normalize.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 { markdownToContentState, splitTitle } from './article.js';
10
+ import { isAuthCode, xclient } from './client.js';
11
+ import * as norm from './normalize.js';
12
+ import { COOKIE_DOMAIN, PROFILE } from './profile.js';
13
+ import { isUserId, parseArticleId, parseTweetId } from './resolve.js';
14
+ // ================================================================ 公共
15
+ /**
16
+ * 当前账号(Viewer)。cookie 失效时 X 仍回 200 和部分 data,只在 errors 里给 code 32,
17
+ * 所以按错误码判断登录态(上游 main.py 的 _saved_cookie_is_valid)。
18
+ */
19
+ async function viewer(x) {
20
+ const res = await api.getViewer(x);
21
+ const u = res?.data?.viewer?.user_results?.result;
22
+ if (u?.rest_id)
23
+ return u;
24
+ const errors = res?.errors ?? [];
25
+ if (errors.some((e) => isAuthCode(e?.code)))
26
+ throw authError(x.ctx, errors.map((e) => e?.message).join('; '));
27
+ throw new SpecificAIError('UPSTREAM', 'Viewer 响应里没有当前用户', { detail: res });
28
+ }
29
+ /** 校验登录态用的 Viewer:cookie 无效或已失效时返回 null,其他错误照抛。 */
30
+ async function viewerOrNull(x) {
31
+ try {
32
+ return await viewer(x);
33
+ }
34
+ catch (err) {
35
+ if (err instanceof SpecificAIError && (err.code === 'AUTH_EXPIRED' || err.code === 'AUTH_REQUIRED'))
36
+ return null;
37
+ throw err;
38
+ }
39
+ }
40
+ /** UserByScreenName / UserByRestId 响应里的用户;不存在、被封禁时报错。 */
41
+ function userOf(res, input) {
42
+ const u = res?.data?.user?.result;
43
+ if (!u?.rest_id) {
44
+ const reason = u?.__typename === 'UserUnavailable' ? `:${u.message ?? u.reason ?? '不可用'}` : '';
45
+ throw new SpecificAIError('UPSTREAM', `用户 ${input} 不存在或不可见${reason}`, { detail: u ?? res });
46
+ }
47
+ return u;
48
+ }
49
+ /** 用户参数 → 数字 rest_id:`me` 取 twid,纯数字直接用,其余按用户名查(上游 get_user_id)。 */
50
+ async function resolveUserId(x, input) {
51
+ if (input === 'me') {
52
+ x.requireLogin();
53
+ return x.userId || (await viewer(x)).rest_id;
54
+ }
55
+ if (isUserId(input))
56
+ return input.trim();
57
+ return userOf(await api.getUserInfo(x, input), input).rest_id;
58
+ }
59
+ /** 推文对象;已删除、受保护等不可见时报错。 */
60
+ async function tweetOf(x, input) {
61
+ const res = await api.getWorkResult(x, input);
62
+ const t = res?.data?.tweetResult?.result;
63
+ if (!norm.unwrap(t)?.legacy) {
64
+ const reason = t?.reason ?? t?.tombstone?.text?.text;
65
+ throw new SpecificAIError('UPSTREAM', `推文 ${input} 不存在或不可见${reason ? `:${reason}` : ''}`, { detail: t ?? res });
66
+ }
67
+ return t;
68
+ }
69
+ /**
70
+ * 时间线分页:游标没变或这一页是空的就算翻到底了。
71
+ * got 是这一页取到的条数(过滤之前);按类型过滤后这一页可能为空,但后面还有。
72
+ */
73
+ function timeline(ctx, list, res, got = list.length) {
74
+ const next = norm.cursorOf(res, 'Bottom');
75
+ return paged(list, next, Boolean(next) && got > 0 && next !== ctx.cursor);
76
+ }
77
+ // ================================================================ auth
78
+ /** 只支持 cookie 导入(注册表只声明了 cookie);上游的账密登录依赖 Castle token,不移植。 */
79
+ export async function authLogin(ctx) {
80
+ const credential = cookieCredential(ctx, COOKIE_DOMAIN);
81
+ const names = new Set(credential.scopes.main.cookies.map((c) => c.name));
82
+ if (!names.has('auth_token') || !names.has('ct0')) {
83
+ throw new SpecificAIError('USAGE', 'X 的 cookie 需要包含 auth_token 和 ct0', { hint: '在已登录的 x.com 页面复制完整的 Cookie 请求头' });
84
+ }
85
+ const me = await viewerOrNull(await xclient(loginContext(ctx, credential)));
86
+ if (!me)
87
+ throw new SpecificAIError('AUTH_REQUIRED', '登录没有成功:cookie 无效或已过期', { hint: '重新在浏览器登录 x.com 后复制 cookie' });
88
+ return finishLogin(ctx, credential, norm.userRef(me));
89
+ }
90
+ export async function authStatus(ctx) {
91
+ const x = await xclient(ctx);
92
+ const out = { logged_in: false, user: null, method: null, expires_at: null };
93
+ const me = x.isLoggedIn ? await viewerOrNull(x) : null;
94
+ if (!me)
95
+ return out;
96
+ const expires = x.jar.cookies.find((c) => c.name === 'auth_token')?.expires;
97
+ return { logged_in: true, user: norm.userRef(me), method: ctx.credential.method, expires_at: expires ? n.time(expires) : null };
98
+ }
99
+ // ================================================================ user
100
+ export async function userGet(ctx) {
101
+ const x = await xclient(ctx);
102
+ const input = ctx.args.user;
103
+ if (input === 'me') {
104
+ x.requireLogin();
105
+ return norm.user(await viewer(x));
106
+ }
107
+ const res = isUserId(input) ? await api.getUserById(x, input.trim()) : await api.getUserInfo(x, input);
108
+ return norm.user(userOf(res, input));
109
+ }
110
+ export async function userSearch(ctx) {
111
+ const x = await xclient(ctx);
112
+ const res = await api.searchWork(x, ctx.args.keyword, ctx.cursor ?? undefined, 'People');
113
+ return timeline(ctx, norm.userResults(res).map(norm.user), res);
114
+ }
115
+ export async function userItems(ctx) {
116
+ const x = await xclient(ctx);
117
+ const id = await resolveUserId(x, ctx.args.user);
118
+ // 登录后主页 Posts 标签是 UserOriginalsTimeline(上游 get_user_post_note 的默认)
119
+ const res = await api.getUserPostNote(x, id, ctx.cursor ?? undefined);
120
+ return timeline(ctx, norm.tweetResults(res).map(norm.item), res);
121
+ }
122
+ async function follow(ctx, on) {
123
+ const x = await xclient(ctx);
124
+ x.requireLogin();
125
+ const id = await resolveUserId(x, ctx.args.user);
126
+ await (on ? api.followUser(x, id) : api.unfollowUser(x, id));
127
+ return { id };
128
+ }
129
+ export const userFollow = (ctx) => follow(ctx, true);
130
+ export const userUnfollow = (ctx) => follow(ctx, false);
131
+ // ================================================================ item
132
+ export async function itemGet(ctx) {
133
+ const x = await xclient(ctx);
134
+ return norm.item(await tweetOf(x, ctx.args.item));
135
+ }
136
+ const SEARCH_PRODUCT = { general: 'Top', latest: 'Latest' };
137
+ /**
138
+ * `--type video|image` 走媒体搜索(product=Media,网页的「媒体」标签):结果里图片和视频混在一起,
139
+ * 按 Item.kind 过滤(既有图又有视频的推文算 video)。
140
+ */
141
+ export async function itemSearch(ctx) {
142
+ const { sort, type } = ctx.options;
143
+ const media = type === 'video' || type === 'image';
144
+ if (media && sort != null && sort !== 'general') {
145
+ throw new SpecificAIError('UNSUPPORTED', `X 的媒体搜索不能和 --sort ${sort} 一起用`, { hint: '去掉 --sort,或去掉 --type' });
146
+ }
147
+ const x = await xclient(ctx);
148
+ const product = media ? 'Media' : (SEARCH_PRODUCT[sort ?? 'general'] ?? 'Top');
149
+ const res = await api.searchWork(x, ctx.args.keyword, ctx.cursor ?? undefined, product);
150
+ const results = (media ? [...norm.tweetResults(res), ...norm.gridResults(res)] : norm.tweetResults(res)).map(norm.item);
151
+ return timeline(ctx, media ? results.filter((it) => it.kind === type) : results, res, results.length);
152
+ }
153
+ export async function itemMedia(ctx) {
154
+ const x = await xclient(ctx);
155
+ return norm.item(await tweetOf(x, ctx.args.item)).media;
156
+ }
157
+ export async function itemDownload(ctx) {
158
+ const x = await xclient(ctx);
159
+ const it = norm.item(await tweetOf(x, ctx.args.item));
160
+ // 图片显式要 orig 才是原图(上游 download_work)
161
+ const media = it.media.map((m) => (m.type === 'image' ? { ...m, url: `${m.url}?format=jpg&name=orig` } : m));
162
+ return downloadMedia(ctx, x.http, it.id, media, {
163
+ headers: [['user-agent', PROFILE.ua]],
164
+ ext: (m) => (m.type === 'image' ? 'jpg' : 'mp4'),
165
+ });
166
+ }
167
+ /** 点赞 / 收藏 / 转发这类只带推文 id 的写操作。 */
168
+ async function act(ctx, target, call) {
169
+ const x = await xclient(ctx);
170
+ x.requireLogin();
171
+ const id = parseTweetId(target);
172
+ await call(x, id);
173
+ return { id };
174
+ }
175
+ export const itemLike = (ctx) => act(ctx, ctx.args.item, api.favoriteTweet);
176
+ export const itemUnlike = (ctx) => act(ctx, ctx.args.item, api.unfavoriteTweet);
177
+ export const itemCollect = (ctx) => act(ctx, ctx.args.item, api.createBookmark);
178
+ export const itemUncollect = (ctx) => act(ctx, ctx.args.item, api.deleteBookmark);
179
+ export const itemRepost = (ctx) => act(ctx, ctx.args.item, api.createRetweet);
180
+ export const itemUnrepost = (ctx) => act(ctx, ctx.args.item, api.deleteRetweet);
181
+ export const itemDelete = (ctx) => act(ctx, ctx.args.item, api.deleteTweet);
182
+ /** thread 两条之间的间隔(上游 post_thread 的 interval),太快容易触发风控。 */
183
+ const THREAD_INTERVAL = 2000;
184
+ /** `--quote`:链接原样作为 attachment_url(上游 quote_url);给的是 ID 时先查出推文,用它的链接。 */
185
+ async function quoteUrl(x, input) {
186
+ const id = parseTweetId(input);
187
+ if (id !== input.trim())
188
+ return input.trim();
189
+ return norm.item(await tweetOf(x, id)).url;
190
+ }
191
+ /**
192
+ * 发推:先传媒体(最多 4 张图,或 1 个视频),再发推。正文超过 280 权重时自动改发长推(CreateNoteTweet)。
193
+ * 带 `--thread` 时接着逐条发,每条回复上一条(上游 post_thread),返回第一条。上游 XWriteAPI.post_tweet。
194
+ */
195
+ export async function itemPublish(ctx) {
196
+ const o = ctx.options;
197
+ const images = o.image ?? [];
198
+ if (images.length && o.video)
199
+ throw new SpecificAIError('USAGE', 'X 的一条推文不能同时带图片和视频');
200
+ if (images.length > 4)
201
+ throw new SpecificAIError('USAGE', 'X 的一条推文最多 4 张图片');
202
+ const text = o.text ?? '';
203
+ if (!text && !images.length && !o.video)
204
+ throw new SpecificAIError('USAGE', '发推需要 --text、--image 或 --video');
205
+ const thread = o.thread ?? [];
206
+ if (thread.some((t) => !t.trim()))
207
+ throw new SpecificAIError('USAGE', '--thread 的每一条都不能为空');
208
+ const x = await xclient(ctx);
209
+ x.requireLogin();
210
+ const mediaIds = [];
211
+ for (const input of o.video ? [o.video] : images) {
212
+ const file = await readMedia(x.http, input);
213
+ mediaIds.push((await api.upload(x, file.data, file.filename)).mediaId);
214
+ }
215
+ const quote = o.quote ? await quoteUrl(x, o.quote) : undefined;
216
+ const first = api.createdTweet(await api.postTweet(x, text, mediaIds, undefined, quote));
217
+ const posted = [norm.item(first)];
218
+ for (const [i, t] of thread.entries()) {
219
+ await rand.sleep(THREAD_INTERVAL);
220
+ try {
221
+ posted.push(norm.item(api.createdTweet(await api.postTweet(x, t, [], posted.at(-1).id))));
222
+ }
223
+ catch (err) {
224
+ const e = toSpecificAIError(err);
225
+ // 中途失败:前面几条已经发出去了,把它们的 id 放进 detail,方便删除或接着发
226
+ throw new SpecificAIError(e.code, `thread 第 ${i + 2} 条失败:${e.message}`, {
227
+ hint: e.hint,
228
+ detail: { posted: posted.map((p) => ({ id: p.id, url: p.url })), error: e.detail },
229
+ });
230
+ }
231
+ }
232
+ return posted[0];
233
+ }
234
+ // ================================================================ comment
235
+ export async function commentList(ctx) {
236
+ const x = await xclient(ctx);
237
+ const id = parseTweetId(ctx.args.item);
238
+ const res = await api.getWorkInfo(x, id, ctx.cursor ?? undefined);
239
+ return timeline(ctx, norm.replyResults(res).map((r) => norm.comment(r, id)), res);
240
+ }
241
+ /**
242
+ * 评论就是一条回复推文;`--reply-to` 时回复那条评论。和发推一样,正文超过 280 权重时自动改发长推
243
+ * (上游 main.py post --reply-to 走的就是 post_tweet)。
244
+ */
245
+ export async function commentAdd(ctx) {
246
+ const x = await xclient(ctx);
247
+ x.requireLogin();
248
+ const id = parseTweetId(ctx.args.item);
249
+ const to = ctx.options.replyTo;
250
+ const created = api.createdTweet(await api.postTweet(x, ctx.args.text, [], to ? parseTweetId(to) : id));
251
+ return norm.comment(created, id);
252
+ }
253
+ export const commentDelete = (ctx) => act(ctx, ctx.args.comment, api.deleteTweet);
254
+ export const commentLike = (ctx) => act(ctx, ctx.args.comment, api.favoriteTweet);
255
+ export const commentUnlike = (ctx) => act(ctx, ctx.args.comment, api.unfavoriteTweet);
256
+ // ================================================================ feed
257
+ export async function feedList(ctx) {
258
+ const kind = ctx.options.kind ?? 'recommend';
259
+ if (kind === 'following')
260
+ throw new SpecificAIError('NOT_IMPLEMENTED', 'x 的 feed list --kind following 尚未实现', { detail: { upstream: 'none' } });
261
+ const x = await xclient(ctx);
262
+ x.requireLogin();
263
+ const res = await api.getHomeTimeline(x, ctx.cursor ?? undefined);
264
+ return timeline(ctx, norm.tweetResults(res).map(norm.item), res);
265
+ }
266
+ // ================================================================ msg(X Chat)
267
+ /**
268
+ * 收件箱首页。单聊对方的资料缺名字时(参与者只有 rest_id),再用 GetUsersByIdsForXChat 查一次成员资料
269
+ * (上游 get_users_by_ids,网页进私信页时也发这个)。
270
+ *
271
+ * 只取首页:上游 get_initial_chat_page 的 max_local_sequence_id / message_pull_version 是增量同步的
272
+ * 水位(取某个序号之后的新消息事件),不是翻页游标;翻更早的会话要 GetInboxPageRequestQuery,上游没有。
273
+ */
274
+ export async function msgList(ctx) {
275
+ const x = await xclient(ctx);
276
+ x.requireLogin();
277
+ const page = (await api.getInitialChatPage(x))?.data?.get_initial_chat_page ?? {};
278
+ // 自己的 id 取 twid cookie;cookie 里没有 twid 时查一次 Viewer,不然会把自己当成对方
279
+ const self = x.userId || (await viewer(x)).rest_id;
280
+ const items = page.items ?? [];
281
+ const missing = [...new Set(items.flatMap((it) => norm.peersWithoutName(it, self)))];
282
+ const members = missing.length ? norm.memberResults(await api.getUsersByIds(x, missing)) : new Map();
283
+ return paged(items.map((it) => norm.conversation(it, self, members)), null, false);
284
+ }
285
+ export async function msgHistory(ctx) {
286
+ const x = await xclient(ctx);
287
+ x.requireLogin();
288
+ const conversationId = ctx.args.conversation;
289
+ const page = (await api.getConversationPage(x, conversationId))?.data?.get_conversation_page ?? {};
290
+ const events = page.encoded_message_events ?? [];
291
+ if (events.length)
292
+ ctx.log.warn('X Chat 的消息是端到端加密的,specific-ai 不解密:只给出占位的消息,原始事件用 --raw 查看');
293
+ return paged(events.map((e, i) => norm.encryptedMessage(e, i, conversationId)), null, false);
294
+ }
295
+ // ================================================================ media
296
+ export async function mediaUpload(ctx) {
297
+ const x = await xclient(ctx);
298
+ x.requireLogin();
299
+ const file = await readMedia(x.http, ctx.args.file);
300
+ const { mediaId, mediaType, result } = await api.upload(x, file.data, file.filename);
301
+ const img = result?.image ?? {};
302
+ return n.media({ id: mediaId, type: mediaType.startsWith('video/') ? 'video' : 'image', url: null, width: n.count(img.w), height: n.count(img.h) }, result);
303
+ }
304
+ // ================================================================ article(平台扩展,AGENTS 4.7)
305
+ const articleUrl = (id) => `https://x.com/i/article/${id}`;
306
+ /** 在 specific-ai 的错误上补充已建草稿的信息:发布失败时草稿还在,方便到网页上处理或删除。 */
307
+ function withDraft(err, articleId) {
308
+ const e = toSpecificAIError(err);
309
+ return new SpecificAIError(e.code, `${e.message}(草稿 ${articleId} 已创建)`, {
310
+ hint: e.hint ?? `到 ${api.articleEditUrl(articleId)} 查看,或 specific-ai x article delete ${articleId}`,
311
+ detail: { article_id: articleId, edit_url: api.articleEditUrl(articleId), error: e.detail },
312
+ });
313
+ }
314
+ /**
315
+ * 从 Markdown 发文章:传正文插图 → 建草稿 → 标题 → 正文 → 传封面、设封面 → 发布(同时生成一条文章推文)。
316
+ * 没给 `--title` 时取正文第一行的 `# 标题`。上游 XArticleAPI.post_article(publish=True)。
317
+ *
318
+ * 正文里 `![](图片)` 的相对路径按 Markdown 文件所在目录解析(上游 main.py 的 base_dir):`--text @file`
319
+ * 时 dispatch 把文件路径记在 textFile 里,直接给正文时按当前目录。`--cover` 是命令行参数,按当前目录。
320
+ */
321
+ export async function articlePublish(ctx) {
322
+ const o = ctx.options;
323
+ let title = o.title;
324
+ let body = o.text;
325
+ if (title == null)
326
+ [title, body] = splitTitle(body);
327
+ if (!title)
328
+ throw new SpecificAIError('USAGE', '文章标题不能为空:传 --title,或让正文第一行是 `# 标题`');
329
+ const x = await xclient(ctx);
330
+ x.requireLogin();
331
+ const upload = async (input) => {
332
+ const file = await readMedia(x.http, input);
333
+ return api.articleUploadImage(x, file.data, file.filename);
334
+ };
335
+ const baseDir = o.textFile ? dirname(resolve(o.textFile)) : null;
336
+ const inline = (path) => upload(baseDir && !/^https?:\/\//i.test(path) && !isAbsolute(path) ? resolve(baseDir, path) : path);
337
+ const contentState = await markdownToContentState(body, inline);
338
+ const articleId = api.extractArticleId(await api.articleCreateDraft(x));
339
+ let published;
340
+ try {
341
+ await api.articleUpdateTitle(x, articleId, title);
342
+ await api.articleUpdateContent(x, articleId, contentState);
343
+ if (o.cover)
344
+ await api.articleUpdateCover(x, articleId, await upload(o.cover));
345
+ published = await api.articlePublish(x, articleId);
346
+ }
347
+ catch (err) {
348
+ throw withDraft(err, articleId);
349
+ }
350
+ // 发布时同时生成一条带文章卡片的推文(上游 cmd_article 打印的就是它)
351
+ const tweetId = api.articleTweetId(published);
352
+ if (tweetId)
353
+ ctx.log.info(`文章推文:${norm.tweetUrl(tweetId)}`);
354
+ return { id: articleId, url: articleUrl(articleId) };
355
+ }
356
+ /** 删文章:草稿或已发布的都可以,已发布的连同文章推文一起删(不可撤销)。上游 XArticleAPI.delete。 */
357
+ export async function articleDelete(ctx) {
358
+ const x = await xclient(ctx);
359
+ x.requireLogin();
360
+ const id = parseArticleId(ctx.args.article);
361
+ await api.articleDelete(x, id);
362
+ return { id };
363
+ }
@@ -0,0 +1,108 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { SpecificAIError } from '../../../core/errors.js';
3
+ import { staticFile } from '../../../core/paths.js';
4
+ import { compactJson } from '../../../core/py.js';
5
+ /**
6
+ * GraphQL 操作装配,移植自上游 utils/graphql_registry.py + builder/params.py。
7
+ *
8
+ * queryId 与 features 不写死,全部取自 `static/x/graphql.json`(上游从线上前端机器提取的注册表)。
9
+ * 前端发版后在上游跑 `python main.py refresh-graphql`,再把 graphql.json 原样同步过来。
10
+ */
11
+ const GRAPHQL_BASE = 'https://x.com/i/api/graphql';
12
+ let cached = null;
13
+ function registry() {
14
+ cached ??= JSON.parse(readFileSync(staticFile('x', 'graphql.json'), 'utf8'));
15
+ return cached;
16
+ }
17
+ /**
18
+ * 浏览器在各操作上实际发送的 fieldToggles 子集(上游 BROWSER_FIELD_TOGGLES,2026-08-16 / 09-27 实抓)。
19
+ * null 表示浏览器不发 fieldToggles;没登记的操作退回注册表全集。
20
+ */
21
+ const BROWSER_FIELD_TOGGLES = {
22
+ SearchTimeline: null,
23
+ HomeTimeline: null,
24
+ CreateTweet: null,
25
+ // 长推:body 形态与 CreateTweet 相同,同样不发 fieldToggles
26
+ CreateNoteTweet: null,
27
+ // 文章编辑器全链路都不发 fieldToggles,虽然注册表里声明了 withPayments / withAuxiliaryUserLabels
28
+ ArticleEntityDraftCreate: null,
29
+ ArticleEntityUpdateTitle: null,
30
+ ArticleEntityUpdateContent: null,
31
+ ArticleEntityUpdateCoverMedia: null,
32
+ ArticleEntityPublish: null,
33
+ ArticleEntityDelete: null,
34
+ ArticleEntitiesSlice: null,
35
+ UserByScreenName: { withPayments: false, withAuxiliaryUserLabels: true },
36
+ UserTweets: { withArticlePlainText: false },
37
+ UserOriginalsTimeline: { withPayments: false, withArticlePlainText: false },
38
+ TweetDetail: {
39
+ withPayments: false,
40
+ withArticleRichContentState: true,
41
+ withArticlePlainText: false,
42
+ withArticleSummaryText: true,
43
+ withArticleVoiceOver: true,
44
+ withGrokAnalyze: false,
45
+ withDisallowedReplyControls: false,
46
+ },
47
+ TweetResultByRestId: {
48
+ withArticleRichContentState: true,
49
+ withArticlePlainText: false,
50
+ withArticleSummaryText: true,
51
+ withArticleVoiceOver: true,
52
+ withPayments: false,
53
+ },
54
+ };
55
+ /** 一个 GraphQL 操作的完整请求形态(上游 GraphQLOperation)。 */
56
+ export class GraphQLOperation {
57
+ name;
58
+ meta;
59
+ constructor(name) {
60
+ this.name = name;
61
+ const op = registry().operations[name];
62
+ if (!op)
63
+ throw new SpecificAIError('UPSTREAM', `GraphQL 注册表里没有操作 ${name},可能是前端改名或本地注册表过期`);
64
+ this.meta = op;
65
+ }
66
+ /** XCTID 要的 pathname:与真实请求路径一致,不含 query。 */
67
+ path() {
68
+ return `/i/api/graphql/${this.meta.queryId}/${this.name}`;
69
+ }
70
+ url() {
71
+ return `${GRAPHQL_BASE}/${this.meta.queryId}/${this.name}`;
72
+ }
73
+ /** 按操作声明的开关名,从 defaults 取布尔值(上游 build_features)。 */
74
+ features() {
75
+ const defaults = registry().featureDefaults;
76
+ return Object.fromEntries(this.meta.featureSwitches.map((k) => [k, defaults[k] ?? false]));
77
+ }
78
+ /** 浏览器实抓的子集,没登记时用注册表全集(上游 _resolve_toggles)。 */
79
+ fieldToggles() {
80
+ if (this.name in BROWSER_FIELD_TOGGLES)
81
+ return BROWSER_FIELD_TOGGLES[this.name];
82
+ const defaults = registry().featureDefaults;
83
+ return Object.fromEntries(this.meta.fieldToggles.map((k) => [k, defaults[k] ?? false]));
84
+ }
85
+ /** GET 型操作的 query 参数;features / fieldToggles 为空时不带(上游 query_params)。 */
86
+ queryParams(variables) {
87
+ const params = [['variables', compactJson(variables)]];
88
+ const features = this.features();
89
+ if (Object.keys(features).length)
90
+ params.push(['features', compactJson(features)]);
91
+ const toggles = this.fieldToggles();
92
+ if (toggles && Object.keys(toggles).length)
93
+ params.push(['fieldToggles', compactJson(toggles)]);
94
+ return params;
95
+ }
96
+ /** POST 型操作的请求体:variables [+ features] [+ fieldToggles] + queryId(上游 json_body)。 */
97
+ jsonBody(variables) {
98
+ const body = new Map([['variables', variables]]);
99
+ const features = this.features();
100
+ if (Object.keys(features).length)
101
+ body.set('features', features);
102
+ const toggles = this.fieldToggles();
103
+ if (toggles && Object.keys(toggles).length)
104
+ body.set('fieldToggles', toggles);
105
+ body.set('queryId', this.meta.queryId);
106
+ return body;
107
+ }
108
+ }