@jackwener/opencli 1.7.22 → 1.8.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 (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -1,111 +1,481 @@
1
1
  /**
2
2
  * Twitter/X download — download images and videos from tweets.
3
3
  *
4
+ * Profile media path uses the same GraphQL UserMedia endpoint the
5
+ * native client uses with cursor-based pagination, so it bypasses the
6
+ * virtual-scroll DOM cap that limited the previous scraper to ~visible
7
+ * tiles (see #1612).
8
+ *
4
9
  * Usage:
5
- * opencli twitter download elonmusk --limit 10 --output ./twitter
10
+ * opencli twitter download elonmusk --limit 50 --output ./twitter
6
11
  * opencli twitter download --tweet-url https://x.com/xxx/status/123 --output ./twitter
7
12
  */
8
13
  import { cli, Strategy } from '@jackwener/opencli/registry';
14
+ import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
9
15
  import { formatCookieHeader } from '@jackwener/opencli/download';
10
16
  import { downloadMedia } from '@jackwener/opencli/download/media-download';
17
+ import {
18
+ resolveTwitterOperationMetadata,
19
+ normalizeTwitterGraphqlPayload,
20
+ unwrapBrowserResult,
21
+ normalizeTwitterScreenName,
22
+ extractMedia,
23
+ parseTweetUrl,
24
+ } from './shared.js';
25
+ import { TWITTER_BEARER_TOKEN } from './utils.js';
26
+
27
+ const USER_MEDIA_QUERY_ID = '9EovraBTXJYGSEQXZqlLmQ';
28
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
29
+ const MAX_PAGINATION_PAGES = 100;
30
+
31
+ const USER_MEDIA_FEATURES = {
32
+ rweb_video_screen_enabled: true,
33
+ rweb_cashtags_enabled: true,
34
+ profile_label_improvements_pcf_label_in_post_enabled: true,
35
+ responsive_web_profile_redirect_enabled: true,
36
+ rweb_tipjar_consumption_enabled: true,
37
+ verified_phone_label_enabled: false,
38
+ creator_subscriptions_tweet_preview_api_enabled: true,
39
+ responsive_web_graphql_timeline_navigation_enabled: true,
40
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
41
+ premium_content_api_read_enabled: false,
42
+ communities_web_enable_tweet_community_results_fetch: true,
43
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
44
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
45
+ responsive_web_grok_analyze_post_followups_enabled: true,
46
+ rweb_cashtags_composer_attachment_enabled: true,
47
+ responsive_web_jetfuel_frame: true,
48
+ responsive_web_grok_share_attachment_enabled: true,
49
+ responsive_web_grok_annotations_enabled: true,
50
+ articles_preview_enabled: true,
51
+ responsive_web_edit_tweet_api_enabled: true,
52
+ rweb_conversational_replies_downvote_enabled: true,
53
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
54
+ view_counts_everywhere_api_enabled: true,
55
+ longform_notetweets_consumption_enabled: true,
56
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
57
+ content_disclosure_indicator_enabled: true,
58
+ content_disclosure_ai_generated_indicator_enabled: true,
59
+ responsive_web_grok_show_grok_translated_post: false,
60
+ responsive_web_grok_analysis_button_from_backend: true,
61
+ post_ctas_fetch_enabled: false,
62
+ freedom_of_speech_not_reach_fetch_enabled: true,
63
+ standardized_nudges_misinfo: true,
64
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
65
+ longform_notetweets_rich_text_read_enabled: true,
66
+ longform_notetweets_inline_media_enabled: true,
67
+ responsive_web_grok_image_annotation_enabled: true,
68
+ responsive_web_grok_imagine_annotation_enabled: true,
69
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
70
+ responsive_web_enhance_cards_enabled: false,
71
+ };
72
+
73
+ const USER_MEDIA_FIELD_TOGGLES = {
74
+ withPayments: true,
75
+ withAuxiliaryUserLabels: true,
76
+ withArticleRichContentState: true,
77
+ withArticlePlainText: true,
78
+ withArticleSummaryText: true,
79
+ withArticleVoiceOver: true,
80
+ withGrokAnalyze: true,
81
+ withDisallowedReplyControls: true,
82
+ };
83
+
84
+ const USER_BY_SCREEN_NAME_FEATURES = {
85
+ hidden_profile_subscriptions_enabled: true,
86
+ profile_label_improvements_pcf_label_in_post_enabled: true,
87
+ responsive_web_profile_redirect_enabled: true,
88
+ rweb_tipjar_consumption_enabled: true,
89
+ responsive_web_graphql_exclude_directive_enabled: true,
90
+ verified_phone_label_enabled: false,
91
+ subscriptions_verification_info_is_identity_verified_enabled: true,
92
+ subscriptions_verification_info_verified_since_enabled: true,
93
+ highlights_tweets_tab_ui_enabled: true,
94
+ responsive_web_twitter_article_notes_tab_enabled: true,
95
+ subscriptions_feature_can_gift_premium: true,
96
+ creator_subscriptions_tweet_preview_api_enabled: true,
97
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
98
+ responsive_web_graphql_timeline_navigation_enabled: true,
99
+ };
100
+
101
+ const USER_BY_SCREEN_NAME_FIELD_TOGGLES = {
102
+ withPayments: true,
103
+ withAuxiliaryUserLabels: true,
104
+ };
105
+
106
+ const USER_MEDIA_OPERATION = {
107
+ queryId: USER_MEDIA_QUERY_ID,
108
+ features: USER_MEDIA_FEATURES,
109
+ fieldToggles: USER_MEDIA_FIELD_TOGGLES,
110
+ };
111
+
112
+ const USER_BY_SCREEN_NAME_OPERATION = {
113
+ queryId: USER_BY_SCREEN_NAME_QUERY_ID,
114
+ features: USER_BY_SCREEN_NAME_FEATURES,
115
+ fieldToggles: USER_BY_SCREEN_NAME_FIELD_TOGGLES,
116
+ };
117
+
118
+ function requireLimit(value) {
119
+ const limit = Number(value ?? 10);
120
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
121
+ throw new ArgumentError('--limit must be an integer between 1 and 1000');
122
+ }
123
+ return limit;
124
+ }
125
+
126
+ function nextUserMediaFetchCount(limit, downloadedCount) {
127
+ const remaining = limit - downloadedCount;
128
+ if (remaining <= 0) return 0;
129
+ const requested = remaining + 10;
130
+ if (requested > 100) return 100;
131
+ return requested;
132
+ }
133
+
134
+ async function downloadTwitterMedia(items, options) {
135
+ const rows = await downloadMedia(items, options);
136
+ return rows.map((row, index) => {
137
+ const item = items[index] || {};
138
+ return {
139
+ index: row.index,
140
+ tweet_id: item.tweet_id || '',
141
+ url: item.url || '',
142
+ type: row.type,
143
+ status: row.status,
144
+ size: row.size,
145
+ };
146
+ });
147
+ }
148
+
149
+ function normalizeUserMediaOperation(operation) {
150
+ if (typeof operation === 'string') {
151
+ return { queryId: operation, features: USER_MEDIA_FEATURES, fieldToggles: USER_MEDIA_FIELD_TOGGLES };
152
+ }
153
+ return {
154
+ queryId: operation?.queryId || USER_MEDIA_QUERY_ID,
155
+ features: operation?.features || USER_MEDIA_FEATURES,
156
+ fieldToggles: operation?.fieldToggles || USER_MEDIA_FIELD_TOGGLES,
157
+ };
158
+ }
159
+
160
+ function normalizeUserByScreenNameOperation(operation) {
161
+ if (typeof operation === 'string') {
162
+ return { queryId: operation, features: USER_BY_SCREEN_NAME_FEATURES, fieldToggles: USER_BY_SCREEN_NAME_FIELD_TOGGLES };
163
+ }
164
+ return {
165
+ queryId: operation?.queryId || USER_BY_SCREEN_NAME_QUERY_ID,
166
+ features: operation?.features || USER_BY_SCREEN_NAME_FEATURES,
167
+ fieldToggles: operation?.fieldToggles || USER_BY_SCREEN_NAME_FIELD_TOGGLES,
168
+ };
169
+ }
170
+
171
+ function appendGraphqlParams(path, variables, operation) {
172
+ const fieldToggles = operation.fieldToggles || {};
173
+ const params = [
174
+ `variables=${encodeURIComponent(JSON.stringify(variables))}`,
175
+ `features=${encodeURIComponent(JSON.stringify(operation.features || {}))}`,
176
+ ];
177
+ if (Object.keys(fieldToggles).length > 0) {
178
+ params.push(`fieldToggles=${encodeURIComponent(JSON.stringify(fieldToggles))}`);
179
+ }
180
+ return `${path}?${params.join('&')}`;
181
+ }
182
+
183
+ function buildUserMediaUrl(operation, userId, count, cursor) {
184
+ const normalized = normalizeUserMediaOperation(operation);
185
+ const vars = {
186
+ userId,
187
+ count,
188
+ includePromotedContent: false,
189
+ withClientEventToken: false,
190
+ withBirdwatchNotes: false,
191
+ withVoice: true,
192
+ };
193
+ if (cursor) vars.cursor = cursor;
194
+ return appendGraphqlParams(`/i/api/graphql/${normalized.queryId}/UserMedia`, vars, normalized);
195
+ }
196
+
197
+ function buildUserByScreenNameUrl(operation, screenName) {
198
+ const normalized = normalizeUserByScreenNameOperation(operation);
199
+ const vars = { screen_name: screenName, withSafetyModeUserFields: true };
200
+ return appendGraphqlParams(`/i/api/graphql/${normalized.queryId}/UserByScreenName`, vars, normalized);
201
+ }
202
+
203
+ function classifyMediaUrl(url) {
204
+ if (!url) return 'unknown';
205
+ if (/video\.twimg\.com|\.mp4(\?|$)|\.m3u8(\?|$)/.test(url)) return 'video';
206
+ return 'image';
207
+ }
208
+
209
+ function requireObjectPayload(value, context) {
210
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
211
+ throw new CommandExecutionError(`Twitter ${context} returned malformed payload`);
212
+ }
213
+ return value;
214
+ }
215
+
216
+ function throwGraphqlFetchError(context, status, message) {
217
+ if (status === 401 || status === 403) {
218
+ throw new AuthRequiredError('x.com', `Twitter ${context} requires an authenticated x.com session`);
219
+ }
220
+ if (status === 404) {
221
+ throw new EmptyResultError(`twitter download ${context}`, message || 'Twitter returned not found');
222
+ }
223
+ const statusText = status ? `HTTP ${status}` : 'fetch failed';
224
+ throw new CommandExecutionError(`Twitter ${context} fetch failed: ${statusText}${message ? ` - ${message}` : ''}`);
225
+ }
226
+
227
+ function requireFetchPayload(value, context) {
228
+ const result = requireObjectPayload(unwrapBrowserResult(value), context);
229
+ if (result.ok === true) {
230
+ return result.payload;
231
+ }
232
+ if (result.ok === false) {
233
+ throwGraphqlFetchError(context, Number(result.status) || 0, typeof result.error === 'string' ? result.error : '');
234
+ }
235
+ throw new CommandExecutionError(`Twitter ${context} returned malformed fetch result`);
236
+ }
237
+
238
+ function requireUserMediaPayload(data) {
239
+ const payload = requireObjectPayload(data, 'UserMedia');
240
+ if (Array.isArray(payload.errors) && payload.errors.length > 0) {
241
+ throw new CommandExecutionError(`Twitter UserMedia returned GraphQL errors: ${JSON.stringify(payload.errors).slice(0, 200)}`);
242
+ }
243
+ const result = payload.data?.user?.result;
244
+ if (!result || typeof result !== 'object') {
245
+ throw new CommandExecutionError('Twitter UserMedia returned malformed user result');
246
+ }
247
+ const instructions = result.timeline_v2?.timeline?.instructions || result.timeline?.timeline?.instructions;
248
+ if (!Array.isArray(instructions)) {
249
+ throw new CommandExecutionError('Twitter UserMedia returned malformed timeline instructions');
250
+ }
251
+ return payload;
252
+ }
253
+
254
+ function parseUserMedia(data, seen) {
255
+ const items = [];
256
+ let nextCursor = null;
257
+ const result = requireUserMediaPayload(data).data.user.result;
258
+ const instructionSets = [
259
+ result.timeline_v2?.timeline?.instructions,
260
+ result.timeline?.timeline?.instructions,
261
+ ].filter(Array.isArray);
262
+ const instructions = instructionSets.flat();
263
+ const visit = (value) => {
264
+ if (!value || typeof value !== 'object') return;
265
+ if (value.type === 'TimelinePinEntry') return;
266
+ if (value.tweet_results?.result) {
267
+ const raw = value.tweet_results.result;
268
+ const tw = raw.__typename === 'TweetWithVisibilityResults' && raw.tweet
269
+ ? raw.tweet
270
+ : (raw.tweet || raw);
271
+ const tweetId = typeof tw.rest_id === 'string' || typeof tw.rest_id === 'number' ? String(tw.rest_id) : '';
272
+ if (!tweetId) {
273
+ throw new CommandExecutionError('Twitter UserMedia returned a tweet without rest_id');
274
+ }
275
+ if (!seen.has(tweetId)) {
276
+ seen.add(tweetId);
277
+ const { media_urls } = extractMedia(tw.legacy || {});
278
+ for (const url of media_urls) {
279
+ items.push({ tweet_id: tweetId, url, type: classifyMediaUrl(url) });
280
+ }
281
+ }
282
+ }
283
+ if (
284
+ (value.entryType === 'TimelineTimelineCursor' || value.__typename === 'TimelineTimelineCursor')
285
+ && (value.cursorType === 'Bottom' || value.cursorType === 'ShowMore')
286
+ && value.value
287
+ ) {
288
+ nextCursor = value.value;
289
+ }
290
+ if (Array.isArray(value)) {
291
+ for (const item of value) visit(item);
292
+ return;
293
+ }
294
+ for (const child of Object.values(value)) {
295
+ if (child && typeof child === 'object') visit(child);
296
+ }
297
+ };
298
+ visit(instructions);
299
+ return { items, nextCursor };
300
+ }
301
+
11
302
  cli({
12
303
  site: 'twitter',
13
304
  name: 'download',
14
305
  access: 'read',
15
- description: 'Download Twitter/X media (images and videos). Provide either <username> to scan a profile\'s media tab, or --tweet-url to download a single tweet.',
306
+ description: 'Download Twitter/X media (images and videos). Provide either <username> to fetch every media item from their profile via the GraphQL UserMedia endpoint with cursor pagination, or --tweet-url to download a single tweet.',
16
307
  domain: 'x.com',
17
308
  strategy: Strategy.COOKIE,
309
+ browser: true,
18
310
  args: [
19
- { name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
311
+ { name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their profile media. Either <username> or --tweet-url is required.' },
20
312
  { name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
21
313
  { name: 'limit', type: 'int', default: 10, help: 'Maximum number of media items to download when scanning a profile (default 10). Ignored when --tweet-url is used.' },
22
314
  { name: 'output', default: './twitter-downloads', help: 'Output directory (default ./twitter-downloads). A per-source subdir is created inside.' },
23
315
  ],
24
- columns: ['index', 'type', 'status', 'size'],
316
+ columns: ['index', 'tweet_id', 'url', 'type', 'status', 'size'],
25
317
  func: async (page, kwargs) => {
26
- const username = kwargs.username;
27
- const tweetUrl = kwargs['tweet-url'];
28
- const limit = kwargs.limit;
29
- const output = kwargs.output;
30
- if (!username && !tweetUrl) {
31
- return [{
32
- index: 0,
33
- type: '-',
34
- status: 'failed',
35
- size: 'Must provide a username or --tweet-url',
36
- }];
318
+ try {
319
+ const rawUsername = String(kwargs.username ?? '').trim();
320
+ const tweetUrl = String(kwargs['tweet-url'] ?? '').trim();
321
+ const output = kwargs.output;
322
+ if (!rawUsername && !tweetUrl) {
323
+ throw new ArgumentError('twitter download requires either <username> or --tweet-url');
324
+ }
325
+ if (rawUsername && tweetUrl) {
326
+ throw new ArgumentError('Use either <username> or --tweet-url, not both');
327
+ }
328
+ if (tweetUrl) {
329
+ return downloadSingleTweet(page, tweetUrl, output);
330
+ }
331
+ const limit = requireLimit(kwargs.limit);
332
+ const username = normalizeTwitterScreenName(rawUsername);
333
+ if (!username) {
334
+ throw new ArgumentError('twitter download username must be a valid Twitter/X handle', 'Example: opencli twitter download @jack --limit 20');
335
+ }
336
+ return downloadUserMedia(page, username, limit, output);
37
337
  }
38
- // Navigate to the appropriate page
39
- if (tweetUrl) {
40
- await page.goto(tweetUrl);
338
+ catch (err) {
339
+ if (err instanceof CliError) throw err;
340
+ throw new CommandExecutionError(`twitter download failed: ${err?.message ?? String(err)}`);
41
341
  }
42
- else {
43
- await page.goto(`https://x.com/${username}/media`);
342
+ },
343
+ });
344
+
345
+ async function downloadUserMedia(page, username, limit, output) {
346
+ await page.goto(`https://x.com/${username}`);
347
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
348
+
349
+ const cookies = await page.getCookies({ url: 'https://x.com' });
350
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
351
+ if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
352
+
353
+ const userMediaOperation = await resolveTwitterOperationMetadata(page, 'UserMedia', USER_MEDIA_OPERATION);
354
+ const userByScreenNameOperation = await resolveTwitterOperationMetadata(page, 'UserByScreenName', USER_BY_SCREEN_NAME_OPERATION);
355
+
356
+ const headers = JSON.stringify({
357
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
358
+ 'X-Csrf-Token': ct0,
359
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
360
+ 'X-Twitter-Active-User': 'yes',
361
+ });
362
+
363
+ const ubsUrl = buildUserByScreenNameUrl(userByScreenNameOperation, username);
364
+ const userLookup = requireFetchPayload(await page.evaluate(`async () => {
365
+ try {
366
+ const resp = await fetch("${ubsUrl}", { headers: ${headers}, credentials: 'include' });
367
+ if (!resp.ok) return { ok: false, status: resp.status };
368
+ const payload = await resp.json();
369
+ return { ok: true, payload };
370
+ } catch (err) {
371
+ return { ok: false, error: err?.message ?? String(err) };
372
+ }
373
+ }`));
374
+ const normalizedUserLookup = normalizeTwitterGraphqlPayload(userLookup);
375
+ if (Array.isArray(normalizedUserLookup?.errors) && normalizedUserLookup.errors.length > 0) {
376
+ throw new CommandExecutionError(`Twitter UserByScreenName returned GraphQL errors: ${JSON.stringify(normalizedUserLookup.errors).slice(0, 200)}`);
377
+ }
378
+ const userId = normalizedUserLookup?.data?.user?.result?.rest_id;
379
+ if (!userId) throw new EmptyResultError(`twitter download @${username}`, `Could not resolve @${username}`);
380
+
381
+ const seen = new Set();
382
+ const all = [];
383
+ let cursor = null;
384
+ let hasMorePages = false;
385
+ for (let i = 0; i < MAX_PAGINATION_PAGES && all.length < limit; i++) {
386
+ const fetchCount = nextUserMediaFetchCount(limit, all.length);
387
+ if (fetchCount === 0) break;
388
+ const url = buildUserMediaUrl(userMediaOperation, userId, fetchCount, cursor);
389
+ const data = normalizeTwitterGraphqlPayload(requireFetchPayload(await page.evaluate(`async () => {
390
+ try {
391
+ const r = await fetch("${url}", { headers: ${headers}, credentials: 'include' });
392
+ if (!r.ok) return { ok: false, status: r.status };
393
+ return { ok: true, payload: await r.json() };
394
+ } catch (err) {
395
+ return { ok: false, error: err?.message ?? String(err) };
44
396
  }
45
- await page.wait(3);
46
- // Scroll to load more content
47
- if (!tweetUrl) {
48
- await page.autoScroll({ times: Math.ceil(limit / 5) });
397
+ }`)));
398
+ const { items, nextCursor } = parseUserMedia(data, seen);
399
+ all.push(...items);
400
+ hasMorePages = Boolean(nextCursor);
401
+ if (!nextCursor) break;
402
+ if (nextCursor === cursor) {
403
+ throw new CommandExecutionError('Twitter UserMedia pagination returned the same cursor twice');
49
404
  }
50
- // Extract media URLs
51
- const data = await page.evaluate(`
52
- (() => {
53
- const media = [];
405
+ cursor = nextCursor;
406
+ }
54
407
 
55
- // Find images (high quality)
408
+ if (all.length === 0) throw new EmptyResultError(`@${username} has no media`, 'Account may be private, suspended, or have no media posts');
409
+ if (all.length < limit && hasMorePages) {
410
+ throw new CommandExecutionError(`Twitter UserMedia pagination reached the ${MAX_PAGINATION_PAGES}-page safety cap before collecting ${limit} media items`);
411
+ }
412
+
413
+ const trimmed = all.slice(0, limit);
414
+ return downloadTwitterMedia(trimmed, {
415
+ output,
416
+ subdir: username,
417
+ cookies: formatCookieHeader(cookies),
418
+ browserCookies: cookies,
419
+ filenamePrefix: username,
420
+ ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
421
+ });
422
+ }
423
+
424
+ async function downloadSingleTweet(page, tweetUrl, output) {
425
+ const target = parseTweetUrl(tweetUrl);
426
+ await page.goto(target.url);
427
+ await page.wait(3);
428
+ const items = unwrapBrowserResult(await page.evaluate(`
429
+ (() => {
430
+ const out = [];
56
431
  document.querySelectorAll('img[src*="pbs.twimg.com/media"]').forEach(img => {
57
432
  let src = img.src || '';
58
- // Get large version
59
433
  src = src.replace(/&name=\\w+$/, '&name=large');
60
- src = src.replace(/\\?format=/, '?format=');
61
- if (!src.includes('&name=')) {
62
- src = src + '&name=large';
63
- }
64
- media.push({ type: 'image', url: src });
434
+ if (!src.includes('&name=')) src = src + '&name=large';
435
+ out.push({ type: 'image', url: src });
65
436
  });
66
-
67
- // Find videos
68
437
  document.querySelectorAll('video').forEach(video => {
69
438
  const src = video.src || '';
70
- if (src) {
71
- media.push({ type: 'video', url: src, poster: video.poster || '' });
72
- }
439
+ if (src) out.push({ type: 'video', url: src });
73
440
  });
74
-
75
- // Find video tweets (for yt-dlp)
76
441
  document.querySelectorAll('[data-testid="videoPlayer"]').forEach(player => {
77
442
  const tweetLink = player.closest('article')?.querySelector('a[href*="/status/"]');
78
443
  const href = tweetLink?.getAttribute('href') || '';
79
- if (href) {
80
- const tweetUrl = 'https://x.com' + href;
81
- media.push({ type: 'video-tweet', url: tweetUrl });
82
- }
444
+ if (href) out.push({ type: 'video-tweet', url: 'https://x.com' + href });
83
445
  });
84
-
85
- return media;
446
+ return out;
86
447
  })()
87
- `);
88
- if (!data || data.length === 0) {
89
- return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
90
- }
91
- // Extract cookies
92
- const browserCookies = await page.getCookies({ domain: 'x.com' });
93
- // Deduplicate media
94
- const seen = new Set();
95
- const uniqueMedia = data.filter((m) => {
96
- if (seen.has(m.url))
97
- return false;
98
- seen.add(m.url);
99
- return true;
100
- }).slice(0, limit);
101
- const subdir = tweetUrl ? 'tweets' : (username || 'media');
102
- return downloadMedia(uniqueMedia, {
103
- output,
104
- subdir,
105
- cookies: formatCookieHeader(browserCookies),
106
- browserCookies,
107
- filenamePrefix: username || 'tweet',
108
- ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
109
- });
110
- },
111
- });
448
+ `));
449
+ if (!Array.isArray(items)) {
450
+ throw new CommandExecutionError('Twitter tweet media extraction returned malformed payload');
451
+ }
452
+ if (items.length === 0) {
453
+ throw new EmptyResultError(`twitter download ${target.id}`, 'No media found in the tweet');
454
+ }
455
+ const cookies = await page.getCookies({ domain: 'x.com' });
456
+ const seen = new Set();
457
+ const unique = items.filter((m) => {
458
+ if (seen.has(m.url)) return false;
459
+ seen.add(m.url);
460
+ return true;
461
+ }).map((m) => {
462
+ return { ...m, tweet_id: target.id };
463
+ });
464
+ return downloadTwitterMedia(unique, {
465
+ output,
466
+ subdir: 'tweets',
467
+ cookies: formatCookieHeader(cookies),
468
+ browserCookies: cookies,
469
+ filenamePrefix: 'tweet',
470
+ ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
471
+ });
472
+ }
473
+
474
+ export const __test__ = {
475
+ buildUserMediaUrl,
476
+ buildUserByScreenNameUrl,
477
+ parseUserMedia,
478
+ classifyMediaUrl,
479
+ requireLimit,
480
+ nextUserMediaFetchCount,
481
+ };