@jackwener/opencli 1.7.21 → 1.8.0

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 (238) hide show
  1. package/README.md +31 -148
  2. package/README.zh-CN.md +38 -211
  3. package/cli-manifest.json +6423 -4260
  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/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/boss/utils.js +17 -1
  24. package/clis/boss/utils.test.js +34 -0
  25. package/clis/chatgpt/envelope.test.js +108 -0
  26. package/clis/chatgpt/image.js +2 -2
  27. package/clis/chatgpt/image.test.js +6 -0
  28. package/clis/chatgpt/utils.js +148 -41
  29. package/clis/chatgpt/utils.test.js +92 -2
  30. package/clis/douyin/_shared/browser-fetch.js +44 -20
  31. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  32. package/clis/douyin/_shared/evaluate-result.js +16 -0
  33. package/clis/douyin/_shared/tos-upload.js +105 -69
  34. package/clis/douyin/_shared/vod-upload.js +212 -0
  35. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  36. package/clis/douyin/delete.js +137 -4
  37. package/clis/douyin/delete.test.js +90 -1
  38. package/clis/douyin/publish-upload-id.test.js +170 -0
  39. package/clis/douyin/publish.js +88 -42
  40. package/clis/douyin/user-videos.js +9 -2
  41. package/clis/douyin/user-videos.test.js +43 -0
  42. package/clis/flomo/memos.js +228 -0
  43. package/clis/flomo/memos.test.js +144 -0
  44. package/clis/gitee/search.js +2 -2
  45. package/clis/gitee/search.test.js +65 -0
  46. package/clis/jike/post.js +27 -17
  47. package/clis/jike/read.test.js +86 -0
  48. package/clis/jike/topic.js +32 -19
  49. package/clis/jike/user.js +33 -20
  50. package/clis/lesswrong/comments.js +1 -1
  51. package/clis/lesswrong/curated.js +1 -1
  52. package/clis/lesswrong/frontpage.js +1 -1
  53. package/clis/lesswrong/frontpage.test.js +37 -0
  54. package/clis/lesswrong/new.js +1 -1
  55. package/clis/lesswrong/read.js +1 -1
  56. package/clis/lesswrong/sequences.js +1 -1
  57. package/clis/lesswrong/shortform.js +1 -1
  58. package/clis/lesswrong/tag.js +1 -1
  59. package/clis/lesswrong/top-month.js +1 -1
  60. package/clis/lesswrong/top-week.js +1 -1
  61. package/clis/lesswrong/top-year.js +1 -1
  62. package/clis/lesswrong/top.js +1 -1
  63. package/clis/linkedin/connect.js +401 -0
  64. package/clis/linkedin/connect.test.js +213 -0
  65. package/clis/linkedin/inbox.js +234 -0
  66. package/clis/linkedin/inbox.test.js +152 -0
  67. package/clis/linkedin/people-search.js +262 -0
  68. package/clis/linkedin/people-search.test.js +216 -0
  69. package/clis/linkedin/safe-send.js +357 -0
  70. package/clis/linkedin/safe-send.test.js +204 -0
  71. package/clis/linkedin/salesnav-inbox.js +210 -0
  72. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  73. package/clis/linkedin/salesnav-message.js +360 -0
  74. package/clis/linkedin/salesnav-message.test.js +172 -0
  75. package/clis/linkedin/salesnav-search.js +186 -0
  76. package/clis/linkedin/salesnav-search.test.js +76 -0
  77. package/clis/linkedin/salesnav-thread.js +212 -0
  78. package/clis/linkedin/salesnav-thread.test.js +79 -0
  79. package/clis/linkedin/sent-invitations.js +92 -0
  80. package/clis/linkedin/sent-invitations.test.js +62 -0
  81. package/clis/linkedin/thread-snapshot.js +214 -0
  82. package/clis/linkedin/thread-snapshot.test.js +89 -0
  83. package/clis/linkedin-learning/course.js +138 -0
  84. package/clis/linkedin-learning/course.test.js +114 -0
  85. package/clis/linkedin-learning/search.js +155 -0
  86. package/clis/linkedin-learning/search.test.js +144 -0
  87. package/clis/linkedin-learning/trending.js +133 -0
  88. package/clis/linkedin-learning/trending.test.js +123 -0
  89. package/clis/powerchina/search.js +3 -3
  90. package/clis/powerchina/search.test.js +27 -1
  91. package/clis/reddit/extract-media.test.js +149 -0
  92. package/clis/reddit/frontpage.js +47 -9
  93. package/clis/reddit/frontpage.test.js +34 -0
  94. package/clis/reddit/home.js +31 -1
  95. package/clis/reddit/home.test.js +46 -3
  96. package/clis/reddit/hot.js +32 -1
  97. package/clis/reddit/hot.test.js +15 -1
  98. package/clis/reddit/popular.js +39 -1
  99. package/clis/reddit/popular.test.js +26 -0
  100. package/clis/reddit/saved.js +1 -1
  101. package/clis/reddit/search.js +38 -1
  102. package/clis/reddit/search.test.js +26 -0
  103. package/clis/reddit/subreddit.js +52 -7
  104. package/clis/reddit/subreddit.test.js +31 -0
  105. package/clis/reddit/subscribed.js +165 -0
  106. package/clis/reddit/subscribed.test.js +168 -0
  107. package/clis/reddit/upvoted.js +1 -1
  108. package/clis/suno/commands.test.js +188 -0
  109. package/clis/suno/download.js +140 -0
  110. package/clis/suno/download.test.js +151 -0
  111. package/clis/suno/generate.js +226 -0
  112. package/clis/suno/generate.test.js +243 -0
  113. package/clis/suno/list.js +79 -0
  114. package/clis/suno/status.js +62 -0
  115. package/clis/suno/utils.js +540 -0
  116. package/clis/suno/utils.test.js +223 -0
  117. package/clis/twitter/device-follow.js +193 -0
  118. package/clis/twitter/device-follow.test.js +287 -0
  119. package/clis/twitter/download.js +443 -73
  120. package/clis/twitter/download.test.js +457 -0
  121. package/clis/twitter/list-create.js +155 -0
  122. package/clis/twitter/list-create.test.js +169 -0
  123. package/clis/twitter/list-remove.js +12 -5
  124. package/clis/twitter/list-remove.test.js +74 -0
  125. package/clis/twitter/list-tweets.js +6 -2
  126. package/clis/twitter/list-tweets.test.js +41 -1
  127. package/clis/twitter/lists.js +31 -4
  128. package/clis/twitter/lists.test.js +152 -16
  129. package/clis/twitter/search.js +6 -2
  130. package/clis/twitter/search.test.js +6 -0
  131. package/clis/twitter/shared.js +144 -0
  132. package/clis/twitter/shared.test.js +429 -1
  133. package/clis/twitter/thread.js +10 -2
  134. package/clis/twitter/thread.test.js +58 -0
  135. package/clis/twitter/timeline.js +6 -2
  136. package/clis/twitter/timeline.test.js +2 -0
  137. package/clis/twitter/tweets.js +3 -2
  138. package/clis/twitter/tweets.test.js +1 -1
  139. package/clis/weibo/comments.js +3 -4
  140. package/clis/weibo/delete.js +172 -0
  141. package/clis/weibo/delete.test.js +94 -0
  142. package/clis/weibo/envelope.test.js +85 -0
  143. package/clis/weibo/favorites.js +4 -4
  144. package/clis/weibo/feed.js +3 -5
  145. package/clis/weibo/hot.js +3 -4
  146. package/clis/weibo/me.js +3 -5
  147. package/clis/weibo/post.js +3 -4
  148. package/clis/weibo/publish.js +37 -14
  149. package/clis/weibo/publish.test.js +14 -5
  150. package/clis/weibo/search.js +4 -3
  151. package/clis/weibo/user-posts.js +234 -0
  152. package/clis/weibo/user-posts.test.js +92 -0
  153. package/clis/weibo/user.js +3 -4
  154. package/clis/weibo/utils.js +34 -5
  155. package/clis/weibo/utils.test.js +36 -0
  156. package/clis/weread/search-regression.test.js +18 -11
  157. package/clis/weread/search.js +15 -7
  158. package/clis/weread-official/book.js +135 -0
  159. package/clis/weread-official/commands.test.js +385 -0
  160. package/clis/weread-official/discover.js +107 -0
  161. package/clis/weread-official/list-apis.js +95 -0
  162. package/clis/weread-official/notes.js +171 -0
  163. package/clis/weread-official/readdata.js +158 -0
  164. package/clis/weread-official/review.js +93 -0
  165. package/clis/weread-official/search.js +106 -0
  166. package/clis/weread-official/shelf.js +97 -0
  167. package/clis/weread-official/utils.js +293 -0
  168. package/clis/weread-official/utils.test.js +242 -0
  169. package/clis/wikipedia/trending.js +7 -3
  170. package/clis/wikipedia/trending.test.js +57 -0
  171. package/clis/xianyu/chat.js +24 -109
  172. package/clis/xianyu/chat.test.js +5 -0
  173. package/clis/xianyu/im.js +322 -0
  174. package/clis/xianyu/im.test.js +253 -0
  175. package/clis/xianyu/inbox.js +96 -0
  176. package/clis/xianyu/messages.js +91 -0
  177. package/clis/xianyu/reply.js +82 -0
  178. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  179. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  180. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  181. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  182. package/clis/xiaohongshu/creator-notes.js +2 -1
  183. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  184. package/clis/xiaohongshu/creator-stats.js +2 -1
  185. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  186. package/clis/xiaohongshu/delete-note.js +260 -0
  187. package/clis/xiaohongshu/delete-note.test.js +172 -0
  188. package/clis/xiaohongshu/publish.js +48 -8
  189. package/clis/xiaohongshu/publish.test.js +65 -10
  190. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  191. package/clis/xiaohongshu/user.js +27 -4
  192. package/clis/xiaoyuzhou/download.js +1 -1
  193. package/clis/xiaoyuzhou/transcript.js +1 -1
  194. package/clis/youdao/note.js +258 -0
  195. package/clis/youdao/note.test.js +99 -0
  196. package/clis/youtube/transcript.js +397 -24
  197. package/clis/youtube/transcript.test.js +196 -6
  198. package/clis/zhihu/answer-comments.js +299 -0
  199. package/clis/zhihu/answer-comments.test.js +287 -0
  200. package/clis/zhihu/answer-detail.js +12 -0
  201. package/clis/zhihu/answer-detail.test.js +8 -0
  202. package/clis/zhihu/collection.js +15 -2
  203. package/clis/zhihu/collection.test.js +46 -0
  204. package/clis/zhihu/download.js +1 -1
  205. package/clis/zhihu/question.js +42 -9
  206. package/clis/zhihu/question.test.js +111 -9
  207. package/clis/zhihu/search.js +206 -43
  208. package/clis/zhihu/search.test.js +198 -0
  209. package/dist/src/browser/errors.js +4 -2
  210. package/dist/src/browser/errors.test.js +6 -0
  211. package/dist/src/browser/page.js +30 -4
  212. package/dist/src/browser/page.test.js +42 -0
  213. package/dist/src/browser/utils.d.ts +1 -1
  214. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  215. package/dist/src/cli-argv-preprocess.js +138 -0
  216. package/dist/src/cli-argv-preprocess.test.js +79 -0
  217. package/dist/src/cli.js +1 -1
  218. package/dist/src/convention-audit.js +15 -8
  219. package/dist/src/convention-audit.test.js +21 -0
  220. package/dist/src/download/media-download.js +15 -2
  221. package/dist/src/download/media-download.test.d.ts +1 -0
  222. package/dist/src/download/media-download.test.js +110 -0
  223. package/dist/src/electron-apps.js +1 -1
  224. package/dist/src/electron-apps.test.js +7 -2
  225. package/dist/src/errors.d.ts +17 -0
  226. package/dist/src/errors.js +22 -0
  227. package/dist/src/external-clis.yaml +20 -0
  228. package/dist/src/external.d.ts +6 -1
  229. package/dist/src/external.test.js +19 -0
  230. package/dist/src/main.js +14 -2
  231. package/dist/src/utils.d.ts +43 -0
  232. package/dist/src/utils.js +97 -0
  233. package/dist/src/utils.test.d.ts +1 -0
  234. package/dist/src/utils.test.js +155 -0
  235. package/package.json +8 -2
  236. package/scripts/silent-column-drop-baseline.json +0 -52
  237. package/scripts/typed-error-lint-baseline.json +28 -380
  238. package/clis/slock/_utils.js +0 -12
@@ -74,7 +74,6 @@ function buildComposerLocatorScript() {
74
74
  };
75
75
 
76
76
  findComposer.toString = () => 'findComposer';
77
- return { findComposer, markerAttr };
78
77
  `;
79
78
  }
80
79
 
@@ -103,6 +102,50 @@ export function requirePositiveInt(value, flagLabel, hint) {
103
102
  return value;
104
103
  }
105
104
 
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+ // page.evaluate envelope helpers.
107
+ //
108
+ // The browser bridge wraps every `page.evaluate(...)` return value in a
109
+ // `{ session, data }` envelope. Adapters that read `.length` or
110
+ // `Array.isArray(payload)` directly on the envelope silently see "no data" —
111
+ // this matches the failure mode fixed for xiaohongshu/rednote (#1561) and
112
+ // weibo (#1568).
113
+ //
114
+ // `unwrapEvaluateResult` is a defensive ternary: it unwraps when the payload
115
+ // looks like an envelope, otherwise passes the value through unchanged so
116
+ // older bridge versions and primitive return values still work.
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+ export function unwrapEvaluateResult(payload) {
119
+ if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
120
+ return payload.data;
121
+ }
122
+ return payload;
123
+ }
124
+
125
+ export function requireArrayEvaluateResult(payload, label) {
126
+ if (!Array.isArray(payload)) {
127
+ if (payload && typeof payload === 'object' && 'error' in payload) {
128
+ throw new CommandExecutionError(`${label}: ${String(payload.error)}`);
129
+ }
130
+ throw new CommandExecutionError(`${label} returned malformed extraction payload`);
131
+ }
132
+ return payload;
133
+ }
134
+
135
+ export function requireObjectEvaluateResult(payload, label) {
136
+ if (!payload || Array.isArray(payload) || typeof payload !== 'object') {
137
+ throw new CommandExecutionError(`${label} returned malformed extraction payload`);
138
+ }
139
+ return payload;
140
+ }
141
+
142
+ export function requireBooleanEvaluateResult(payload, label) {
143
+ if (typeof payload !== 'boolean') {
144
+ throw new CommandExecutionError(`${label} returned malformed extraction payload`);
145
+ }
146
+ return payload;
147
+ }
148
+
106
149
  export function parseChatGPTConversationId(value) {
107
150
  const raw = String(value ?? '').trim();
108
151
  const match = raw.match(/(?:^|\/c\/)([A-Za-z0-9_-]{8,})(?:[/?#]|$)/);
@@ -115,7 +158,7 @@ export function parseChatGPTConversationId(value) {
115
158
  }
116
159
 
117
160
  export async function currentChatGPTUrl(page) {
118
- const url = await page.evaluate('window.location.href').catch(() => '');
161
+ const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
119
162
  return typeof url === 'string' ? url : '';
120
163
  }
121
164
 
@@ -161,7 +204,7 @@ export async function startNewChat(page) {
161
204
  }
162
205
 
163
206
  export async function getPageState(page) {
164
- return await page.evaluate(`(() => {
207
+ return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
165
208
  const isVisible = (el) => {
166
209
  if (!(el instanceof HTMLElement)) return false;
167
210
  const style = window.getComputedStyle(el);
@@ -187,7 +230,7 @@ export async function getPageState(page) {
187
230
  isLoggedIn: hasComposer || !!userMenu || !hasLoginGate,
188
231
  hasLoginGate,
189
232
  };
190
- })()`);
233
+ })()`)), 'chatgpt page state');
191
234
  }
192
235
 
193
236
  export async function ensureChatGPTLogin(page, message = 'ChatGPT requires a logged-in browser session.') {
@@ -258,7 +301,7 @@ export async function sendChatGPTMessage(page, text) {
258
301
  // findComposer() retries inside a single CDP call, so no fixed sleep is
259
302
  // needed before reading the composer.
260
303
 
261
- const typeResult = await page.evaluate(`
304
+ const typeResult = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
262
305
  (() => {
263
306
  ${buildComposerLocatorScript()}
264
307
  const composer = findComposer();
@@ -276,8 +319,8 @@ export async function sendChatGPTMessage(page, text) {
276
319
  composer.dispatchEvent(new Event('change', { bubbles: true }));
277
320
  return true;
278
321
  })()
279
- `);
280
-
322
+ `)), 'chatgpt composer readiness');
323
+
281
324
  if (!typeResult) return false;
282
325
 
283
326
  // Use page.type() which is Playwright's native method
@@ -304,7 +347,7 @@ export async function sendChatGPTMessage(page, text) {
304
347
  let sent = null;
305
348
  for (let attempt = 0; attempt < 20; attempt += 1) {
306
349
  await page.wait(0.5);
307
- sent = await page.evaluate(`
350
+ sent = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
308
351
  (() => {
309
352
  const isUsable = (button) => button
310
353
  && !button.disabled
@@ -318,7 +361,7 @@ export async function sendChatGPTMessage(page, text) {
318
361
  : btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && isUsable(b));
319
362
  return { sendBtnFound: !!sendBtn };
320
363
  })()
321
- `);
364
+ `)), 'chatgpt send button readiness');
322
365
  if (sent?.sendBtnFound) break;
323
366
  }
324
367
 
@@ -339,7 +382,7 @@ export async function sendChatGPTMessage(page, text) {
339
382
  }
340
383
 
341
384
  export async function getVisibleMessages(page) {
342
- const result = await page.evaluate(`(() => {
385
+ const result = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
343
386
  const isVisible = (el) => {
344
387
  if (!(el instanceof HTMLElement)) return false;
345
388
  const style = window.getComputedStyle(el);
@@ -385,8 +428,7 @@ export async function getVisibleMessages(page) {
385
428
  rows.push({ role, text, html });
386
429
  }
387
430
  return rows;
388
- })()`);
389
- if (!Array.isArray(result)) return [];
431
+ })()`)), 'chatgpt visible messages');
390
432
  return result.map((item, index) => ({
391
433
  Index: index + 1,
392
434
  Role: item?.role === 'Assistant' ? 'Assistant' : 'User',
@@ -448,7 +490,7 @@ export async function getConversationList(page) {
448
490
  // so the previous standalone 2 s settle is redundant.
449
491
  await ensureOnChatGPT(page);
450
492
 
451
- const openSidebar = await page.evaluate(`(() => {
493
+ const openSidebar = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
452
494
  const button = Array.from(document.querySelectorAll('button'))
453
495
  .find((node) => /open sidebar/i.test(node.getAttribute('aria-label') || ''));
454
496
  if (button instanceof HTMLElement) {
@@ -456,7 +498,7 @@ export async function getConversationList(page) {
456
498
  return true;
457
499
  }
458
500
  return false;
459
- })()`);
501
+ })()`)), 'chatgpt sidebar open state');
460
502
  if (openSidebar) {
461
503
  try {
462
504
  await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 });
@@ -480,7 +522,7 @@ export async function getConversationList(page) {
480
522
  }
481
523
 
482
524
  async function extractConversationLinks(page) {
483
- const items = await page.evaluate(`(() => {
525
+ const items = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
484
526
  const isVisible = (el) => {
485
527
  if (!(el instanceof HTMLElement)) return false;
486
528
  const style = window.getComputedStyle(el);
@@ -505,15 +547,13 @@ async function extractConversationLinks(page) {
505
547
  });
506
548
  }
507
549
  return rows;
508
- })()`);
509
- return Array.isArray(items)
510
- ? items.map((item, index) => ({
550
+ })()`)), 'chatgpt conversation link extraction');
551
+ return items.map((item, index) => ({
511
552
  Index: index + 1,
512
553
  Id: String(item?.Id || ''),
513
554
  Title: String(item?.Title || '(untitled)').trim() || '(untitled)',
514
555
  Url: String(item?.Url || ''),
515
- })).filter((item) => item.Id)
516
- : [];
556
+ })).filter((item) => item.Id);
517
557
  }
518
558
 
519
559
  function imageMimeFromPath(filePath) {
@@ -556,7 +596,7 @@ async function waitForChatGPTUploadPreview(page, fileNames) {
556
596
  const namesJson = JSON.stringify(fileNames);
557
597
  for (let attempt = 0; attempt < 10; attempt += 1) {
558
598
  await page.wait(1);
559
- const ready = await page.evaluate(`
599
+ const ready = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
560
600
  (() => {
561
601
  const names = ${namesJson};
562
602
  const text = document.body ? (document.body.innerText || '') : '';
@@ -572,7 +612,7 @@ async function waitForChatGPTUploadPreview(page, fileNames) {
572
612
  const previewNodes = scope.querySelectorAll('img[src], canvas, video, [style*="background-image"], [data-testid*="attachment"], [data-testid*="upload"], [class*="attachment"], [class*="upload"]');
573
613
  return previewNodes.length >= names.length;
574
614
  })()
575
- `);
615
+ `)), 'chatgpt upload preview detection');
576
616
  if (ready) return true;
577
617
  }
578
618
  return false;
@@ -606,7 +646,7 @@ export async function uploadChatGPTImages(page, imagePaths) {
606
646
  mime: imageMimeFromPath(absPath),
607
647
  base64: fs.default.readFileSync(absPath).toString('base64'),
608
648
  }));
609
- const fallbackResult = await page.evaluate(`
649
+ const fallbackResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
610
650
  (() => {
611
651
  const files = ${JSON.stringify(files)};
612
652
  const input = document.querySelector('input[type="file"]');
@@ -642,7 +682,7 @@ export async function uploadChatGPTImages(page, imagePaths) {
642
682
  }
643
683
  return { ok: true };
644
684
  })()
645
- `);
685
+ `)), 'chatgpt image upload fallback');
646
686
  if (fallbackResult && !fallbackResult.ok) return fallbackResult;
647
687
  }
648
688
 
@@ -656,21 +696,21 @@ export async function uploadChatGPTImages(page, imagePaths) {
656
696
  * Check if ChatGPT is still generating a response.
657
697
  */
658
698
  export async function isGenerating(page) {
659
- return await page.evaluate(`
699
+ return requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
660
700
  (() => {
661
701
  return Array.from(document.querySelectorAll('button')).some(b => {
662
702
  const label = b.getAttribute('aria-label') || '';
663
703
  return label === 'Stop generating' || label.includes('Thinking');
664
704
  });
665
705
  })()
666
- `);
706
+ `)), 'chatgpt generation state');
667
707
  }
668
708
 
669
709
  /**
670
710
  * Get visible image URLs from the ChatGPT page (excluding profile/avatar images).
671
711
  */
672
712
  export async function getChatGPTVisibleImageUrls(page) {
673
- return await page.evaluate(`
713
+ return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
674
714
  (() => {
675
715
  const isVisible = (el) => {
676
716
  if (!(el instanceof HTMLElement)) return false;
@@ -680,32 +720,78 @@ export async function getChatGPTVisibleImageUrls(page) {
680
720
  return rect.width > 32 && rect.height > 32;
681
721
  };
682
722
 
723
+ const urls = [];
724
+ const seen = new Set();
725
+ const normalizeUrl = (value) => {
726
+ const raw = String(value || '').trim();
727
+ if (!raw || raw === 'none') return '';
728
+ if (/^(?:https?:|blob:|data:)/i.test(raw)) return raw;
729
+ try {
730
+ return new URL(raw, window.location.href).href;
731
+ } catch {
732
+ return raw;
733
+ }
734
+ };
735
+ const addUrl = (value) => {
736
+ const src = normalizeUrl(value);
737
+ if (!src || seen.has(src)) return;
738
+ seen.add(src);
739
+ urls.push(src);
740
+ };
741
+ const isDecorative = (el, src = '') => {
742
+ const alt = (el.getAttribute('alt') || '').toLowerCase();
743
+ const cls = String(el.className || '').toLowerCase();
744
+ const testId = (el.getAttribute('data-testid') || '').toLowerCase();
745
+ const label = (el.getAttribute('aria-label') || '').toLowerCase();
746
+ const text = [alt, cls, testId, label, src.toLowerCase()].join(' ');
747
+ return /avatar|profile|logo|icon/.test(text);
748
+ };
749
+
683
750
  const imgs = Array.from(document.querySelectorAll('img')).filter(img =>
684
751
  img instanceof HTMLImageElement && isVisible(img)
685
752
  );
686
753
 
687
- const urls = [];
688
- const seen = new Set();
689
-
690
754
  for (const img of imgs) {
691
755
  const src = img.currentSrc || img.src || '';
692
- const alt = (img.getAttribute('alt') || '').toLowerCase();
693
- const cls = (img.className || '').toLowerCase();
694
756
  const width = img.naturalWidth || img.width || 0;
695
757
  const height = img.naturalHeight || img.height || 0;
696
758
 
697
759
  if (!src) continue;
698
- if (alt.includes('avatar') || alt.includes('profile') || alt.includes('logo') || alt.includes('icon')) continue;
699
- if (cls.includes('avatar') || cls.includes('profile') || cls.includes('icon')) continue;
760
+ if (isDecorative(img, src)) continue;
700
761
  if (width < 128 && height < 128) continue;
701
- if (seen.has(src)) continue;
762
+ addUrl(src);
763
+ }
702
764
 
703
- seen.add(src);
704
- urls.push(src);
765
+ // ChatGPT occasionally renders generated images as CSS background
766
+ // thumbnails instead of plain <img> nodes. Treat visible, large
767
+ // background images as generated-image candidates too.
768
+ for (const el of Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]'))) {
769
+ if (!(el instanceof HTMLElement) || !isVisible(el) || isDecorative(el)) continue;
770
+ const rect = el.getBoundingClientRect();
771
+ if (rect.width < 128 && rect.height < 128) continue;
772
+ const backgroundImage = window.getComputedStyle(el).backgroundImage || '';
773
+ for (const match of backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)) {
774
+ const src = match[2];
775
+ if (!src || isDecorative(el, src)) continue;
776
+ addUrl(src);
777
+ }
778
+ }
779
+
780
+ // Some image experiences render to a canvas. Returning the data URL
781
+ // lets the downstream asset exporter save it without needing a DOM
782
+ // selector to rediscover the canvas.
783
+ for (const canvas of Array.from(document.querySelectorAll('canvas'))) {
784
+ if (!(canvas instanceof HTMLCanvasElement) || !isVisible(canvas) || isDecorative(canvas)) continue;
785
+ const width = canvas.width || canvas.getBoundingClientRect().width || 0;
786
+ const height = canvas.height || canvas.getBoundingClientRect().height || 0;
787
+ if (width < 128 && height < 128) continue;
788
+ try {
789
+ addUrl(canvas.toDataURL('image/png'));
790
+ } catch { }
705
791
  }
706
792
  return urls;
707
793
  })()
708
- `);
794
+ `)), 'chatgpt visible image url extraction');
709
795
  }
710
796
 
711
797
  /**
@@ -723,7 +809,7 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
723
809
 
724
810
  let currentUrl = '';
725
811
  if (convUrl && convUrl.includes('/c/')) {
726
- currentUrl = await page.evaluate('window.location.href').catch(() => '');
812
+ currentUrl = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
727
813
  if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
728
814
  await page.goto(convUrl);
729
815
  await page.wait(3);
@@ -766,6 +852,7 @@ export const __test__ = {
766
852
  SEND_BUTTON_FALLBACK_SELECTORS,
767
853
  SEND_BUTTON_LABELS,
768
854
  CLOSE_SIDEBAR_LABELS,
855
+ buildComposerLocatorScript,
769
856
  isSameChatGPTConversation,
770
857
  parseChatGPTConversationId,
771
858
  imageMimeFromPath,
@@ -776,7 +863,7 @@ export const __test__ = {
776
863
  */
777
864
  export async function getChatGPTImageAssets(page, urls) {
778
865
  const urlsJson = JSON.stringify(urls);
779
- return await page.evaluate(`
866
+ return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
780
867
  (async (targetUrls) => {
781
868
  const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
782
869
  const reader = new FileReader();
@@ -809,6 +896,26 @@ export async function getChatGPTImageAssets(page, urls) {
809
896
  if (img) {
810
897
  width = img.naturalWidth || img.width || 0;
811
898
  height = img.naturalHeight || img.height || 0;
899
+ } else {
900
+ const backgroundEl = Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]')).find(el => {
901
+ if (!(el instanceof HTMLElement)) return false;
902
+ const backgroundImage = window.getComputedStyle(el).backgroundImage || '';
903
+ return Array.from(backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)).some(match => {
904
+ const raw = String(match[2] || '').trim();
905
+ if (!raw) return false;
906
+ if (raw === targetUrl) return true;
907
+ try {
908
+ return new URL(raw, window.location.href).href === targetUrl;
909
+ } catch {
910
+ return false;
911
+ }
912
+ });
913
+ });
914
+ if (backgroundEl) {
915
+ const rect = backgroundEl.getBoundingClientRect();
916
+ width = Math.round(rect.width || 0);
917
+ height = Math.round(rect.height || 0);
918
+ }
812
919
  }
813
920
 
814
921
  try {
@@ -850,5 +957,5 @@ export async function getChatGPTImageAssets(page, urls) {
850
957
 
851
958
  return results;
852
959
  })(${urlsJson})
853
- `, urls);
960
+ `)), 'chatgpt image asset export');
854
961
  }
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { JSDOM } from 'jsdom';
4
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
- import { __test__, prepareChatGPTImagePaths, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTImages } from './utils.js';
6
+ import { __test__, getChatGPTImageAssets, getChatGPTVisibleImageUrls, prepareChatGPTImagePaths, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTImages } from './utils.js';
6
7
 
7
8
  const tempDirs = [];
8
9
 
@@ -88,6 +89,25 @@ describe('chatgpt conversation id parsing', () => {
88
89
  });
89
90
 
90
91
  describe('chatgpt send selectors', () => {
92
+ it('inlines the composer locator without returning before caller code runs', () => {
93
+ const dom = new JSDOM('<!doctype html><div id="prompt-textarea" contenteditable="true"></div>', {
94
+ url: 'https://chatgpt.com/',
95
+ runScripts: 'outside-only',
96
+ });
97
+ const composer = dom.window.document.querySelector('#prompt-textarea');
98
+ composer.getBoundingClientRect = () => ({ width: 320, height: 48 });
99
+
100
+ const result = dom.window.eval(`
101
+ (() => {
102
+ ${__test__.buildComposerLocatorScript()}
103
+ const composer = findComposer();
104
+ return !!composer && composer.getAttribute(markerAttr) === '1';
105
+ })()
106
+ `);
107
+
108
+ expect(result).toBe(true);
109
+ });
110
+
91
111
  it('keeps locale-independent send-button selector before aria-label fallbacks', async () => {
92
112
  const page = {
93
113
  wait: vi.fn().mockResolvedValue(undefined),
@@ -143,6 +163,73 @@ describe('chatgpt send selectors', () => {
143
163
  });
144
164
  });
145
165
 
166
+ describe('chatgpt generated image detection', () => {
167
+ function createDomPage(html, setup = () => {}) {
168
+ const dom = new JSDOM(html, {
169
+ url: 'https://chatgpt.com/c/demo',
170
+ runScripts: 'outside-only',
171
+ });
172
+ setup(dom.window);
173
+ return {
174
+ evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
175
+ };
176
+ }
177
+
178
+ it('detects visible CSS background images when ChatGPT does not render a plain img', async () => {
179
+ const page = createDomPage(`
180
+ <!doctype html>
181
+ <main>
182
+ <div class="avatar" style="background-image: url('https://chatgpt.com/avatar.png')"></div>
183
+ <button data-testid="generated-image" style="background-image: url('/backend-api/generated/foo.webp')"></button>
184
+ </main>
185
+ `, (window) => {
186
+ for (const el of window.document.querySelectorAll('div, button')) {
187
+ el.getBoundingClientRect = () => ({ width: 512, height: 512 });
188
+ }
189
+ });
190
+
191
+ await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
192
+ 'https://chatgpt.com/backend-api/generated/foo.webp',
193
+ ]);
194
+ });
195
+
196
+ it('detects visible generated canvases as data URLs', async () => {
197
+ const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
198
+ const canvas = window.document.querySelector('canvas');
199
+ canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
200
+ canvas.toDataURL = () => 'data:image/png;base64,ZmFrZQ==';
201
+ });
202
+
203
+ await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
204
+ 'data:image/png;base64,ZmFrZQ==',
205
+ ]);
206
+ });
207
+
208
+ it('exports assets for generated CSS background images', async () => {
209
+ const imageUrl = 'https://chatgpt.com/backend-api/generated/foo.webp';
210
+ const page = createDomPage(`
211
+ <!doctype html>
212
+ <button style="background-image: url('/backend-api/generated/foo.webp')"></button>
213
+ `, (window) => {
214
+ const button = window.document.querySelector('button');
215
+ button.getBoundingClientRect = () => ({ width: 512, height: 512 });
216
+ window.fetch = vi.fn().mockResolvedValue({
217
+ ok: true,
218
+ blob: async () => new window.Blob(['fake-image'], { type: 'image/webp' }),
219
+ });
220
+ });
221
+
222
+ await expect(getChatGPTImageAssets(page, [imageUrl])).resolves.toEqual([
223
+ expect.objectContaining({
224
+ url: imageUrl,
225
+ mimeType: 'image/webp',
226
+ width: 512,
227
+ height: 512,
228
+ }),
229
+ ]);
230
+ });
231
+ });
232
+
146
233
  describe('chatgpt image upload helper', () => {
147
234
  it('validates local images without a browser page', async () => {
148
235
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
@@ -218,7 +305,10 @@ describe('chatgpt image upload helper', () => {
218
305
  setFileInput: vi.fn().mockRejectedValue(new Error('No element found')),
219
306
  wait: vi.fn().mockResolvedValue(undefined),
220
307
  evaluate: vi.fn((script) => {
221
- return Promise.resolve({ ok: true });
308
+ if (String(script).includes('new DataTransfer()')) {
309
+ return Promise.resolve({ ok: true });
310
+ }
311
+ return Promise.resolve(true);
222
312
  }),
223
313
  };
224
314
 
@@ -1,4 +1,11 @@
1
- import { CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { unwrapEvaluateResult } from './evaluate-result.js';
3
+
4
+ function isAuthLikeError(code, message) {
5
+ const text = String(message ?? '');
6
+ return code === 401 || code === 403 || /login|cookie|auth|captcha|verify|forbidden|permission|登录|登陆|权限|验证|验证码/i.test(text);
7
+ }
8
+
2
9
  /**
3
10
  * Execute a fetch() call inside the Chrome browser context via page.evaluate.
4
11
  * This ensures a_bogus signing and cookies are handled automatically by the browser.
@@ -6,36 +13,53 @@ import { CommandExecutionError } from '@jackwener/opencli/errors';
6
13
  export async function browserFetch(page, method, url, options = {}) {
7
14
  const js = `
8
15
  (async () => {
9
- const res = await fetch(${JSON.stringify(url)}, {
10
- method: ${JSON.stringify(method)},
11
- credentials: 'include',
12
- headers: {
13
- 'Content-Type': 'application/json',
14
- ...${JSON.stringify(options.headers ?? {})}
15
- },
16
- ${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
17
- });
18
- const text = await res.text();
19
- if (!text) return null;
20
- return JSON.parse(text);
16
+ const controller = new AbortController();
17
+ const timer = setTimeout(() => controller.abort(), ${Number(options.timeoutMs ?? 30000)});
18
+ try {
19
+ const res = await fetch(${JSON.stringify(url)}, {
20
+ method: ${JSON.stringify(method)},
21
+ credentials: 'include',
22
+ signal: controller.signal,
23
+ headers: {
24
+ 'Content-Type': 'application/json',
25
+ ...${JSON.stringify(options.headers ?? {})}
26
+ },
27
+ ${options.body ? `body: JSON.stringify(${JSON.stringify(options.body)}),` : ''}
28
+ });
29
+ const text = await res.text();
30
+ try {
31
+ return JSON.parse(text);
32
+ } catch (error) {
33
+ return { status_code: res.ok ? -2 : res.status, status_msg: \`JSON parse failed: \${text.slice(0, 500) || String(error && error.message || error)}\` };
34
+ }
35
+ } catch (error) {
36
+ return { status_code: -1, status_msg: String(error && error.message || error) };
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
21
40
  })()
22
41
  `;
23
42
  let result;
24
43
  try {
25
- result = await page.evaluate(js);
44
+ result = unwrapEvaluateResult(await page.evaluate(js));
26
45
  }
27
46
  catch (error) {
28
- const message = error instanceof Error ? error.message : String(error);
29
- throw new CommandExecutionError(`Douyin API request failed: ${message}`);
47
+ throw new CommandExecutionError(`Douyin API request failed (${method} ${url}): ${error instanceof Error ? error.message : String(error)}`);
48
+ }
49
+ if (result == null) {
50
+ throw new CommandExecutionError(`Empty response from Douyin API (${method} ${url})`);
30
51
  }
31
- if (result === null || result === undefined) {
32
- throw new CommandExecutionError('Empty response from Douyin API');
52
+ if (Array.isArray(result) || typeof result !== 'object') {
53
+ throw new CommandExecutionError(`Malformed response from Douyin API (${method} ${url})`);
33
54
  }
34
55
  if (result && typeof result === 'object' && 'status_code' in result) {
35
56
  const code = result.status_code;
36
57
  if (code !== 0) {
37
- const msg = result.status_msg ?? 'unknown error';
38
- throw new CommandExecutionError(`Douyin API error ${code}: ${msg}`);
58
+ const msg = result.status_msg ?? result.message ?? 'unknown error';
59
+ if (isAuthLikeError(code, msg)) {
60
+ throw new AuthRequiredError('creator.douyin.com', `Douyin API auth/permission error ${code} at ${method} ${url}: ${msg}`);
61
+ }
62
+ throw new CommandExecutionError(`Douyin API error ${code} at ${method} ${url}: ${msg}`);
39
63
  }
40
64
  }
41
65
  return result;
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
3
  import { browserFetch } from './browser-fetch.js';
3
4
  function makePage(result) {
4
5
  return {
@@ -18,10 +19,20 @@ describe('browserFetch', () => {
18
19
  const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
19
20
  expect(result).toEqual({ status_code: 0, data: { ak: 'KEY' } });
20
21
  });
22
+ it('unwraps Browser Bridge {session,data} envelopes', async () => {
23
+ const page = makePage({ session: 'site:douyin:test', data: { status_code: 0, data: { ok: true } } });
24
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
25
+ .resolves.toEqual({ status_code: 0, data: { ok: true } });
26
+ });
21
27
  it('throws when status_code is non-zero', async () => {
22
28
  const page = makePage({ status_code: 8, message: 'fail' });
23
29
  await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API error 8');
24
30
  });
31
+ it('maps auth-like API errors to AuthRequiredError', async () => {
32
+ const page = makePage({ status_code: 401, status_msg: 'login required' });
33
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
34
+ .rejects.toBeInstanceOf(AuthRequiredError);
35
+ });
25
36
  it('returns result even when no status_code field', async () => {
26
37
  const page = makePage({ some_field: 'value' });
27
38
  const result = await browserFetch(page, 'GET', 'https://creator.douyin.com/api/test');
@@ -35,9 +46,19 @@ describe('browserFetch', () => {
35
46
  const page = makePage(undefined);
36
47
  await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Empty response from Douyin API');
37
48
  });
49
+ it('throws typed on malformed primitive response body', async () => {
50
+ const page = makePage('not-json-object');
51
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
52
+ .rejects.toBeInstanceOf(CommandExecutionError);
53
+ });
54
+ it('throws typed when browser fetch returns a non-JSON body', async () => {
55
+ const page = makePage({ status_code: -2, status_msg: 'JSON parse failed: <html>not-json</html>' });
56
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test'))
57
+ .rejects.toThrow('Douyin API error -2');
58
+ });
38
59
  it('wraps browser-side fetch or JSON parse failures', async () => {
39
60
  const page = makePage(null);
40
61
  page.evaluate.mockRejectedValueOnce(new SyntaxError('Unexpected token < in JSON'));
41
- await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API request failed: Unexpected token < in JSON');
62
+ await expect(browserFetch(page, 'GET', 'https://creator.douyin.com/api/test')).rejects.toThrow('Douyin API request failed (GET https://creator.douyin.com/api/test): Unexpected token < in JSON');
42
63
  });
43
64
  });
@@ -0,0 +1,16 @@
1
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ export function unwrapEvaluateResult(payload) {
4
+ if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
5
+ return payload.data;
6
+ }
7
+ return payload;
8
+ }
9
+
10
+ export function requireObjectEvaluateResult(payload, context) {
11
+ const result = unwrapEvaluateResult(payload);
12
+ if (!result || Array.isArray(result) || typeof result !== 'object') {
13
+ throw new CommandExecutionError(`${context}: malformed evaluate payload`);
14
+ }
15
+ return result;
16
+ }