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,443 @@
1
+ import { SpecificAIError } from '../../../core/errors.js';
2
+ import { md5Hex } from '../../../core/hash.js';
3
+ import { compactJson, quote } from '../../../core/py.js';
4
+ import * as rand from '../../../core/rand.js';
5
+ import { multipartMedia } from './client.js';
6
+ import { API_X, fetchHeaders, PUBLIC_BEARER, UPLOAD_HOST, X_HOST } from './profile.js';
7
+ import { parseScreenName, parseTweetId } from './resolve.js';
8
+ /**
9
+ * 上游 x_apis/*.py 的请求构造,一个函数对应一个上游方法;variables / 字段顺序照抄(对拍逐字节比较)。
10
+ * 键序影响序列化结果,所以 variables 一律用 Map。
11
+ */
12
+ const V = (entries) => new Map(entries);
13
+ // ================================================================ 读接口(XAPI)
14
+ /** 推文详情,含评论区首屏;cursor 翻评论。上游 get_work_info / get_work_comments(TweetDetail)。 */
15
+ export function getWorkInfo(x, workId, cursor, referrer) {
16
+ const tweetId = parseTweetId(workId);
17
+ let entries = [
18
+ ['focalTweetId', tweetId],
19
+ ['with_rux_injections', false],
20
+ ['rankingMode', 'Relevance'],
21
+ ['includePromotedContent', true],
22
+ ['withCommunity', true],
23
+ ['withQuickPromoteEligibilityTweetFields', true],
24
+ ['withBirdwatchNotes', true],
25
+ ['withVoice', true],
26
+ ];
27
+ // 从某个页面点进详情时,referrer 紧跟在 focalTweetId 之后
28
+ if (referrer)
29
+ entries = [entries[0], ['referrer', referrer], ...entries.slice(1)];
30
+ if (cursor)
31
+ entries.push(['cursor', cursor]);
32
+ return x.graphqlGet('TweetDetail', V(entries), { referer: `${X_HOST}/i/status/${tweetId}` });
33
+ }
34
+ /** 详情页伴随的补水请求,游客可用。上游 get_work_result(TweetResultByRestId)。 */
35
+ export function getWorkResult(x, workId) {
36
+ const tweetId = parseTweetId(workId);
37
+ return x.graphqlGet('TweetResultByRestId', V([
38
+ ['tweetId', tweetId],
39
+ ['includePromotedContent', true],
40
+ ['withBirdwatchNotes', true],
41
+ ['withVoice', true],
42
+ ['withCommunity', true],
43
+ ]), { referer: `${X_HOST}/i/status/${tweetId}` });
44
+ }
45
+ /** 搜索。product:Top / Latest / People / Media / Lists。上游 search_work(SearchTimeline,POST)。 */
46
+ export function searchWork(x, query, cursor, product = 'Top', count = 20) {
47
+ const entries = [['rawQuery', query], ['count', count]];
48
+ // 浏览器把 cursor 放在 count 之后、querySource 之前
49
+ if (cursor)
50
+ entries.push(['cursor', cursor]);
51
+ entries.push(['querySource', 'typed_query'], ['product', product], ['withGrokTranslatedBio', true], ['withQuickPromoteEligibilityTweetFields', false]);
52
+ return x.graphqlPostQuery('SearchTimeline', V(entries), { referer: `${X_HOST}/search?q=${quote(query)}&src=typed_query` });
53
+ }
54
+ /** 按 screen_name 取用户信息,接受主页链接、@handle 或裸用户名。上游 get_user_info(UserByScreenName)。 */
55
+ export function getUserInfo(x, userName) {
56
+ const screenName = parseScreenName(userName);
57
+ return x.graphqlGet('UserByScreenName', V([['screen_name', screenName], ['withGrokTranslatedBio', true]]), { referer: `${X_HOST}/${screenName}` });
58
+ }
59
+ /**
60
+ * 按数字用户 ID 取用户信息(UserByRestId)。上游没有这个方法,是 specific-ai 为「输出的 id 可以直接作为参数」
61
+ * 补的:走上游 graphql_get,variables 照 UserByScreenName 的形状,fieldToggles 用注册表全集。
62
+ */
63
+ export function getUserById(x, userId) {
64
+ return x.graphqlGet('UserByRestId', V([['userId', userId], ['withGrokTranslatedBio', true]]), { referer: `${X_HOST}/i/user/${userId}` });
65
+ }
66
+ /**
67
+ * 用户发布的推文。userId 是数字 rest_id。上游 get_user_post_note:默认 UserOriginalsTimeline(登录后
68
+ * 主页 Posts 标签),UserTweets 是老操作(未登录的主页用它)。
69
+ */
70
+ export function getUserPostNote(x, userId, cursor, count = 20, operation = 'UserOriginalsTimeline') {
71
+ const entries = [['userId', userId], ['count', count]];
72
+ if (cursor)
73
+ entries.push(['cursor', cursor]);
74
+ entries.push(['includePromotedContent', true], ['withQuickPromoteEligibilityTweetFields', true], ['withVoice', true]);
75
+ return x.graphqlGet(operation, V(entries));
76
+ }
77
+ /** 主页推荐流。冷启动 GET(没有 seenTweetIds 键),有已读记录时 POST。上游 get_home_timeline。 */
78
+ export function getHomeTimeline(x, cursor, count = 20, seenTweetIds = []) {
79
+ const entries = [['count', count]];
80
+ if (cursor)
81
+ entries.push(['cursor', cursor]);
82
+ entries.push(['includePromotedContent', true]);
83
+ if (!cursor)
84
+ entries.push(['requestContext', 'launch']);
85
+ entries.push(['withCommunity', true]);
86
+ if (!seenTweetIds.length)
87
+ return x.graphqlGet('HomeTimeline', V(entries));
88
+ entries.push(['seenTweetIds', seenTweetIds]);
89
+ return x.graphqlPostQuery('HomeTimeline', V(entries));
90
+ }
91
+ /** 当前会话身份,用来判断 cookie 是否还活着。上游 get_viewer(Viewer)。 */
92
+ export function getViewer(x) {
93
+ return x.graphqlGet('Viewer', V([['withCommunitiesMemberships', true]]));
94
+ }
95
+ // ================================================================ 写接口(XWriteAPI)
96
+ /** CreateTweet / CreateNoteTweet 共用的 variables,两者逐字段相同。上游 _tweet_variables。 */
97
+ function tweetVariables(text, mediaIds, replyTo, quoteUrl, excludeReplyUserIds = []) {
98
+ const entries = [
99
+ ['tweet_text', text],
100
+ ['media', { media_entities: mediaIds.map((id) => ({ media_id: String(id), tagged_users: [] })), possibly_sensitive: false }],
101
+ ['semantic_annotation_ids', []],
102
+ ['disallowed_reply_options', null],
103
+ ['semantic_annotation_options', { source: 'UniversalLink' }],
104
+ ];
105
+ if (replyTo)
106
+ entries.push(['reply', { in_reply_to_tweet_id: parseTweetId(replyTo), exclude_reply_user_ids: excludeReplyUserIds }]);
107
+ if (quoteUrl)
108
+ entries.push(['attachment_url', quoteUrl]);
109
+ return V(entries);
110
+ }
111
+ /** 发推;replyTo 给出即为回复,quoteUrl 给出即为引用。上游 create_tweet(CreateTweet)。 */
112
+ export function createTweet(x, text, mediaIds = [], replyTo, quoteUrl, excludeReplyUserIds = []) {
113
+ // 浏览器打开 compose/post 后,真正提交请求的来源仍是 /home
114
+ return x.graphqlPost('CreateTweet', tweetVariables(text, mediaIds, replyTo, quoteUrl, excludeReplyUserIds), { referer: `${X_HOST}/home` });
115
+ }
116
+ /**
117
+ * 长推(Premium,权重 > 280):网页端正文超长时发帖按钮提交的是 CreateNoteTweet,variables 与 CreateTweet 相同。
118
+ * 非 Premium 账号会被服务端拒绝。上游 create_note_tweet。
119
+ */
120
+ export function createNoteTweet(x, text, mediaIds = [], replyTo, quoteUrl, excludeReplyUserIds = []) {
121
+ return x.graphqlPost('CreateNoteTweet', tweetVariables(text, mediaIds, replyTo, quoteUrl, excludeReplyUserIds), { referer: `${X_HOST}/home` });
122
+ }
123
+ /** 普通推的权重上限(上游 TWEET_WEIGHT_LIMIT)。 */
124
+ export const TWEET_WEIGHT_LIMIT = 280;
125
+ const URL_WEIGHT = 23;
126
+ const LIGHT_RANGES = [
127
+ [0, 4351],
128
+ [8192, 8205],
129
+ [8208, 8223],
130
+ [8242, 8247],
131
+ ];
132
+ /** Python `re` 的 `\s`(str.isspace 的全集),与 JS 的 `\s` 不同:多了 0x1c-0x1f、0x85,少了 0xfeff。 */
133
+ const PY_SPACE = '\\t\\n\\v\\f\\r\\x1c-\\x20\\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
134
+ /** 链接:遇到空白、CJK 标点或全角字符即结束(上游 _URL_RE,IGNORECASE)。 */
135
+ const URL_RE = new RegExp(`https?://[^${PY_SPACE}\\u3000-\\u303f\\uff00-\\uffef]+`, 'giu');
136
+ /** emoji 序列里不单独计数的码点:ZWJ、变体选择符、keycap;肤色修饰另判。 */
137
+ const EMOJI_JOINERS = new Set([0x200d, 0xfe0e, 0xfe0f, 0x20e3]);
138
+ function plainWeight(text) {
139
+ let weight = 0;
140
+ let joined = false;
141
+ for (const ch of text) {
142
+ const cp = ch.codePointAt(0);
143
+ if (EMOJI_JOINERS.has(cp) || (cp >= 0x1f3fb && cp <= 0x1f3ff)) {
144
+ // ZWJ 之后紧跟的码点属于同一个 emoji,不再计数
145
+ joined = cp === 0x200d;
146
+ continue;
147
+ }
148
+ if (joined) {
149
+ joined = false;
150
+ continue;
151
+ }
152
+ weight += LIGHT_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi) ? 1 : 2;
153
+ }
154
+ return weight;
155
+ }
156
+ /** 按 twitter-text v3 规则计算推文权重:拉丁和常用标点算 1,其余(CJK、emoji)算 2,链接一律 23。上游 tweet_weight。 */
157
+ export function tweetWeight(text) {
158
+ const s = text ?? '';
159
+ let weight = 0;
160
+ let cursor = 0;
161
+ for (const m of s.matchAll(URL_RE)) {
162
+ weight += plainWeight(s.slice(cursor, m.index)) + URL_WEIGHT;
163
+ cursor = m.index + m[0].length;
164
+ }
165
+ return weight + plainWeight(s.slice(cursor));
166
+ }
167
+ /** 发一条推:权重超过 280 自动改发长推。上游 post_tweet 的 note=None 分支(媒体已传好)。 */
168
+ export function postTweet(x, text, mediaIds = [], replyTo, quoteUrl) {
169
+ const create = tweetWeight(text) > TWEET_WEIGHT_LIMIT ? createNoteTweet : createTweet;
170
+ return create(x, text, mediaIds, replyTo, quoteUrl);
171
+ }
172
+ /** CreateTweet 响应里的新推文对象(长推文在 notetweet_create 下)。上游 extract_tweet_id 的取值路径。 */
173
+ export function createdTweet(res) {
174
+ const data = res?.data ?? {};
175
+ for (const key of ['create_tweet', 'notetweet_create']) {
176
+ const result = data[key]?.tweet_results?.result;
177
+ if (result)
178
+ return result;
179
+ }
180
+ throw new SpecificAIError('UPSTREAM', 'CreateTweet 响应里没有新推文', { detail: res });
181
+ }
182
+ export function deleteTweet(x, workId) {
183
+ return x.graphqlPost('DeleteTweet', V([['tweet_id', parseTweetId(workId)]]));
184
+ }
185
+ export function favoriteTweet(x, workId) {
186
+ return x.graphqlPost('FavoriteTweet', V([['tweet_id', parseTweetId(workId)]]));
187
+ }
188
+ export function unfavoriteTweet(x, workId) {
189
+ return x.graphqlPost('UnfavoriteTweet', V([['tweet_id', parseTweetId(workId)]]));
190
+ }
191
+ export function createRetweet(x, workId) {
192
+ return x.graphqlPost('CreateRetweet', V([['tweet_id', parseTweetId(workId)], ['dark_request', false]]));
193
+ }
194
+ export function deleteRetweet(x, workId) {
195
+ return x.graphqlPost('DeleteRetweet', V([['source_tweet_id', parseTweetId(workId)], ['dark_request', false]]));
196
+ }
197
+ export function createBookmark(x, workId) {
198
+ return x.graphqlPost('CreateBookmark', V([['tweet_id', parseTweetId(workId)]]));
199
+ }
200
+ export function deleteBookmark(x, workId) {
201
+ return x.graphqlPost('DeleteBookmark', V([['tweet_id', parseTweetId(workId)]]));
202
+ }
203
+ const FRIENDSHIP_FIELDS = [
204
+ ['include_profile_interstitial_type', '1'],
205
+ ['include_blocking', '1'],
206
+ ['include_blocked_by', '1'],
207
+ ['include_followed_by', '1'],
208
+ ['include_want_retweets', '1'],
209
+ ['include_mute_edge', '1'],
210
+ ['include_can_dm', '1'],
211
+ ['include_can_media_tag', '1'],
212
+ ['skip_status', '1'],
213
+ ];
214
+ export function followUser(x, userId) {
215
+ return x.restPost('/friendships/create.json', [...FRIENDSHIP_FIELDS, ['user_id', userId]]);
216
+ }
217
+ export function unfollowUser(x, userId) {
218
+ return x.restPost('/friendships/destroy.json', [...FRIENDSHIP_FIELDS, ['user_id', userId]]);
219
+ }
220
+ // ================================================================ 媒体上传(XMediaAPI)
221
+ const UPLOAD_URL = `${UPLOAD_HOST}/i/media/upload.json`;
222
+ const METADATA_PATH = '/i/api/1.1/media/metadata/create.json';
223
+ const MIME_BY_EXT = {
224
+ jpg: 'image/jpeg',
225
+ jpeg: 'image/jpeg',
226
+ png: 'image/png',
227
+ gif: 'image/gif',
228
+ webp: 'image/webp',
229
+ mp4: 'video/mp4',
230
+ mov: 'video/quicktime',
231
+ };
232
+ const CATEGORY_BY_MIME = { 'image/gif': 'tweet_gif', 'video/mp4': 'tweet_video', 'video/quicktime': 'tweet_video' };
233
+ /** 单片 4MB。 */
234
+ export const CHUNK_SIZE = 4 * 1024 * 1024;
235
+ const PROCESSING_TIMEOUT = 180_000;
236
+ /** 按扩展名猜 MIME,猜不出按 image/jpeg(上游 guess_media_type)。 */
237
+ export function guessMediaType(filename) {
238
+ const dot = filename.lastIndexOf('.');
239
+ return MIME_BY_EXT[dot < 0 ? '' : filename.slice(dot + 1).toLowerCase()] ?? 'image/jpeg';
240
+ }
241
+ export function guessMediaCategory(mediaType) {
242
+ return CATEGORY_BY_MIME[mediaType] ?? 'tweet_image';
243
+ }
244
+ /** upload.x.com 的头:不带 XCTID / client-language / active-user(上游 _upload_headers)。 */
245
+ function uploadHeaders(x) {
246
+ return x.buildHeaders('UPLOAD', UPLOAD_URL, { referer: `${X_HOST}/`, origin: X_HOST });
247
+ }
248
+ /** 申请 media_id,参数走 query string。上游 XMediaAPI.init。 */
249
+ export async function mediaInit(x, totalBytes, mediaType, mediaCategory) {
250
+ const query = [
251
+ ['command', 'INIT'],
252
+ ['total_bytes', String(totalBytes)],
253
+ ['media_type', mediaType],
254
+ ['media_category', mediaCategory ?? guessMediaCategory(mediaType)],
255
+ ];
256
+ const res = await x.request({ method: 'POST', url: UPLOAD_URL, query, headers: uploadHeaders(x) });
257
+ return (await x.checkJson(res)).media_id_string;
258
+ }
259
+ /** 上传一个分片:命令走 query,分片数据走 multipart。上游 XMediaAPI.append。 */
260
+ export async function mediaAppend(x, mediaId, chunk, segmentIndex) {
261
+ const res = await x.request({
262
+ method: 'POST',
263
+ url: UPLOAD_URL,
264
+ query: [['command', 'APPEND'], ['media_id', mediaId], ['segment_index', String(segmentIndex)]],
265
+ headers: uploadHeaders(x),
266
+ multipart: [multipartMedia(chunk)],
267
+ });
268
+ await x.checkOk(res);
269
+ }
270
+ /** 收尾,带整个文件的 md5。上游 XMediaAPI.finalize。 */
271
+ export async function mediaFinalize(x, mediaId, originalMd5) {
272
+ const query = [['command', 'FINALIZE'], ['media_id', mediaId]];
273
+ if (originalMd5)
274
+ query.push(['original_md5', originalMd5]);
275
+ return x.checkJson(await x.request({ method: 'POST', url: UPLOAD_URL, query, headers: uploadHeaders(x) }));
276
+ }
277
+ /** 查询转码状态。上游 XMediaAPI.status。 */
278
+ export async function mediaStatus(x, mediaId) {
279
+ const res = await x.request({ method: 'GET', url: UPLOAD_URL, query: [['command', 'STATUS'], ['media_id', mediaId]], headers: uploadHeaders(x) });
280
+ return x.checkJson(res);
281
+ }
282
+ /** 登记媒体的下载权限元数据(FINALIZE 之后、发推之前),响应为空。上游 XMediaAPI.metadata_create。 */
283
+ export async function mediaMetadataCreate(x, mediaId, allowDownload = true, referer = `${X_HOST}/home`) {
284
+ const url = `${X_HOST}${METADATA_PATH}`;
285
+ const res = await x.request({
286
+ method: 'POST',
287
+ url,
288
+ json: new Map([
289
+ ['media_id', String(mediaId)],
290
+ ['allow_download_status', { allow_download: allowDownload ? 'true' : 'false' }],
291
+ ]),
292
+ headers: x.buildHeaders('GRAPHQL', url, { xctid: x.transactionId('POST', METADATA_PATH), referer }),
293
+ });
294
+ await x.checkOk(res);
295
+ }
296
+ /** 视频要等服务端转码完成才能发推;图片没有 processing_info,直接返回。上游 XMediaAPI.wait_processing。 */
297
+ export async function waitProcessing(x, mediaId, finalizeResult) {
298
+ let result = finalizeResult;
299
+ let info = result?.processing_info;
300
+ const deadline = rand.now() + PROCESSING_TIMEOUT;
301
+ while (info && (info.state === 'pending' || info.state === 'in_progress')) {
302
+ if (rand.now() > deadline)
303
+ throw new SpecificAIError('UPSTREAM', `媒体 ${mediaId} 转码超时`);
304
+ await rand.sleep(Math.max(Number(info.check_after_secs ?? 1), 1) * 1000);
305
+ result = await mediaStatus(x, mediaId);
306
+ info = result?.processing_info;
307
+ }
308
+ if (info?.state === 'failed')
309
+ throw new SpecificAIError('UPSTREAM', `媒体 ${mediaId} 转码失败`, { detail: info.error ?? info });
310
+ return result;
311
+ }
312
+ /**
313
+ * 完整上传一个文件,返回 media_id 和 FINALIZE / STATUS 的结果。上游 XMediaAPI.upload。
314
+ * withMetadata 为 false 时 FINALIZE 之后就结束,不登记元数据(文章编辑器的封面 / 插图就是这样)。
315
+ */
316
+ export async function upload(x, data, filename, options = {}) {
317
+ const mediaType = guessMediaType(filename);
318
+ const mediaId = await mediaInit(x, data.length, mediaType, options.mediaCategory);
319
+ for (let index = 0, offset = 0; offset < data.length; index++, offset += CHUNK_SIZE) {
320
+ await mediaAppend(x, mediaId, data.subarray(offset, offset + CHUNK_SIZE), index);
321
+ }
322
+ const finalized = await mediaFinalize(x, mediaId, md5Hex(data));
323
+ const result = await waitProcessing(x, mediaId, finalized);
324
+ if (options.withMetadata !== false)
325
+ await mediaMetadataCreate(x, mediaId);
326
+ return { mediaId, mediaType, result };
327
+ }
328
+ // ================================================================ 文章(XArticleAPI)
329
+ const COMPOSE_URL = `${X_HOST}/compose/articles`;
330
+ /** 文章草稿的编辑页,也是这组请求的 referer。 */
331
+ export const articleEditUrl = (articleId) => `${COMPOSE_URL}/edit/${articleId}`;
332
+ /** 新建草稿:浏览器点「新建」时发的是空标题 + 空正文。上游 create_draft(ArticleEntityDraftCreate)。 */
333
+ export function articleCreateDraft(x) {
334
+ return x.graphqlPost('ArticleEntityDraftCreate', V([['content_state', { blocks: [], entity_map: [] }], ['title', '']]), { referer: COMPOSE_URL });
335
+ }
336
+ /**
337
+ * 从任意 ArticleEntity* 响应里取出文章 rest_id。建草稿的结果包了一层 article_entity_results.result;
338
+ * `id` 是 relay 全局 id:base64("ArticleEntity:<rest_id>")。上游 extract_article_id。
339
+ */
340
+ export function extractArticleId(res) {
341
+ for (const value of Object.values(res?.data ?? {})) {
342
+ if (!value || typeof value !== 'object' || Array.isArray(value))
343
+ continue;
344
+ const v = value;
345
+ const result = v.article_entity_results?.result ?? v;
346
+ if (result?.rest_id)
347
+ return String(result.rest_id);
348
+ if (result?.id)
349
+ return Buffer.from(String(result.id), 'base64').toString('utf8').split(':').slice(1).join(':');
350
+ }
351
+ throw new SpecificAIError('UPSTREAM', '响应里没有文章 id', { detail: res });
352
+ }
353
+ /** 改标题。上游 update_title(ArticleEntityUpdateTitle)。 */
354
+ export function articleUpdateTitle(x, articleId, title) {
355
+ return x.graphqlPost('ArticleEntityUpdateTitle', V([['articleEntityId', String(articleId)], ['title', title]]), { referer: articleEditUrl(articleId) });
356
+ }
357
+ /** 整篇覆盖正文;这里文章 id 的键名是 `article_entity`。上游 update_content(ArticleEntityUpdateContent)。 */
358
+ export function articleUpdateContent(x, articleId, contentState) {
359
+ return x.graphqlPost('ArticleEntityUpdateContent', V([['content_state', contentState], ['article_entity', String(articleId)]]), {
360
+ referer: articleEditUrl(articleId),
361
+ });
362
+ }
363
+ /** 文章图片的 media_category(封面和正文插图都是它)。上游 MEDIA_CATEGORY。 */
364
+ export const ARTICLE_MEDIA_CATEGORY = 'DraftTweetImage';
365
+ /** 设封面,编辑器建议 5:2。上游 update_cover(ArticleEntityUpdateCoverMedia)。 */
366
+ export function articleUpdateCover(x, articleId, mediaId) {
367
+ return x.graphqlPost('ArticleEntityUpdateCoverMedia', V([
368
+ ['articleEntityId', String(articleId)],
369
+ ['coverMedia', { media_id: String(mediaId), media_category: ARTICLE_MEDIA_CATEGORY }],
370
+ ]), { referer: articleEditUrl(articleId) });
371
+ }
372
+ /**
373
+ * 发布草稿,同时生成一条带文章卡片的推文。说明文字(tweetText)为空时浏览器不带这个键;
374
+ * 谁可以回复默认所有人,也不带 conversationControl。上游 publish(ArticleEntityPublish)。
375
+ */
376
+ export function articlePublish(x, articleId, visibility = 'Public') {
377
+ return x.graphqlPost('ArticleEntityPublish', V([['articleEntityId', String(articleId)], ['visibilitySetting', visibility]]), { referer: articleEditUrl(articleId) });
378
+ }
379
+ /** 发布响应里那条文章推文的 id。上游 XArticleAPI.extract_tweet_id。 */
380
+ export function articleTweetId(res) {
381
+ const id = res?.data?.articleentity_publish?.article_entity_results?.result?.metadata?.tweet_results?.result?.rest_id;
382
+ return id == null ? null : String(id);
383
+ }
384
+ /** 删除草稿或已发布文章(不可撤销),已发布的连同文章推文一起消失。上游 delete(ArticleEntityDelete)。 */
385
+ export function articleDelete(x, articleId) {
386
+ return x.graphqlPost('ArticleEntityDelete', V([['articleEntityId', String(articleId)]]), { referer: articleEditUrl(articleId) });
387
+ }
388
+ /** 文章封面 / 插图上传:与发推同一套分片上传,但不登记元数据。上游 upload_image。 */
389
+ export async function articleUploadImage(x, data, filename) {
390
+ return (await upload(x, data, filename, { withMetadata: false })).mediaId;
391
+ }
392
+ // ================================================================ 私信(XChatAPI)
393
+ const MEMBERS_QID = '4zbPmRwsca859ORbYCLyzg';
394
+ const INITIAL_PAGE_QID = 'm1gzpOV8JFOTaFH0Xq7lMQ';
395
+ const CONVERSATION_PAGE_QID = 'GX9ZijkxG8AqRMQVD7hMnQ';
396
+ const DEFAULT_QUERY_SETTINGS = { conversation_event_limit: 200, inbox_conversation_event_limit: 5, inbox_conversation_limit: 20, user_event_limit: 500 };
397
+ /**
398
+ * api.x.com 上 X Chat 请求的头(上游 _external_headers 的排列):
399
+ * authorization, x-csrf-token, referer, [apollo-require-preflight], x-client-transaction-id,
400
+ * [x-twitter-auth-type], accept, [content-type], origin,再接浏览器 fetch 头(accept 已显式给出)。
401
+ */
402
+ function chatHeaders(x, path, options = {}) {
403
+ const h = [
404
+ ['authorization', PUBLIC_BEARER],
405
+ ['x-csrf-token', x.ct0],
406
+ ['referer', `${X_HOST}/`],
407
+ ];
408
+ if (options.apollo)
409
+ h.push(['apollo-require-preflight', 'true']);
410
+ h.push(['x-client-transaction-id', x.transactionId(options.contentType ? 'POST' : 'GET', path)]);
411
+ if (x.isLoggedIn)
412
+ h.push(['x-twitter-auth-type', 'OAuth2Session']);
413
+ h.push(['accept', 'application/json']);
414
+ if (options.contentType)
415
+ h.push(['content-type', options.contentType]);
416
+ h.push(['origin', X_HOST]);
417
+ return [...h, ...fetchHeaders(`${API_X}${path}`).filter(([k]) => k !== 'accept')];
418
+ }
419
+ async function chatQuery(x, path, variables) {
420
+ const res = await x.request({ method: 'GET', url: `${API_X}${path}`, query: [['variables', compactJson(variables)]], headers: chatHeaders(x, path, { apollo: true }) });
421
+ return x.checkJson(res);
422
+ }
423
+ /** X Chat 收件箱初始页。上游 get_initial_chat_page。 */
424
+ export function getInitialChatPage(x) {
425
+ return chatQuery(x, `/graphql/${INITIAL_PAGE_QID}/GetInitialXChatPageQuery`, {
426
+ max_local_sequence_id: null,
427
+ query_settings: { ...DEFAULT_QUERY_SETTINGS },
428
+ message_pull_version: null,
429
+ });
430
+ }
431
+ /** X Chat 成员资料(名字、用户名)。上游 get_users_by_ids(GetUsersByIdsForXChat)。 */
432
+ export function getUsersByIds(x, userIds) {
433
+ return chatQuery(x, `/graphql/${MEMBERS_QID}/GetUsersByIdsForXChat`, { ids: userIds.map(String), with_social_proof: false });
434
+ }
435
+ /** 单个会话页(不发送消息)。上游 get_conversation_page。 */
436
+ export function getConversationPage(x, conversationId) {
437
+ return chatQuery(x, `/graphql/${CONVERSATION_PAGE_QID}/GetConversationPageQuery`, {
438
+ conversation_id: String(conversationId),
439
+ min_local_sequence_id: '9223372036854775807',
440
+ min_conversation_key_version: '9223372036854775807',
441
+ query_settings: { ...DEFAULT_QUERY_SETTINGS },
442
+ });
443
+ }
@@ -0,0 +1,212 @@
1
+ import { SpecificAIError } from '../../../core/errors.js';
2
+ import * as rand from '../../../core/rand.js';
3
+ import { ARTICLE_MEDIA_CATEGORY } from './api.js';
4
+ /**
5
+ * X 文章(Article)正文装配:Markdown → Draft.js content_state,移植自上游 utils/article_util.py。
6
+ *
7
+ * 结构与浏览器一致:键名是 snake_case(entity_ranges / inline_style_ranges / entity_map),entity_map 是
8
+ * `[{key, value}]` 数组,样式名是 Bold / Italic / Strikethrough。offset / length 按 Unicode 码点计
9
+ * (Python 的 len()),不是 JS 字符串的 UTF-16 下标,所以这里一律用 `[...s].length`。
10
+ *
11
+ * 正则照抄上游,只把 Python 与 JS 语义不同的地方换掉:`\s` 换成 Python 的空白全集,`.` 换成 `[^\n]`
12
+ * (JS 的 `.` 还不匹配 \r 和 U+2028 / U+2029),`\d` 换成 `\p{Nd}`。
13
+ */
14
+ /** Python `re` / `str.strip()` 的空白(str.isspace 的全集)。 */
15
+ const WS = '\\t\\n\\v\\f\\r\\x1c-\\x20\\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
16
+ /** 按上游的正则写,`\s` 换成 Python 的空白全集。 */
17
+ const re = (source) => new RegExp(source.replaceAll('\\s', `[${WS}]`), 'u');
18
+ const HEADING_RE = re('^(#{1,6})\\s+([^\\n]*)$');
19
+ const UL_RE = re('^[-*+]\\s+([^\\n]*)$');
20
+ const OL_RE = re('^\\p{Nd}+[.)]\\s+([^\\n]*)$');
21
+ const QUOTE_RE = re('^>\\s?([^\\n]*)$');
22
+ const DIVIDER_RE = re('^(?:-{3,}|\\*{3,}|_{3,})$');
23
+ const IMAGE_RE = new RegExp(`^!\\[([^\\]]*)\\]\\(([^)${WS}]+)(?:[${WS}]+"[^"]*")?\\)$`, 'u');
24
+ const FENCE_RE = re('^(`{3,}|~{3,})([^\\n]*)$');
25
+ const TITLE_RE = re('^#\\s+([^\\n]*)$');
26
+ // 行内记号:顺序即优先级,** 必须排在 * 前面。粗体内部允许完整的 *斜体* 和转义字符,这样 `**粗 *斜***` 能正确闭合。
27
+ const ESC = '\\\\[\\\\`*_~\\[\\]()!#>\\-]';
28
+ const ITALIC_BODY = `[^*${WS}](?:(?:${ESC}|[^*\\\\])*?[^*${WS}\\\\])?`;
29
+ const INLINE_RE = new RegExp('\\\\(?<esc>[\\\\`*_~\\[\\]()!#>\\-])' +
30
+ `|\\*\\*(?<bold>(?:${ESC}|\\*${ITALIC_BODY}\\*|[^*\\\\])+?)\\*\\*` +
31
+ '|~~(?<strike>[^\\n]+?)~~' +
32
+ `|\\*(?<italic>${ITALIC_BODY})\\*` +
33
+ `|\\[(?<ltext>[^\\]]+)\\]\\((?<lurl>[^)${WS}]+)\\)`, 'gu');
34
+ const STYLE_BY_GROUP = { bold: 'Bold', italic: 'Italic', strike: 'Strikethrough' };
35
+ const BLOCK_KEY_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
36
+ /** Python 的 `str.strip()` / `str.rstrip()`。 */
37
+ const strip = (s) => s.replace(new RegExp(`^[${WS}]+|[${WS}]+$`, 'gu'), '');
38
+ const rstrip = (s) => s.replace(new RegExp(`[${WS}]+$`, 'u'), '');
39
+ /** 码点数,对应 Python 的 `len()`。 */
40
+ const len = (s) => [...s].length;
41
+ /** Draft.js genKey:5 位 base36 随机串。上游 gen_block_key(random.choices)。 */
42
+ export function genBlockKey() {
43
+ return rand.string(5, BLOCK_KEY_ALPHABET);
44
+ }
45
+ /** 逐块拼 content_state。上游 ContentStateBuilder。 */
46
+ class ContentStateBuilder {
47
+ blocks = [];
48
+ entityMap = [];
49
+ mediaSeq = 0;
50
+ addEntity(type, data, mutability = 'Immutable') {
51
+ const key = this.entityMap.length;
52
+ this.entityMap.push({ key: String(key), value: { data, type, mutability } });
53
+ return key;
54
+ }
55
+ addText(text, blockType = 'unstyled', styles = [], entityRanges = []) {
56
+ this.blocks.push({ data: {}, text, key: genBlockKey(), type: blockType, entity_ranges: entityRanges, inline_style_ranges: styles });
57
+ }
58
+ /** 带行内 Markdown 记号的一段文字。 */
59
+ addMarkdownText(source, blockType = 'unstyled') {
60
+ const [text, styles, links] = parseInline(source);
61
+ const entityRanges = links.map(([offset, length, url]) => ({ key: this.addEntity('LINK', { url }, 'Mutable'), offset, length }));
62
+ this.addText(text, blockType, styles, entityRanges);
63
+ }
64
+ /** atomic 块的文本固定是一个空格,实体挂在这个空格上。 */
65
+ addAtomic(entityKey) {
66
+ this.addText(' ', 'atomic', [], [{ key: entityKey, offset: 0, length: 1 }]);
67
+ }
68
+ addDivider() {
69
+ this.addAtomic(this.addEntity('DIVIDER', {}));
70
+ }
71
+ /** 插图:一个 MEDIA 块最多 4 张图(编辑器限制)。 */
72
+ addMedia(mediaIds) {
73
+ const items = mediaIds.map((id) => ({ local_media_id: ++this.mediaSeq, media_category: ARTICLE_MEDIA_CATEGORY, media_id: String(id) }));
74
+ // 与上游求值顺序一致:先生成 uuid(实体),再生成块的 key
75
+ this.addAtomic(this.addEntity('MEDIA', { entity_key: rand.uuid4(), media_items: items }));
76
+ }
77
+ /** 代码块:编辑器「插入 → 代码」存成 MARKDOWN 实体,内容是整段围栏。 */
78
+ addCode(code, language = '') {
79
+ this.addAtomic(this.addEntity('MARKDOWN', { markdown: '```' + language + '\n' + code + '\n```' }, 'Mutable'));
80
+ }
81
+ build() {
82
+ return { blocks: this.blocks, entity_map: this.entityMap };
83
+ }
84
+ }
85
+ /** 同一样式首尾相接的区间合并成一段(编辑器也是这样保存的)。上游 _merge_adjacent。 */
86
+ function mergeAdjacent(styles) {
87
+ const cmp = (a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0;
88
+ const merged = [];
89
+ for (const item of [...styles].sort((a, b) => cmp([a.style, a.offset], [b.style, b.offset]))) {
90
+ const last = merged.at(-1);
91
+ if (last && last.style === item.style && last.offset + last.length >= item.offset) {
92
+ const end = Math.max(last.offset + last.length, item.offset + item.length);
93
+ last.length = end - last.offset;
94
+ }
95
+ else
96
+ merged.push({ ...item });
97
+ }
98
+ return merged.sort((a, b) => cmp([a.offset, a.style], [b.offset, b.style]));
99
+ }
100
+ /**
101
+ * 解析行内记号,返回 [纯文本, inline_style_ranges, [offset, length, url][]]。支持嵌套,offset 按码点计。
102
+ * 上游 parse_inline。
103
+ */
104
+ export function parseInline(source, baseStyles = []) {
105
+ const out = [];
106
+ const styles = [];
107
+ const links = [];
108
+ let size = 0;
109
+ let cursor = 0;
110
+ const emit = (text, active) => {
111
+ if (!text)
112
+ return;
113
+ const offset = size;
114
+ const length = len(text);
115
+ out.push(text);
116
+ size += length;
117
+ for (const style of active)
118
+ styles.push({ length, offset, style });
119
+ };
120
+ const merge = (text, innerStyles, innerLinks) => {
121
+ const base = size;
122
+ out.push(text);
123
+ size += len(text);
124
+ for (const s of innerStyles)
125
+ styles.push({ ...s, offset: s.offset + base });
126
+ for (const [o, n, u] of innerLinks)
127
+ links.push([o + base, n, u]);
128
+ };
129
+ for (const m of source.matchAll(INLINE_RE)) {
130
+ emit(source.slice(cursor, m.index), baseStyles);
131
+ cursor = m.index + m[0].length;
132
+ const g = m.groups;
133
+ if (g.esc !== undefined)
134
+ emit(g.esc, baseStyles);
135
+ else if (g.ltext !== undefined) {
136
+ const [text, innerStyles, innerLinks] = parseInline(g.ltext, baseStyles);
137
+ const offset = size;
138
+ merge(text, innerStyles, innerLinks);
139
+ links.push([offset, len(text), g.lurl]);
140
+ }
141
+ else {
142
+ const group = ['bold', 'strike', 'italic'].find((k) => g[k] !== undefined);
143
+ const [text, innerStyles, innerLinks] = parseInline(g[group], [...baseStyles, STYLE_BY_GROUP[group]]);
144
+ merge(text, innerStyles, innerLinks);
145
+ }
146
+ }
147
+ emit(source.slice(cursor), baseStyles);
148
+ return [out.join(''), mergeAdjacent(styles), links];
149
+ }
150
+ /**
151
+ * Markdown → content_state。每个非空行是一个块,空行只做分隔。遇到独占一行的 `![](path)` 时调用
152
+ * uploadImage 取 media_id;没给 uploadImage 时报错,避免静默丢图。上游 markdown_to_content_state。
153
+ */
154
+ export async function markdownToContentState(markdown, uploadImage) {
155
+ const builder = new ContentStateBuilder();
156
+ const lines = (markdown ?? '').replaceAll('\r\n', '\n').split('\n');
157
+ let index = 0;
158
+ while (index < lines.length) {
159
+ const line = rstrip(lines[index]);
160
+ index++;
161
+ const stripped = strip(line);
162
+ if (!stripped)
163
+ continue;
164
+ const fence = FENCE_RE.exec(stripped);
165
+ if (fence) {
166
+ const [, marker, language] = fence;
167
+ const code = [];
168
+ while (index < lines.length && !strip(lines[index]).startsWith(marker))
169
+ code.push(lines[index++]);
170
+ index++; // 跳过收尾围栏
171
+ builder.addCode(code.join('\n'), strip(language));
172
+ continue;
173
+ }
174
+ if (DIVIDER_RE.test(stripped)) {
175
+ builder.addDivider();
176
+ continue;
177
+ }
178
+ const image = IMAGE_RE.exec(stripped);
179
+ if (image) {
180
+ if (!uploadImage)
181
+ throw new SpecificAIError('USAGE', `正文里有图片 ${image[2]},但没有提供上传方式`);
182
+ builder.addMedia([await uploadImage(image[2])]);
183
+ continue;
184
+ }
185
+ const heading = HEADING_RE.exec(stripped);
186
+ if (heading) {
187
+ builder.addMarkdownText(strip(heading[2]), heading[1].length === 1 ? 'header-one' : 'header-two');
188
+ continue;
189
+ }
190
+ const block = [
191
+ [UL_RE, 'unordered-list-item'],
192
+ [OL_RE, 'ordered-list-item'],
193
+ [QUOTE_RE, 'blockquote'],
194
+ ].find(([r]) => r.test(stripped));
195
+ if (block)
196
+ builder.addMarkdownText(strip(block[0].exec(stripped)[1]), block[1]);
197
+ else
198
+ builder.addMarkdownText(stripped);
199
+ }
200
+ return builder.build();
201
+ }
202
+ /**
203
+ * 正文第一行是 `# 标题` 时拆出来当文章标题,返回 [标题, 剩余正文];否则 [null, 原文]。
204
+ * 文章标题在编辑器里是单独字段,正文里再放一个一级标题会重复显示。上游 split_title。
205
+ */
206
+ export function splitTitle(markdown) {
207
+ const lines = (markdown ?? '').replace(/^\n+/, '').split('\n');
208
+ const m = TITLE_RE.exec(strip(lines[0]));
209
+ if (m)
210
+ return [strip(m[1]), lines.slice(1).join('\n')];
211
+ return [null, markdown];
212
+ }