@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.
- package/README.md +8 -49
- package/README.zh-CN.md +8 -52
- package/cli-manifest.json +1796 -191
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/suno/generate.js +5 -0
- package/clis/suno/generate.test.js +9 -0
- package/clis/suno/status.js +3 -2
- package/clis/suno/utils.js +33 -24
- package/clis/suno/utils.test.js +106 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-remove.js +1 -1
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/search.test.js +35 -0
- package/clis/twitter/shared.js +11 -0
- package/clis/twitter/shared.test.js +37 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +80 -0
- package/clis/weread/search.js +17 -2
- package/clis/xiaohongshu/creator-note-detail.js +165 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
- package/clis/xiaohongshu/creator-notes.js +251 -2
- package/clis/xiaohongshu/creator-notes.test.js +79 -2
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/zhihu/answer-comments.js +2 -21
- package/clis/zhihu/answer-detail.js +2 -31
- package/clis/zhihu/collection.js +2 -14
- package/clis/zhihu/collection.test.js +4 -3
- package/clis/zhihu/question.js +1 -9
- package/clis/zhihu/question.test.js +2 -2
- package/clis/zhihu/search.js +1 -12
- package/clis/zhihu/search.test.js +2 -2
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.test.js +3 -1
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/package.json +1 -1
package/clis/twitter/reply.js
CHANGED
|
@@ -90,19 +90,22 @@ async function insertReplyText(page, text) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
async function clickReplyButton(page) {
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
});
|
package/clis/twitter/search.js
CHANGED
|
@@ -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 || '
|
|
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
|
});
|
package/clis/twitter/shared.js
CHANGED
|
@@ -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
|
+
});
|
package/clis/twitter/utils.js
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
+
});
|