@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
@@ -62,10 +62,15 @@ async function submitQuote(page, text, tweetId) {
62
62
  return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
63
63
  }
64
64
 
65
- const buttons = Array.from(
66
- document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
67
- );
68
- const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
65
+ let btn = null;
66
+ for (let i = 0; i < 30; i++) {
67
+ const buttons = Array.from(
68
+ document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
69
+ );
70
+ btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
71
+ if (btn) break;
72
+ await new Promise(r => setTimeout(r, 500));
73
+ }
69
74
  if (!btn) {
70
75
  return { ok: false, message: 'Tweet button is disabled or not found.' };
71
76
  }
@@ -90,19 +90,22 @@ async function insertReplyText(page, text) {
90
90
  }
91
91
 
92
92
  async function clickReplyButton(page) {
93
- return page.evaluate(`(() => {
93
+ const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS);
94
+ return page.evaluate(`(async () => {
94
95
  try {
95
96
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
96
- const buttons = Array.from(
97
- document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
98
- );
99
- const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
100
- if (!btn) {
101
- return { ok: false, message: 'Reply button is disabled or not found.' };
97
+ for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
98
+ const buttons = Array.from(
99
+ document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
100
+ );
101
+ const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
102
+ if (btn) {
103
+ btn.click();
104
+ return { ok: true };
105
+ }
106
+ await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
102
107
  }
103
-
104
- btn.click();
105
- return { ok: true };
108
+ return { ok: false, message: 'Reply button is disabled or not found.' };
106
109
  } catch (e) {
107
110
  return { ok: false, message: e.toString() };
108
111
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
+ import { JSDOM } from 'jsdom';
4
5
  import { describe, expect, it, vi } from 'vitest';
5
6
  import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
6
7
  import { getRegistry } from '@jackwener/opencli/registry';
@@ -193,4 +194,44 @@ describe('twitter image helpers (utils.js)', () => {
193
194
  expect(utilsTest.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp');
194
195
  expect(utilsTest.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg');
195
196
  });
197
+
198
+ it('classifies CDP NotAllowed file-input failures as recoverable', () => {
199
+ expect(utilsTest.isRecoverableFileInputError(new Error('NotAllowedError: Not allowed'))).toBe(true);
200
+ expect(utilsTest.isRecoverableFileInputError(new Error('ProtocolError: not-allowed'))).toBe(true);
201
+ expect(utilsTest.isRecoverableFileInputError(new Error('Permission denied'))).toBe(false);
202
+ });
203
+
204
+ it('fails closed when a composer image preview never appears', async () => {
205
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-helper-'));
206
+ const imagePath = path.join(tempDir, 'missing-preview.png');
207
+ fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
208
+ const page = createPageMock([{ ok: false, message: 'Image upload timed out (30s).' }], {
209
+ setFileInput: vi.fn().mockResolvedValue(undefined),
210
+ });
211
+
212
+ await expect(utilsTest.attachComposerImage(page, imagePath)).rejects.toThrow('Image upload timed out');
213
+ fs.rmSync(tempDir, { recursive: true, force: true });
214
+ });
215
+
216
+ it('does not treat an empty attachments container as uploaded media', async () => {
217
+ const runMediaReadyProbe = async (html) => {
218
+ const dom = new JSDOM(`<!doctype html><body>${html}</body>`, {
219
+ url: 'https://x.com/compose/post',
220
+ runScripts: 'outside-only',
221
+ });
222
+ dom.window.setTimeout = (callback) => {
223
+ callback();
224
+ return 0;
225
+ };
226
+ const page = {
227
+ evaluate: vi.fn(async (script) => dom.window.eval(script)),
228
+ };
229
+ return utilsTest.waitForComposerMediaReady(page, 1);
230
+ };
231
+
232
+ await expect(runMediaReadyProbe('<div data-testid="attachments"></div>'))
233
+ .resolves.toMatchObject({ ok: false });
234
+ await expect(runMediaReadyProbe('<div data-testid="attachments"><img src="blob:https://x.com/1"></div>'))
235
+ .resolves.toMatchObject({ ok: true, previewCount: 1 });
236
+ });
196
237
  });
@@ -1,6 +1,6 @@
1
1
  import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { extractMedia, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata } from './shared.js';
3
+ import { extractMedia, extractCard, extractQuotedTweet, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
 
6
6
  // ── Public-search operator surface ─────────────────────────────────────
@@ -212,15 +212,19 @@ function tweetToRow(result, seen) {
212
212
  if (!tweet?.rest_id || seen.has(tweet.rest_id)) return null;
213
213
  seen.add(tweet.rest_id);
214
214
  const tweetUser = tweet.core?.user_results?.result;
215
+ const bio = tweetUser?.legacy?.description || '';
215
216
  return {
216
217
  id: tweet.rest_id,
217
- author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
218
+ author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '',
219
+ bio,
218
220
  text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
219
221
  created_at: tweet.legacy?.created_at || '',
220
222
  likes: tweet.legacy?.favorite_count || 0,
221
223
  views: tweet.views?.count || '0',
222
224
  url: `https://x.com/i/status/${tweet.rest_id}`,
223
225
  ...extractMedia(tweet.legacy),
226
+ card: extractCard(tweet),
227
+ quoted_tweet: extractQuotedTweet(tweet),
224
228
  };
225
229
  }
226
230
 
@@ -271,7 +275,7 @@ cli({
271
275
  { name: 'limit', type: 'int', default: 15, help: 'Maximum number of tweets to return (default 15). Result count after server-side filtering.' },
272
276
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the results by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
273
277
  ],
274
- columns: ['id', 'author', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls'],
278
+ columns: ['id', 'author', 'bio', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
275
279
  func: async (page, kwargs) => {
276
280
  const finalQuery = buildSearchQuery(kwargs.query, kwargs);
277
281
  if (!finalQuery) {
@@ -44,6 +44,9 @@ describe('twitter search command', () => {
44
44
  core: {
45
45
  screen_name: 'alice',
46
46
  },
47
+ legacy: {
48
+ description: 'Search author bio',
49
+ },
47
50
  },
48
51
  },
49
52
  },
@@ -68,6 +71,7 @@ describe('twitter search command', () => {
68
71
  {
69
72
  id: '1',
70
73
  author: 'alice',
74
+ bio: 'Search author bio',
71
75
  text: 'hello world',
72
76
  created_at: 'Thu Mar 26 10:30:00 +0000 2026',
73
77
  likes: 7,
@@ -75,6 +79,8 @@ describe('twitter search command', () => {
75
79
  url: 'https://x.com/i/status/1',
76
80
  has_media: false,
77
81
  media_urls: [],
82
+ card: null,
83
+ quoted_tweet: null,
78
84
  },
79
85
  ]);
80
86
  expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
@@ -148,6 +154,40 @@ describe('twitter search command', () => {
148
154
  expect(result.map((row) => row.id)).toEqual(['1', '2', '3', '4', '5', '6', '7']);
149
155
  expect(page.evaluate).toHaveBeenCalledTimes(8);
150
156
  });
157
+
158
+ it('surfaces empty author when the tweet has no user screen_name', () => {
159
+ const payload = {
160
+ data: {
161
+ search_by_raw_query: {
162
+ search_timeline: {
163
+ timeline: {
164
+ instructions: [{
165
+ type: 'TimelineAddEntries',
166
+ entries: [{
167
+ entryId: 'tweet-2',
168
+ content: {
169
+ itemContent: {
170
+ tweet_results: {
171
+ result: {
172
+ rest_id: '2',
173
+ legacy: { full_text: 'no author here', favorite_count: 0, created_at: '' },
174
+ core: { user_results: { result: {} } },
175
+ },
176
+ },
177
+ },
178
+ },
179
+ }],
180
+ }],
181
+ },
182
+ },
183
+ },
184
+ },
185
+ };
186
+ const { rows } = parseSearchTimeline(payload, new Set());
187
+ expect(rows).toHaveLength(1);
188
+ expect(rows[0].author).toBe('');
189
+ expect(rows[0].id).toBe('2');
190
+ });
151
191
  });
152
192
 
153
193
  describe('twitter search filter helpers', () => {
@@ -340,4 +380,5 @@ describe('twitter search end-to-end with new filters', () => {
340
380
  const searchFetch = evaluate.mock.calls[1][0];
341
381
  expect(searchFetch).toContain('\\"rawQuery\\":\\"from:alice\\"');
342
382
  });
383
+
343
384
  });
@@ -155,6 +155,16 @@ export function unwrapBrowserResult(value) {
155
155
  return value;
156
156
  }
157
157
 
158
+ function isEmptyObject(value) {
159
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0;
160
+ }
161
+
162
+ export function looksLikePrivateTwitterTimeline(data) {
163
+ const result = data?.data?.user?.result;
164
+ if (!result || typeof result !== 'object') return false;
165
+ return Boolean(isEmptyObject(result.timeline) || isEmptyObject(result.timeline_v2?.timeline));
166
+ }
167
+
158
168
  export function normalizeTwitterGraphqlPayload(value) {
159
169
  const unwrapped = unwrapBrowserResult(value);
160
170
  if (unwrapped?.data && typeof unwrapped.data === 'object') return unwrapped;
@@ -288,6 +298,148 @@ export function extractMedia(legacy) {
288
298
  }
289
299
  return { has_media: urls.length > 0, media_urls: urls };
290
300
  }
301
+
302
+ /**
303
+ * Extract the link-preview card from a tweet's GraphQL response.
304
+ *
305
+ * Reads `tweet.card.legacy.{name, binding_values}` plus the expanded URL from
306
+ * the `tweet.legacy.entities.urls` entry matching the card's t.co URL.
307
+ * `binding_values` is an array of `{ key, value: { type, string_value, image_value: { url } } }`.
308
+ *
309
+ * Returns `null` when:
310
+ * - the tweet has no card, OR
311
+ * - the card is structurally empty (no landing URL AND no title/description),
312
+ * which would be useless to downstream renderers.
313
+ *
314
+ * Otherwise returns a partial card object — missing fields are simply omitted
315
+ * (no `undefined` values in the output) so JSON consumers see a clean shape.
316
+ */
317
+ export function extractCard(tweet) {
318
+ const cardLegacy = tweet?.card?.legacy;
319
+ if (!cardLegacy) return null;
320
+ const bindings = Array.isArray(cardLegacy.binding_values) ? cardLegacy.binding_values : [];
321
+ const byKey = new Map();
322
+ for (const b of bindings) {
323
+ if (b && typeof b.key === 'string') byKey.set(b.key, b.value);
324
+ }
325
+ const str = (key) => {
326
+ const v = byKey.get(key);
327
+ return typeof v?.string_value === 'string' && v.string_value.length > 0 ? v.string_value : undefined;
328
+ };
329
+ const img = (key) => {
330
+ const v = byKey.get(key);
331
+ const u = v?.image_value?.url;
332
+ return typeof u === 'string' && u.length > 0 ? u : undefined;
333
+ };
334
+ const title = str('title');
335
+ const description = str('description');
336
+ const domainBinding = str('domain');
337
+ const cardUrlBinding = str('card_url');
338
+ const image_url = img('thumbnail_image_large') || img('photo_image_full_size_large') || img('summary_photo_image_large');
339
+ const urlEntities = Array.isArray(tweet?.legacy?.entities?.urls)
340
+ ? tweet.legacy.entities.urls
341
+ : [];
342
+ const matchingEntity = cardUrlBinding
343
+ ? urlEntities.find((entity) => entity?.url === cardUrlBinding || entity?.expanded_url === cardUrlBinding)
344
+ : undefined;
345
+ const matchedExpandedUrl = matchingEntity?.expanded_url;
346
+ const url = (typeof matchedExpandedUrl === 'string' && matchedExpandedUrl.length > 0)
347
+ ? matchedExpandedUrl
348
+ : cardUrlBinding;
349
+ let domain = domainBinding;
350
+ if (!domain && url) {
351
+ try { domain = new URL(url).hostname; }
352
+ catch { /* malformed url — domain stays undefined */ }
353
+ }
354
+ if (!url && !title && !description) return null;
355
+ const out = { name: cardLegacy.name };
356
+ if (title) out.title = title;
357
+ if (description) out.description = description;
358
+ if (image_url) out.image_url = image_url;
359
+ if (url) out.url = url;
360
+ if (domain) out.domain = domain;
361
+ return out;
362
+ }
363
+
364
+ /**
365
+ * Extract the quoted tweet from a tweet's GraphQL response.
366
+ *
367
+ * A quote tweet is a tweet that embeds and comments on another tweet (distinct
368
+ * from a reply or retweet). The author writes new commentary and the embedded
369
+ * tweet renders as a card-like preview under the new tweet.
370
+ *
371
+ * GraphQL surfaces this as `tweet.quoted_status_result.result`, which contains
372
+ * the same `legacy / core / card / note_tweet` shape as the outer tweet — so
373
+ * we reuse `extractMedia` / `extractCard` on the nested object. Detection is
374
+ * gated by `legacy.is_quote_status === true` (plus the presence of the nested
375
+ * result) so we don't return junk on plain replies that share field shapes.
376
+ *
377
+ * Returns `null` when:
378
+ * - the tweet is not a quote, OR
379
+ * - the nested `quoted_status_result.result` is missing/empty/tombstoned.
380
+ *
381
+ * Only goes ONE level deep — a quote-of-a-quote returns its level-1 quoted
382
+ * tweet without further nesting. Recursing would explode payload size on
383
+ * threads where every reply re-quotes the original.
384
+ *
385
+ * The output shape is a deliberately small subset of the main tweet shape
386
+ * (id/author/name/text/created_at/url + media + card). Consumers that need
387
+ * counts or full author bio of the quoted tweet can re-fetch the quoted id
388
+ * via `twitter thread <id>` — keeping this slim avoids ballooning every
389
+ * timeline/list/search response by 2-3x.
390
+ */
391
+ export function extractQuotedTweet(tweet) {
392
+ const legacy = tweet?.legacy;
393
+ if (!legacy?.is_quote_status) return null;
394
+ const q = tweet?.quoted_status_result?.result
395
+ ?? tweet?.legacy?.quoted_status_result?.result;
396
+ // `result` can be a tombstone (`__typename: 'TweetTombstone'`) or
397
+ // `'TweetUnavailable'` when the quoted tweet was deleted / privacy-restricted.
398
+ if (!q) return null;
399
+ // Nested `tweet` wrapper appears on TweetWithVisibilityResults — same
400
+ // shim that callers already do at the top level (`tw.tweet || tw`).
401
+ const qTw = q.tweet || q;
402
+ if (!qTw || typeof qTw !== 'object') return null;
403
+ const qLegacy = qTw.legacy && typeof qTw.legacy === 'object' ? qTw.legacy : {};
404
+ // `rest_id` is required — tombstoned / unavailable wrappers have neither
405
+ // rest_id nor legacy. Don't fall back to outer `legacy.quoted_status_id_str`:
406
+ // the id alone can't substitute for missing content (author/text/media all
407
+ // empty), so emitting a stub object would mislead downstream renderers into
408
+ // drawing an empty "quoted tweet" preview.
409
+ if (typeof qTw.rest_id !== 'string' || !qTw.rest_id.trim()) return null;
410
+ const qUser = qTw.core?.user_results?.result;
411
+ const qLegacyScreenName = qUser?.legacy?.screen_name;
412
+ const qCoreScreenName = qUser?.core?.screen_name;
413
+ const qScreenName = typeof qLegacyScreenName === 'string' && qLegacyScreenName.trim()
414
+ ? qLegacyScreenName.trim()
415
+ : (typeof qCoreScreenName === 'string' && qCoreScreenName.trim() ? qCoreScreenName.trim() : '');
416
+ if (!SCREEN_NAME_PATTERN.test(qScreenName)) return null;
417
+ const qLegacyDisplayName = qUser?.legacy?.name;
418
+ const qCoreDisplayName = qUser?.core?.name;
419
+ const qDisplayName = typeof qLegacyDisplayName === 'string'
420
+ ? qLegacyDisplayName
421
+ : (typeof qCoreDisplayName === 'string' ? qCoreDisplayName : '');
422
+ const qNoteText = qTw.note_tweet?.note_tweet_results?.result?.text;
423
+ const qText = (typeof qNoteText === 'string' && qNoteText.length > 0)
424
+ ? qNoteText
425
+ : (typeof qLegacy.full_text === 'string' ? qLegacy.full_text : '');
426
+ const qMedia = extractMedia(qLegacy);
427
+ const qCard = extractCard(qTw);
428
+ if (!qText && !qMedia.has_media && !qCard) return null;
429
+ const out = {
430
+ id: qTw.rest_id,
431
+ author: qScreenName,
432
+ name: qDisplayName,
433
+ text: qText,
434
+ created_at: typeof qLegacy.created_at === 'string' ? qLegacy.created_at : '',
435
+ url: `https://x.com/${qScreenName}/status/${qTw.rest_id}`,
436
+ has_media: qMedia.has_media,
437
+ media_urls: qMedia.media_urls,
438
+ };
439
+ if (qCard) out.card = qCard;
440
+ return out;
441
+ }
442
+
291
443
  export const __test__ = {
292
444
  sanitizeQueryId,
293
445
  sanitizeTwitterOperationMetadata,
@@ -295,6 +447,9 @@ export const __test__ = {
295
447
  normalizeTwitterGraphqlPayload,
296
448
  normalizeTwitterScreenName,
297
449
  extractMedia,
450
+ extractCard,
451
+ extractQuotedTweet,
298
452
  parseTweetUrl,
299
453
  buildTwitterArticleScopeSource,
454
+ looksLikePrivateTwitterTimeline,
300
455
  };