@jackwener/opencli 1.8.0 → 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 (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -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
  });
@@ -215,7 +215,7 @@ function tweetToRow(result, seen) {
215
215
  const bio = tweetUser?.legacy?.description || '';
216
216
  return {
217
217
  id: tweet.rest_id,
218
- author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
218
+ author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '',
219
219
  bio,
220
220
  text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
221
221
  created_at: tweet.legacy?.created_at || '',
@@ -154,6 +154,40 @@ describe('twitter search command', () => {
154
154
  expect(result.map((row) => row.id)).toEqual(['1', '2', '3', '4', '5', '6', '7']);
155
155
  expect(page.evaluate).toHaveBeenCalledTimes(8);
156
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
+ });
157
191
  });
158
192
 
159
193
  describe('twitter search filter helpers', () => {
@@ -346,4 +380,5 @@ describe('twitter search end-to-end with new filters', () => {
346
380
  const searchFetch = evaluate.mock.calls[1][0];
347
381
  expect(searchFetch).toContain('\\"rawQuery\\":\\"from:alice\\"');
348
382
  });
383
+
349
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;
@@ -441,4 +451,5 @@ export const __test__ = {
441
451
  extractQuotedTweet,
442
452
  parseTweetUrl,
443
453
  buildTwitterArticleScopeSource,
454
+ looksLikePrivateTwitterTimeline,
444
455
  };
@@ -3,7 +3,7 @@ import { JSDOM } from 'jsdom';
3
3
  import { __test__ } from './shared.js';
4
4
  import { ArgumentError } from '@jackwener/opencli/errors';
5
5
 
6
- const { extractMedia, extractCard, extractQuotedTweet, parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult, normalizeTwitterGraphqlPayload, normalizeTwitterScreenName, sanitizeTwitterOperationMetadata } = __test__;
6
+ const { extractMedia, extractCard, extractQuotedTweet, parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult, normalizeTwitterGraphqlPayload, normalizeTwitterScreenName, sanitizeTwitterOperationMetadata, looksLikePrivateTwitterTimeline } = __test__;
7
7
 
8
8
  function makeCardTweet({ name, bindings, expandedUrl, urls }) {
9
9
  const tweet = {
@@ -756,3 +756,39 @@ describe('twitter extractQuotedTweet', () => {
756
756
  expect(q).not.toHaveProperty('quoted_tweet');
757
757
  });
758
758
  });
759
+
760
+ describe('looksLikePrivateTwitterTimeline', () => {
761
+ it('returns true when result.timeline is an empty object', () => {
762
+ expect(looksLikePrivateTwitterTimeline({
763
+ data: { user: { result: { __typename: 'User', timeline: {} } } },
764
+ })).toBe(true);
765
+ });
766
+ it('returns false when timeline.timeline.instructions is present', () => {
767
+ expect(looksLikePrivateTwitterTimeline({
768
+ data: { user: { result: { timeline: { timeline: { instructions: [] } } } } },
769
+ })).toBe(false);
770
+ });
771
+ it('returns false when timeline_v2.timeline.instructions is present', () => {
772
+ expect(looksLikePrivateTwitterTimeline({
773
+ data: { user: { result: { timeline_v2: { timeline: { instructions: [] } } } } },
774
+ })).toBe(false);
775
+ });
776
+ it('returns false when result is missing entirely', () => {
777
+ expect(looksLikePrivateTwitterTimeline({})).toBe(false);
778
+ expect(looksLikePrivateTwitterTimeline(null)).toBe(false);
779
+ expect(looksLikePrivateTwitterTimeline({ data: { user: {} } })).toBe(false);
780
+ });
781
+ it('returns false for non-empty malformed timeline objects', () => {
782
+ expect(looksLikePrivateTwitterTimeline({
783
+ data: { user: { result: { timeline: { unexpected: true } } } },
784
+ })).toBe(false);
785
+ expect(looksLikePrivateTwitterTimeline({
786
+ data: { user: { result: { timeline: { timeline: {} } } } },
787
+ })).toBe(false);
788
+ });
789
+ it('returns true when timeline_v2.timeline is an empty object', () => {
790
+ expect(looksLikePrivateTwitterTimeline({
791
+ data: { user: { result: { timeline_v2: { timeline: {} } } } },
792
+ })).toBe(true);
793
+ });
794
+ });
@@ -19,6 +19,8 @@ export const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gi
19
19
 
20
20
  /** 20 MB hard cap. Twitter allows ~5MB images / 15MB GIFs; 20MB is a safety net. */
21
21
  export const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024;
22
+ const MEDIA_UPLOAD_POLL_MS = 500;
23
+ const MEDIA_UPLOAD_TIMEOUT_MS = 30_000;
22
24
 
23
25
  const CONTENT_TYPE_TO_EXTENSION = {
24
26
  'image/jpeg': '.jpg',
@@ -133,7 +135,7 @@ export async function attachComposerImage(page, absImagePath, fileInputSelector
133
135
  uploaded = true;
134
136
  } catch (err) {
135
137
  const msg = err instanceof Error ? err.message : String(err);
136
- if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
138
+ if (!isRecoverableFileInputError(msg)) {
137
139
  throw new Error(`Image upload failed: ${msg}`);
138
140
  }
139
141
  // setFileInput not supported by extension — fall through to base64 fallback.
@@ -167,7 +169,21 @@ export async function attachComposerImage(page, absImagePath, fileInputSelector
167
169
  const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
168
170
  dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
169
171
 
170
- Object.defineProperty(input, 'files', { value: dt.files, writable: false });
172
+ let assigned = false;
173
+ try {
174
+ Object.defineProperty(input, 'files', { value: dt.files, writable: false, configurable: true });
175
+ assigned = input.files && input.files.length > 0;
176
+ } catch(e) {
177
+ // files property not redefinable — use nativeInputValueSetter trick
178
+ try {
179
+ const nativeInputFileSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'files');
180
+ if (nativeInputFileSetter && nativeInputFileSetter.set) {
181
+ nativeInputFileSetter.set.call(input, dt.files);
182
+ assigned = input.files && input.files.length > 0;
183
+ }
184
+ } catch(e2) { /* ignore */ }
185
+ }
186
+ if (!assigned) return { ok: false, error: 'Could not assign files to input' };
171
187
  input.dispatchEvent(new Event('change', { bubbles: true }));
172
188
  input.dispatchEvent(new Event('input', { bubbles: true }));
173
189
  return { ok: true };
@@ -177,23 +193,42 @@ export async function attachComposerImage(page, absImagePath, fileInputSelector
177
193
  throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
178
194
  }
179
195
  }
180
- await page.wait(2);
181
- const uploadState = await page.evaluate(`
182
- (() => {
183
- const previewCount = document.querySelectorAll(
184
- '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
185
- ).length;
186
- const hasMedia = previewCount > 0
187
- || !!document.querySelector('[data-testid="attachments"]')
188
- || !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
189
- /remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
196
+ const uploadState = await waitForComposerMediaReady(page, 1);
197
+ if (!uploadState?.ok) {
198
+ throw new Error(uploadState?.message ?? 'Image upload failed: preview did not appear.');
199
+ }
200
+ }
201
+
202
+ export function isRecoverableFileInputError(error) {
203
+ const msg = error instanceof Error ? error.message : String(error);
204
+ return /unknown action|not supported|not[-\s]?allowed|notallowederror/i.test(msg);
205
+ }
206
+
207
+ export async function waitForComposerMediaReady(page, expectedCount = 1) {
208
+ const iterations = Math.ceil(MEDIA_UPLOAD_TIMEOUT_MS / MEDIA_UPLOAD_POLL_MS);
209
+ return page.evaluate(`
210
+ (async () => {
211
+ const expected = ${JSON.stringify(expectedCount)};
212
+ for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
213
+ await new Promise(r => setTimeout(r, ${JSON.stringify(MEDIA_UPLOAD_POLL_MS)}));
214
+ const previewCount = document.querySelectorAll(
215
+ '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="attachments"] [role="group"], [data-testid="tweetPhoto"]'
216
+ ).length;
217
+ const blobCount = document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length;
218
+ const removeButtonCount = Array.from(document.querySelectorAll('button,[role="button"]')).filter((el) =>
219
+ /remove media|remove image|remove|编辑/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
220
+ ).length;
221
+ const explicitPreviewCount = Math.max(
222
+ previewCount,
223
+ blobCount,
224
+ removeButtonCount,
225
+ document.querySelectorAll('[data-testid="media-upload-preview"], [data-testid="card.layoutLarge.media"]').length
190
226
  );
191
- return { ok: hasMedia, previewCount };
227
+ if (explicitPreviewCount >= expected) return { ok: true, previewCount: explicitPreviewCount };
228
+ }
229
+ return { ok: false, message: 'Image upload timed out (${MEDIA_UPLOAD_TIMEOUT_MS / 1000}s).' };
192
230
  })()
193
231
  `);
194
- if (!uploadState?.ok) {
195
- throw new Error('Image upload failed: preview did not appear.');
196
- }
197
232
  }
198
233
 
199
234
  // ── Engagement scoring (P3) ────────────────────────────────────────────
@@ -280,6 +315,8 @@ export const __test__ = {
280
315
  resolveImageExtension,
281
316
  downloadRemoteImage,
282
317
  attachComposerImage,
318
+ isRecoverableFileInputError,
319
+ waitForComposerMediaReady,
283
320
  computeEngagementScore,
284
321
  applyTopByEngagement,
285
322
  ENGAGEMENT_WEIGHTS,
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Upwork job detail.
3
+ *
4
+ * Reads the full job posting (title, description, budget, experience
5
+ * level, workload, client stats, applicant count) for a given
6
+ * ciphertext id (the `~02…` form surfaced by `upwork search` /
7
+ * `upwork feed`). Unlike search/feed, the job-detail page does not
8
+ * populate `window.__NUXT__.state` for the job — it rehydrates into
9
+ * the Vuex store at `window.$nuxt.$store.state.jobDetails.{job, buyer,
10
+ * applicants, …}`. We read straight from that store.
11
+ */
12
+
13
+ import { cli, Strategy } from '@jackwener/opencli/registry';
14
+ import {
15
+ CommandExecutionError,
16
+ EmptyResultError,
17
+ AuthRequiredError,
18
+ } from '@jackwener/opencli/errors';
19
+ import {
20
+ DETAIL_COLUMNS,
21
+ buildJobUrl,
22
+ decodeExperienceLevel,
23
+ decodeWorkload,
24
+ formatBudgetFromDetail,
25
+ formatSkills,
26
+ isPlainObject,
27
+ jobType,
28
+ requireCiphertext,
29
+ stripHighlight,
30
+ unwrapBrowserResult,
31
+ } from './utils.js';
32
+
33
+ cli({
34
+ site: 'upwork',
35
+ name: 'detail',
36
+ aliases: ['job', 'view'],
37
+ access: 'read',
38
+ description: 'Read the full Upwork job posting by ciphertext id (e.g. ~022054964136512093518)',
39
+ domain: 'www.upwork.com',
40
+ strategy: Strategy.COOKIE,
41
+ browser: true,
42
+ navigateBefore: false,
43
+ args: [
44
+ { name: 'id', positional: true, required: true, help: 'Job ciphertext id (~01… / ~02…) or full /jobs/~02… URL' },
45
+ ],
46
+ columns: DETAIL_COLUMNS,
47
+ func: async (page, kwargs) => {
48
+ const id = requireCiphertext(kwargs.id);
49
+ const url = buildJobUrl(id);
50
+ await page.goto(url);
51
+ await page.wait(5);
52
+
53
+ let payload;
54
+ try {
55
+ payload = unwrapBrowserResult(await page.evaluate(`(async () => {
56
+ const haveStore = () => !!(window.$nuxt && window.$nuxt.$store && window.$nuxt.$store.state && window.$nuxt.$store.state.jobDetails && window.$nuxt.$store.state.jobDetails.job);
57
+ let ready = haveStore();
58
+ for (let i = 0; i < 30; i++) {
59
+ if (ready) break;
60
+ await new Promise(r => setTimeout(r, 500));
61
+ ready = haveStore();
62
+ }
63
+ const onLogin = /\\/(ab\\/account-security\\/login|nx\\/login)/.test(location.pathname);
64
+ const challenge = (document.title || '').toLowerCase().includes('just a moment') || !!document.querySelector('[id^="cf-"]');
65
+ if (!ready) {
66
+ return { ready, onLogin, challenge, job: null, buyer: null };
67
+ }
68
+ const s = window.$nuxt.$store.state.jobDetails;
69
+ return {
70
+ ready,
71
+ onLogin,
72
+ challenge,
73
+ job: s.job ? JSON.parse(JSON.stringify(s.job)) : null,
74
+ buyer: s.buyer ? JSON.parse(JSON.stringify(s.buyer)) : null,
75
+ };
76
+ })()`));
77
+ }
78
+ catch (e) {
79
+ throw new CommandExecutionError(`Failed to read Upwork job-detail store: ${e?.message ?? e}`, 'The Vuex store was not reachable; try again after opening Upwork in the connected browser.');
80
+ }
81
+
82
+ if (payload?.onLogin) {
83
+ throw new AuthRequiredError('upwork.com', 'Upwork redirected to login. Open https://www.upwork.com in the connected browser and sign in, then retry.');
84
+ }
85
+ if (payload?.challenge) {
86
+ throw new CommandExecutionError('Upwork served a Cloudflare challenge page', 'Open https://www.upwork.com in the connected browser and clear the challenge, then retry.');
87
+ }
88
+ if (!isPlainObject(payload)) {
89
+ throw new CommandExecutionError('Upwork detail returned an unexpected Browser Bridge payload shape');
90
+ }
91
+ if (!payload?.ready || !payload.job) {
92
+ throw new EmptyResultError('upwork detail', `No Upwork job posting found for id "${id}" (may be closed, expired, or private)`);
93
+ }
94
+ if (!isPlainObject(payload.job)) {
95
+ throw new CommandExecutionError('Upwork job-detail store had an unexpected job shape; expected an object.');
96
+ }
97
+
98
+ const job = payload.job;
99
+ const returnedCiphertext = String(job?.ciphertext ?? '').trim();
100
+ if (returnedCiphertext && returnedCiphertext !== id) {
101
+ throw new CommandExecutionError(`Upwork job-detail store returned ciphertext "${returnedCiphertext}" while reading "${id}".`);
102
+ }
103
+ const buyer = payload.buyer || {};
104
+ const stats = buyer?.stats || {};
105
+ const location = buyer?.location || {};
106
+ const category = job?.category?.name || '';
107
+ const skills = formatSkills(job);
108
+ const totalSpent = Number(stats?.totalCharges?.amount);
109
+ const totalHires = Number(stats?.totalJobsWithHires);
110
+ const score = Number(stats?.score);
111
+ const totalApplicants = Number(job?.clientActivity?.totalApplicants);
112
+
113
+ return [{
114
+ id,
115
+ title: stripHighlight(job?.title),
116
+ type: jobType(job?.type),
117
+ budget: formatBudgetFromDetail(job),
118
+ experienceLevel: decodeExperienceLevel(job?.contractorTier),
119
+ workload: decodeWorkload(job?.workload),
120
+ category,
121
+ skills,
122
+ description: String(job?.description ?? '').trim(),
123
+ clientCountry: location?.country || '',
124
+ clientSpent: Number.isFinite(totalSpent) && totalSpent > 0 ? totalSpent : null,
125
+ clientHires: Number.isFinite(totalHires) ? totalHires : null,
126
+ clientRating: Number.isFinite(score) && score > 0 ? score : null,
127
+ proposalsCount: Number.isFinite(totalApplicants) ? totalApplicants : null,
128
+ publishedOn: job?.publishTime || job?.postedOn || job?.createdOn || '',
129
+ url,
130
+ }];
131
+ },
132
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Upwork personalized feed (Best Matches / Most Recent).
3
+ *
4
+ * Reads the logged-in user's recommended-jobs feed. Two tabs are
5
+ * supported: `best-matches` (default, Upwork's relevance-ranked feed)
6
+ * and `most-recent` (chronological). Both surface the full job list in
7
+ * `window.__NUXT__.state.feed{BestMatch,MostRecent}.{jobs, paging}`.
8
+ *
9
+ * Login is required — bare visitors get redirected through the
10
+ * onboarding flow and never see the feed state.
11
+ */
12
+
13
+ import { cli, Strategy } from '@jackwener/opencli/registry';
14
+ import {
15
+ CommandExecutionError,
16
+ EmptyResultError,
17
+ AuthRequiredError,
18
+ } from '@jackwener/opencli/errors';
19
+ import {
20
+ buildFeedUrl,
21
+ feedStateKey,
22
+ isPlainObject,
23
+ jobsToListRows,
24
+ LIST_COLUMNS,
25
+ requireBoundedInt,
26
+ requireFeedTab,
27
+ unwrapBrowserResult,
28
+ } from './utils.js';
29
+
30
+ cli({
31
+ site: 'upwork',
32
+ name: 'feed',
33
+ aliases: ['best-matches'],
34
+ access: 'read',
35
+ description: 'Upwork personalized jobs feed (best-matches | most-recent) — requires login',
36
+ domain: 'www.upwork.com',
37
+ strategy: Strategy.COOKIE,
38
+ browser: true,
39
+ navigateBefore: false,
40
+ args: [
41
+ { name: 'tab', positional: true, required: false, default: 'best-matches', help: 'Feed tab: best-matches | most-recent' },
42
+ { name: 'limit', type: 'int', default: 20, help: 'Max rows to return (1-50, capped at one page)' },
43
+ ],
44
+ columns: LIST_COLUMNS,
45
+ func: async (page, kwargs) => {
46
+ const tab = requireFeedTab(kwargs.tab);
47
+ const limit = requireBoundedInt(kwargs.limit, 20, 1, 50, 'limit');
48
+ const stateKey = feedStateKey(tab);
49
+ const url = buildFeedUrl(tab);
50
+
51
+ await page.goto(url);
52
+ await page.wait(5);
53
+
54
+ let payload;
55
+ try {
56
+ payload = unwrapBrowserResult(await page.evaluate(`(async () => {
57
+ const key = ${JSON.stringify(stateKey)};
58
+ const haveState = () => !!(window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state[key]);
59
+ let ready = haveState();
60
+ for (let i = 0; i < 30; i++) {
61
+ if (ready) break;
62
+ await new Promise(r => setTimeout(r, 500));
63
+ ready = haveState();
64
+ }
65
+ const onLogin = /\\/(ab\\/account-security\\/login|nx\\/login)/.test(location.pathname);
66
+ const challenge = (document.title || '').toLowerCase().includes('just a moment') || !!document.querySelector('[id^="cf-"]');
67
+ const state = window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state[key];
68
+ return {
69
+ ready,
70
+ onLogin,
71
+ challenge,
72
+ jobsPresent: !!(state && Object.prototype.hasOwnProperty.call(state, 'jobs')),
73
+ jobs: state ? state.jobs : undefined,
74
+ paging: state && state.paging ? state.paging : null,
75
+ };
76
+ })()`));
77
+ }
78
+ catch (e) {
79
+ throw new CommandExecutionError(`Failed to read Upwork feed state: ${e?.message ?? e}`, 'The Nuxt state global was not reachable; try again after opening Upwork in the connected browser.');
80
+ }
81
+
82
+ if (payload?.onLogin) {
83
+ throw new AuthRequiredError('upwork.com', 'Upwork redirected to login. Open https://www.upwork.com in the connected browser and sign in, then retry.');
84
+ }
85
+ if (payload?.challenge) {
86
+ throw new CommandExecutionError('Upwork served a Cloudflare challenge page', 'Open https://www.upwork.com in the connected browser and clear the challenge, then retry.');
87
+ }
88
+ if (!payload?.ready) {
89
+ throw new CommandExecutionError(`Upwork feed state (window.__NUXT__.state.${stateKey}) was not present within 15s`, 'The page may not have finished hydrating, or the SSR state shape may have changed.');
90
+ }
91
+ if (!isPlainObject(payload)) {
92
+ throw new CommandExecutionError('Upwork feed returned an unexpected Browser Bridge payload shape');
93
+ }
94
+ if (!payload.jobsPresent || !Array.isArray(payload.jobs)) {
95
+ throw new CommandExecutionError(`Upwork feed state had an unexpected jobs shape; expected window.__NUXT__.state.${stateKey}.jobs to be an array.`);
96
+ }
97
+
98
+ const jobs = payload.jobs;
99
+ if (jobs.length === 0) {
100
+ throw new EmptyResultError(`upwork feed ${tab}`, `Upwork ${tab} feed is empty for the current account`);
101
+ }
102
+
103
+ const rows = jobsToListRows(jobs, { limit });
104
+ if (rows.length === 0) {
105
+ throw new CommandExecutionError('Upwork feed results did not include any job with a valid ciphertext id; cannot produce round-trippable detail rows.');
106
+ }
107
+ return rows;
108
+ },
109
+ });