@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,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
3
+ import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
4
4
  import { __test__ } from './likes.js';
5
5
 
6
6
  function likesPayload() {
@@ -129,6 +129,31 @@ describe('twitter likes helpers', () => {
129
129
  });
130
130
  });
131
131
 
132
+ describe('twitter likes command', () => {
133
+ it('throws EmptyResultError with privacy message when API returns empty-timeline shape', async () => {
134
+ const command = getRegistry().get('twitter/likes');
135
+ const page = {
136
+ goto: vi.fn().mockResolvedValue(undefined),
137
+ wait: vi.fn().mockResolvedValue(undefined),
138
+ getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
139
+ evaluate: vi.fn(async (script) => {
140
+ const text = String(script);
141
+ if (text.includes('AppTabBar_Profile_Link')) return { session: 'site:twitter', data: '/viewer' };
142
+ if (text.includes('operationName')) return null;
143
+ if (text.includes('/UserByScreenName')) return { session: 'site:twitter', data: '42' };
144
+ if (text.includes('/Likes')) {
145
+ return { session: 'site:twitter', data: { data: { user: { result: { __typename: 'User', timeline: {} } } } } };
146
+ }
147
+ throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`);
148
+ }),
149
+ };
150
+ await expect(command.func(page, { username: 'simonw', limit: 5 }))
151
+ .rejects.toMatchObject({ hint: expect.stringContaining('Likes are private by default on X') });
152
+ await expect(command.func(page, { username: 'simonw', limit: 5 }))
153
+ .rejects.toBeInstanceOf(EmptyResultError);
154
+ });
155
+ });
156
+
132
157
  describe('twitter likes command', () => {
133
158
  it('rejects invalid explicit username before cookies or navigation', async () => {
134
159
  const command = getRegistry().get('twitter/likes');
@@ -4,7 +4,7 @@ import { resolveTwitterQueryId } from './shared.js';
4
4
  import { parseListsManagement } from './lists.js';
5
5
  import { TWITTER_BEARER_TOKEN } from './utils.js';
6
6
 
7
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
7
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
8
8
  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
9
  // 2026-05 fallback — X rotates queryIds; resolveTwitterQueryId() does live lookup,
10
10
  // this constant is just the default if live lookup fails.
@@ -0,0 +1,155 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { unwrapBrowserResult } from './shared.js';
4
+ import { TWITTER_BEARER_TOKEN } from './utils.js';
5
+
6
+ const CREATE_LIST_QUERY_ID = 'UQRa0jJ9doxGEIQRea1Y0w';
7
+ const NAME_MAX = 25;
8
+ const DESCRIPTION_MAX = 100;
9
+
10
+ // Minimal feature set as observed in the real CreateList web request payload.
11
+ // Twitter rejects requests with extra/unknown features (DecodeException).
12
+ const FEATURES = {
13
+ profile_label_improvements_pcf_label_in_post_enabled: true,
14
+ responsive_web_profile_redirect_enabled: false,
15
+ rweb_tipjar_consumption_enabled: false,
16
+ verified_phone_label_enabled: false,
17
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
18
+ responsive_web_graphql_timeline_navigation_enabled: true,
19
+ };
20
+
21
+ export function parseListCreateArgs(kwargs) {
22
+ const name = String(kwargs.name || '').trim();
23
+ const description = String(kwargs.description || '').trim();
24
+ const modeRaw = String(kwargs.mode || 'public').trim().toLowerCase();
25
+ if (!name) {
26
+ throw new ArgumentError('List name is required', 'Example: opencli twitter list-create "My List"');
27
+ }
28
+ if (name.length > NAME_MAX) {
29
+ throw new ArgumentError(`List name too long: ${name.length} chars (max ${NAME_MAX})`);
30
+ }
31
+ if (description.length > DESCRIPTION_MAX) {
32
+ throw new ArgumentError(`Description too long: ${description.length} chars (max ${DESCRIPTION_MAX})`);
33
+ }
34
+ if (modeRaw !== 'public' && modeRaw !== 'private') {
35
+ throw new ArgumentError(`Invalid mode: ${JSON.stringify(kwargs.mode)}. Expected "public" or "private".`);
36
+ }
37
+ return { listName: name, listDescription: description, listMode: modeRaw, privateFlag: modeRaw === 'private' };
38
+ }
39
+
40
+ function requireCreateListResult(result, expectedName, expectedMode) {
41
+ if (!result || typeof result !== 'object') {
42
+ throw new CommandExecutionError(`Unexpected result from twitter list-create: ${JSON.stringify(result)}`);
43
+ }
44
+ if (result.httpStatus === 401 || result.httpStatus === 403) {
45
+ throw new AuthRequiredError('x.com', `Twitter CreateList returned HTTP ${result.httpStatus}`);
46
+ }
47
+ if (!result.ok) {
48
+ const snippet = String(result.bodyText || '').slice(0, 300);
49
+ throw new CommandExecutionError(`HTTP ${result.httpStatus} from CreateList: ${snippet}`);
50
+ }
51
+ if (!result.bodyJson || typeof result.bodyJson !== 'object') {
52
+ throw new CommandExecutionError(`CreateList returned malformed JSON payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
53
+ }
54
+ const list = result.bodyJson?.data?.list;
55
+ if (!list || typeof list !== 'object') {
56
+ const errors = result.bodyJson?.errors;
57
+ if (Array.isArray(errors) && errors.length > 0) {
58
+ throw new CommandExecutionError(`CreateList failed: ${errors[0].message || JSON.stringify(errors[0])}`);
59
+ }
60
+ throw new CommandExecutionError(`CreateList returned no list payload. Body: ${String(result.bodyText || '').slice(0, 300)}`);
61
+ }
62
+ const id = String(list.id_str || list.id || '');
63
+ if (!/^\d+$/.test(id)) {
64
+ throw new CommandExecutionError('CreateList returned a list payload without a numeric list id.');
65
+ }
66
+ if (typeof list.name !== 'string' || !list.name.trim()) {
67
+ throw new CommandExecutionError('CreateList returned a list payload without a list name.');
68
+ }
69
+ if (list.name.trim() !== expectedName) {
70
+ throw new CommandExecutionError(`CreateList returned name ${JSON.stringify(list.name)}, expected ${JSON.stringify(expectedName)}.`);
71
+ }
72
+ const modeValue = typeof list.mode === 'string' ? list.mode : '';
73
+ if (!modeValue) {
74
+ throw new CommandExecutionError('CreateList returned a list payload without list mode.');
75
+ }
76
+ const mode = /private/i.test(modeValue) ? 'private' : 'public';
77
+ if (mode !== expectedMode) {
78
+ throw new CommandExecutionError(`CreateList returned mode ${mode}, expected ${expectedMode}.`);
79
+ }
80
+ return { createdList: list, listId: id, listMode: mode };
81
+ }
82
+
83
+ export function buildListCreateRow({ result, name, description, mode }) {
84
+ const { createdList, listId, listMode } = requireCreateListResult(result, name, mode);
85
+ return {
86
+ id: listId,
87
+ name: createdList.name,
88
+ description: typeof createdList.description === 'string' ? createdList.description : description,
89
+ mode: listMode,
90
+ status: 'success',
91
+ };
92
+ }
93
+
94
+ cli({
95
+ site: 'twitter',
96
+ name: 'list-create',
97
+ description: 'Create a new Twitter/X list (returns the new list id)',
98
+ access: 'write',
99
+ domain: 'x.com',
100
+ strategy: Strategy.COOKIE,
101
+ browser: true,
102
+ args: [
103
+ { name: 'name', positional: true, type: 'string', required: true, help: `List name (max ${NAME_MAX} chars)` },
104
+ { name: 'description', type: 'string', default: '', help: `Optional list description (max ${DESCRIPTION_MAX} chars)` },
105
+ { name: 'mode', type: 'string', default: 'public', help: 'public | private' },
106
+ ],
107
+ columns: ['id', 'name', 'description', 'mode', 'status'],
108
+ func: async (page, kwargs) => {
109
+ const { listName: name, listDescription: description, listMode: mode, privateFlag: isPrivate } = parseListCreateArgs(kwargs);
110
+
111
+ await page.goto('https://x.com');
112
+ await page.wait(3);
113
+ const cookies = await page.getCookies({ url: 'https://x.com' });
114
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
115
+ if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
116
+
117
+ // Hardcode queryId: it must match the FEATURES schema below.
118
+ // Letting resolveTwitterQueryId() drift would pull a newer queryId
119
+ // whose schema would reject our simplified features payload.
120
+ const queryId = CREATE_LIST_QUERY_ID;
121
+
122
+ const headers = JSON.stringify({
123
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
124
+ 'X-Csrf-Token': ct0,
125
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
126
+ 'X-Twitter-Active-User': 'yes',
127
+ 'Content-Type': 'application/json',
128
+ });
129
+ const body = JSON.stringify({
130
+ variables: { isPrivate, name, description },
131
+ features: FEATURES,
132
+ queryId,
133
+ });
134
+ const apiUrl = `/i/api/graphql/${queryId}/CreateList`;
135
+
136
+ const result = unwrapBrowserResult(await page.evaluate(`async () => {
137
+ const r = await fetch(${JSON.stringify(apiUrl)}, {
138
+ method: 'POST',
139
+ headers: ${headers},
140
+ credentials: 'include',
141
+ body: ${JSON.stringify(body)},
142
+ });
143
+ const bodyText = await r.text();
144
+ let bodyJson = null;
145
+ try { bodyJson = JSON.parse(bodyText); } catch {}
146
+ return { ok: r.ok, httpStatus: r.status, bodyJson, bodyText };
147
+ }`));
148
+
149
+ // Note: Twitter sometimes returns a non-fatal `errors` array (e.g. a
150
+ // strato DecodeException from a side-effect serializer) WHILE STILL
151
+ // creating the list. So check for a valid list payload FIRST and
152
+ // only treat errors as fatal if no list came back.
153
+ return [buildListCreateRow({ result, name, description, mode })];
154
+ },
155
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import { buildListCreateRow, parseListCreateArgs } from './list-create.js';
5
+ import './list-create.js';
6
+
7
+ function createPayload(overrides = {}) {
8
+ return {
9
+ ok: true,
10
+ httpStatus: 200,
11
+ bodyText: '{}',
12
+ bodyJson: {
13
+ data: {
14
+ list: {
15
+ id_str: '123456789',
16
+ name: 'My List',
17
+ description: 'A list',
18
+ mode: 'Private',
19
+ ...overrides,
20
+ },
21
+ },
22
+ },
23
+ };
24
+ }
25
+
26
+ describe('twitter list-create registration', () => {
27
+ it('registers the list-create command with the expected shape', () => {
28
+ const cmd = getRegistry().get('twitter/list-create');
29
+ expect(cmd?.func).toBeTypeOf('function');
30
+ expect(cmd?.columns).toEqual(['id', 'name', 'description', 'mode', 'status']);
31
+ const nameArg = cmd?.args?.find((a) => a.name === 'name');
32
+ expect(nameArg).toBeTruthy();
33
+ expect(nameArg?.required).toBe(true);
34
+ expect(nameArg?.positional).toBe(true);
35
+ const modeArg = cmd?.args?.find((a) => a.name === 'mode');
36
+ expect(modeArg?.default).toBe('public');
37
+ const descArg = cmd?.args?.find((a) => a.name === 'description');
38
+ expect(descArg?.default).toBe('');
39
+ });
40
+
41
+ it('rejects empty name', async () => {
42
+ const cmd = getRegistry().get('twitter/list-create');
43
+ await expect(cmd.func({}, { name: ' ' })).rejects.toBeInstanceOf(ArgumentError);
44
+ });
45
+
46
+ it('rejects names over 25 chars', async () => {
47
+ const cmd = getRegistry().get('twitter/list-create');
48
+ await expect(cmd.func({}, { name: 'x'.repeat(26) })).rejects.toBeInstanceOf(ArgumentError);
49
+ });
50
+
51
+ it('rejects descriptions over 100 chars', () => {
52
+ expect(() => parseListCreateArgs({ name: 'ok', description: 'x'.repeat(101) })).toThrow(ArgumentError);
53
+ });
54
+
55
+ it('rejects invalid mode', async () => {
56
+ const cmd = getRegistry().get('twitter/list-create');
57
+ await expect(cmd.func({}, { name: 'ok', mode: 'secret' })).rejects.toBeInstanceOf(ArgumentError);
58
+ });
59
+
60
+ it('reads ct0 from cookies and unwraps Browser Bridge mutation envelopes', async () => {
61
+ const cmd = getRegistry().get('twitter/list-create');
62
+ const page = {
63
+ goto: vi.fn().mockResolvedValue(undefined),
64
+ wait: vi.fn().mockResolvedValue(undefined),
65
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf-token' }]),
66
+ evaluate: vi.fn().mockResolvedValue({ session: 'browser:default', data: createPayload() }),
67
+ };
68
+
69
+ const rows = await cmd.func(page, { name: 'My List', description: 'A list', mode: 'private' });
70
+
71
+ expect(page.goto).toHaveBeenCalledWith('https://x.com');
72
+ expect(page.wait).toHaveBeenCalledWith(3);
73
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
74
+ expect(rows).toEqual([{ id: '123456789', name: 'My List', description: 'A list', mode: 'private', status: 'success' }]);
75
+ });
76
+
77
+ it('rejects missing ct0 before mutation', async () => {
78
+ const cmd = getRegistry().get('twitter/list-create');
79
+ const page = {
80
+ goto: vi.fn().mockResolvedValue(undefined),
81
+ wait: vi.fn().mockResolvedValue(undefined),
82
+ getCookies: vi.fn().mockResolvedValue([]),
83
+ evaluate: vi.fn(),
84
+ };
85
+
86
+ await expect(cmd.func(page, { name: 'My List' })).rejects.toBeInstanceOf(AuthRequiredError);
87
+ expect(page.evaluate).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('keeps non-fatal GraphQL errors when a valid created list payload exists', () => {
91
+ const row = buildListCreateRow({
92
+ result: {
93
+ ...createPayload(),
94
+ bodyJson: {
95
+ ...createPayload().bodyJson,
96
+ errors: [{ message: 'DecodeException' }],
97
+ },
98
+ },
99
+ name: 'My List',
100
+ description: 'A list',
101
+ mode: 'private',
102
+ });
103
+
104
+ expect(row).toEqual({ id: '123456789', name: 'My List', description: 'A list', mode: 'private', status: 'success' });
105
+ });
106
+
107
+ it('maps mutation auth and HTTP failures to typed errors', () => {
108
+ expect(() => buildListCreateRow({
109
+ result: { ok: false, httpStatus: 401, bodyText: 'login' },
110
+ name: 'My List',
111
+ description: '',
112
+ mode: 'public',
113
+ })).toThrow(AuthRequiredError);
114
+
115
+ expect(() => buildListCreateRow({
116
+ result: { ok: false, httpStatus: 500, bodyText: 'server' },
117
+ name: 'My List',
118
+ description: '',
119
+ mode: 'public',
120
+ })).toThrow(CommandExecutionError);
121
+ });
122
+
123
+ it('fails typed when the mutation response lacks post-condition evidence', () => {
124
+ for (const result of [
125
+ createPayload({ id_str: '', id: '' }),
126
+ createPayload({ name: '' }),
127
+ createPayload({ mode: '' }),
128
+ ]) {
129
+ expect(() => buildListCreateRow({
130
+ result,
131
+ name: 'My List',
132
+ description: '',
133
+ mode: 'public',
134
+ })).toThrow(CommandExecutionError);
135
+ }
136
+ });
137
+
138
+ it('fails typed when returned list name does not match the requested name', () => {
139
+ expect(() => buildListCreateRow({
140
+ result: createPayload({ name: 'Other List' }),
141
+ name: 'My List',
142
+ description: '',
143
+ mode: 'private',
144
+ })).toThrow(/expected "My List"/);
145
+ });
146
+
147
+ it('fails typed when returned list mode does not match requested mode', () => {
148
+ expect(() => buildListCreateRow({
149
+ result: createPayload({ mode: 'Public' }),
150
+ name: 'My List',
151
+ description: '',
152
+ mode: 'private',
153
+ })).toThrow(/expected private/);
154
+ });
155
+
156
+ it('fails typed when errors appear without a list payload', () => {
157
+ expect(() => buildListCreateRow({
158
+ result: {
159
+ ok: true,
160
+ httpStatus: 200,
161
+ bodyText: '{}',
162
+ bodyJson: { errors: [{ message: 'duplicate name' }] },
163
+ },
164
+ name: 'My List',
165
+ description: '',
166
+ mode: 'public',
167
+ })).toThrow(/duplicate name/);
168
+ });
169
+ });
@@ -1,10 +1,10 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { resolveTwitterQueryId } from './shared.js';
4
- import { parseListsManagement } from './lists.js';
4
+ import { getListsManagementInstructions, parseListsManagement } from './lists.js';
5
5
  import { TWITTER_BEARER_TOKEN } from './utils.js';
6
6
 
7
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
7
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
8
8
  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
9
 
10
10
  const LISTS_MANAGEMENT_FEATURES = {
@@ -274,11 +274,18 @@ cli({
274
274
  if (!r.ok) return { __error: 'HTTP ' + r.status };
275
275
  return await r.json();
276
276
  }`);
277
- const parsedAfter = listsAfter && !listsAfter.__error
278
- ? parseListsManagement(listsAfter, new Set())
279
- : [];
277
+ if (listsAfter && listsAfter.__error) {
278
+ throw new CommandExecutionError(`Could not verify list removal: ${listsAfter.__error}`);
279
+ }
280
+ if (!getListsManagementInstructions(listsAfter)) {
281
+ throw new CommandExecutionError('Could not verify list removal: unexpected lists payload shape');
282
+ }
283
+ const parsedAfter = parseListsManagement(listsAfter, new Set());
280
284
  const afterList = parsedAfter.find((l) => l.id === listId);
281
- const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
285
+ if (!afterList) {
286
+ throw new CommandExecutionError(`Could not verify list removal: list ${listId} missing from post-delete payload`);
287
+ }
288
+ const memberCountAfter = Number(afterList.members) || 0;
282
289
  if (memberCountAfter < memberCountBefore) {
283
290
  verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
284
291
  } else {
@@ -1,7 +1,57 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
4
  import './list-remove.js';
4
5
 
6
+ function buildListsPayload(listId = '123', memberCount = 10) {
7
+ return {
8
+ data: {
9
+ viewer: {
10
+ list_management_timeline: {
11
+ timeline: {
12
+ instructions: [{
13
+ entries: [{
14
+ entryId: 'owned-subscribed-list-module-0',
15
+ content: {
16
+ items: [{
17
+ item: {
18
+ itemContent: {
19
+ list: {
20
+ id_str: listId,
21
+ name: 'My List',
22
+ member_count: memberCount,
23
+ subscriber_count: 0,
24
+ mode: 'Public',
25
+ },
26
+ },
27
+ },
28
+ }],
29
+ },
30
+ }],
31
+ }],
32
+ },
33
+ },
34
+ },
35
+ },
36
+ };
37
+ }
38
+
39
+ function buildRemovePage(afterPayload) {
40
+ return {
41
+ goto: vi.fn().mockResolvedValue(undefined),
42
+ wait: vi.fn().mockResolvedValue(undefined),
43
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
44
+ nativeClick: vi.fn().mockResolvedValue(undefined),
45
+ evaluate: vi.fn()
46
+ .mockResolvedValueOnce(null) // UserByScreenName queryId fallback
47
+ .mockResolvedValueOnce('user-1')
48
+ .mockResolvedValueOnce(null) // ListsManagement queryId fallback
49
+ .mockResolvedValueOnce(buildListsPayload('123', 10))
50
+ .mockResolvedValueOnce({ ok: true, needsNativeInteraction: true, rowClickX: 1, rowClickY: 2, saveClickX: 3, saveClickY: 4 })
51
+ .mockResolvedValueOnce(afterPayload),
52
+ };
53
+ }
54
+
5
55
  describe('twitter list-remove registration', () => {
6
56
  it('registers the list-remove command with the expected shape', () => {
7
57
  const cmd = getRegistry().get('twitter/list-remove');
@@ -33,4 +83,28 @@ describe('twitter list-remove registration', () => {
33
83
  expect(page.wait).toHaveBeenCalledWith(3);
34
84
  expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
35
85
  });
86
+
87
+ it('does not treat post-delete fetch failure as successful member_count decrease', async () => {
88
+ const cmd = getRegistry().get('twitter/list-remove');
89
+ const page = buildRemovePage({ __error: 'HTTP 500' });
90
+
91
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
92
+ .rejects.toBeInstanceOf(CommandExecutionError);
93
+ });
94
+
95
+ it('does not treat malformed post-delete payload as successful member_count decrease', async () => {
96
+ const cmd = getRegistry().get('twitter/list-remove');
97
+ const page = buildRemovePage({ data: {} });
98
+
99
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
100
+ .rejects.toBeInstanceOf(CommandExecutionError);
101
+ });
102
+
103
+ it('does not treat a missing target list in post-delete payload as success', async () => {
104
+ const cmd = getRegistry().get('twitter/list-remove');
105
+ const page = buildRemovePage(buildListsPayload('456', 1));
106
+
107
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
108
+ .rejects.toBeInstanceOf(CommandExecutionError);
109
+ });
36
110
  });
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
- import { extractMedia } from './shared.js';
3
+ import { extractMedia, extractCard, extractQuotedTweet } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
 
6
6
  const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
@@ -61,11 +61,13 @@ export function extractTimelineTweet(result, seen) {
61
61
  const user = tw.core?.user_results?.result;
62
62
  const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
63
63
  const displayName = user?.legacy?.name || user?.core?.name || '';
64
+ const bio = user?.legacy?.description || '';
64
65
  const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
65
66
  return {
66
67
  id: tw.rest_id,
67
68
  author: screenName,
68
69
  name: displayName,
70
+ bio,
69
71
  text: noteText || legacy.full_text || '',
70
72
  likes: legacy.favorite_count || 0,
71
73
  retweets: legacy.retweet_count || 0,
@@ -73,6 +75,8 @@ export function extractTimelineTweet(result, seen) {
73
75
  created_at: legacy.created_at || '',
74
76
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
75
77
  ...extractMedia(legacy),
78
+ card: extractCard(tw),
79
+ quoted_tweet: extractQuotedTweet(tw),
76
80
  };
77
81
  }
78
82
 
@@ -120,7 +124,7 @@ cli({
120
124
  { name: 'limit', type: 'int', default: 50 },
121
125
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the list\'s native (recency) ordering.' },
122
126
  ],
123
- columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls'],
127
+ columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
124
128
  func: async (page, kwargs) => {
125
129
  const listId = String(kwargs.listId || '').trim();
126
130
  if (!listId || !/^\d+$/.test(listId)) {
@@ -15,7 +15,7 @@ describe('twitter list-tweets parser', () => {
15
15
  core: {
16
16
  user_results: {
17
17
  result: {
18
- legacy: { screen_name: 'bob', name: 'Bob' },
18
+ legacy: { screen_name: 'bob', name: 'Bob', description: 'List author bio' },
19
19
  },
20
20
  },
21
21
  },
@@ -24,6 +24,7 @@ describe('twitter list-tweets parser', () => {
24
24
  id: '99',
25
25
  author: 'bob',
26
26
  name: 'Bob',
27
+ bio: 'List author bio',
27
28
  text: 'hello list',
28
29
  likes: 3,
29
30
  retweets: 1,
@@ -32,6 +33,45 @@ describe('twitter list-tweets parser', () => {
32
33
  url: 'https://x.com/bob/status/99',
33
34
  has_media: false,
34
35
  media_urls: [],
36
+ card: null,
37
+ quoted_tweet: null,
38
+ });
39
+ });
40
+
41
+ it('surfaces quoted_tweet field on quote tweets (mini-tweet shape)', () => {
42
+ // 1778721843 stale-snapshot case from ml-scout — downstream consumers
43
+ // need quote-tweet content to render the embedded preview card.
44
+ const tweet = extractTimelineTweet({
45
+ rest_id: '500',
46
+ legacy: {
47
+ full_text: '总的来说,还是有个好爹',
48
+ is_quote_status: true,
49
+ quoted_status_id_str: '499',
50
+ },
51
+ core: { user_results: { result: { legacy: { screen_name: 'rwayne' } } } },
52
+ quoted_status_result: {
53
+ result: {
54
+ rest_id: '499',
55
+ legacy: {
56
+ full_text: '罗某官二代背景考',
57
+ created_at: 'Wed May 13 22:00:00 +0000 2026',
58
+ extended_entities: {
59
+ media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/x.jpg' }],
60
+ },
61
+ },
62
+ core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } },
63
+ },
64
+ },
65
+ }, new Set());
66
+ expect(tweet?.quoted_tweet).toEqual({
67
+ id: '499',
68
+ author: 'alice',
69
+ name: 'Alice',
70
+ text: '罗某官二代背景考',
71
+ created_at: 'Wed May 13 22:00:00 +0000 2026',
72
+ url: 'https://x.com/alice/status/499',
73
+ has_media: true,
74
+ media_urls: ['https://pbs.twimg.com/media/x.jpg'],
35
75
  });
36
76
  });
37
77
 
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { TWITTER_BEARER_TOKEN } from './utils.js';
4
4
 
5
5
  const LISTS_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
@@ -62,14 +62,35 @@ export function extractListEntry(entry, seen) {
62
62
  };
63
63
  }
64
64
 
65
- export function parseListsManagement(data, seen) {
66
- const lists = [];
65
+ // X ListsManagementPageTimeline 把 /<user>/lists 整个页面的所有 section
66
+ // 都塞在同一个 TimelineAddEntries instruction 里,靠 entry.entryId 前缀区分:
67
+ // - `owned-subscribed-list-module-*` → 用户的 owned + subscribed list(要保留)
68
+ // - `list-to-follow-module-*` → "Discover new Lists" 算法推荐(要剔除)
69
+ // - `cursor-*` → 分页游标(无 list 数据)
70
+ // 旧版 parser 忽略 entryId 一律下钻,导致推荐 list 被当成自建/订阅泄漏出来。
71
+ const OWNED_SUBSCRIBED_ENTRY_PREFIX = 'owned-subscribed-list-module-';
72
+
73
+ export function isOwnedSubscribedEntry(entry) {
74
+ return typeof entry?.entryId === 'string'
75
+ && entry.entryId.startsWith(OWNED_SUBSCRIBED_ENTRY_PREFIX);
76
+ }
77
+
78
+ export function getListsManagementInstructions(data) {
67
79
  const instructions = data?.data?.viewer?.list_management_timeline?.timeline?.instructions
68
80
  || data?.data?.viewer_v2?.user_results?.result?.list_management_timeline?.timeline?.instructions
69
81
  || data?.data?.list_management_timeline?.timeline?.instructions
70
- || [];
82
+ || data?.data?.data?.viewer?.list_management_timeline?.timeline?.instructions
83
+ || data?.data?.data?.viewer_v2?.user_results?.result?.list_management_timeline?.timeline?.instructions
84
+ || data?.data?.data?.list_management_timeline?.timeline?.instructions;
85
+ return Array.isArray(instructions) ? instructions : null;
86
+ }
87
+
88
+ export function parseListsManagement(data, seen) {
89
+ const lists = [];
90
+ const instructions = getListsManagementInstructions(data) || [];
71
91
  for (const inst of instructions) {
72
92
  for (const entry of inst.entries || []) {
93
+ if (!isOwnedSubscribedEntry(entry)) continue;
73
94
  const direct = extractListEntry(entry, seen);
74
95
  if (direct) {
75
96
  lists.push(direct);
@@ -144,7 +165,13 @@ export const command = cli({
144
165
  throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch lists. queryId may have expired.`);
145
166
  }
146
167
  const seen = new Set();
168
+ if (!getListsManagementInstructions(data)) {
169
+ throw new CommandExecutionError('Twitter lists returned an unexpected payload shape');
170
+ }
147
171
  const lists = parseListsManagement(data, seen);
172
+ if (lists.length === 0) {
173
+ throw new EmptyResultError('twitter lists', 'No owned or subscribed lists found');
174
+ }
148
175
  return lists.slice(0, limit);
149
176
  },
150
177
  });