@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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upwork adapter utilities.
|
|
3
|
+
*
|
|
4
|
+
* Upwork is a Nuxt (Vue) SSR app behind Cloudflare. Every adapter runs
|
|
5
|
+
* through the user's logged-in browser session (Strategy.COOKIE,
|
|
6
|
+
* browser: true) because bare fetches hit a `__cf_bm` challenge and
|
|
7
|
+
* because most surfaces only render data for an authenticated user.
|
|
8
|
+
*
|
|
9
|
+
* The list pages (search, best-matches feed) ship their full result
|
|
10
|
+
* payload inside `window.__NUXT__.state` — we read straight from that
|
|
11
|
+
* global instead of DOM-scraping rendered cards. Job detail uses the
|
|
12
|
+
* Vuex store (`window.$nuxt.$store.state.jobDetails.*`). All helpers
|
|
13
|
+
* below are pure (arg validation, decoders, URL builders, row mappers)
|
|
14
|
+
* so they stay unit-testable without a browser.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
ArgumentError,
|
|
19
|
+
CommandExecutionError,
|
|
20
|
+
} from '@jackwener/opencli/errors';
|
|
21
|
+
|
|
22
|
+
export const UPWORK_ORIGIN = 'https://www.upwork.com';
|
|
23
|
+
|
|
24
|
+
const CIPHERTEXT_PATTERN = /^~0[12]\d{15,21}$/;
|
|
25
|
+
|
|
26
|
+
const FEED_TABS = {
|
|
27
|
+
'best-matches': { path: '/nx/find-work/best-matches', state: 'feedBestMatch' },
|
|
28
|
+
'most-recent': { path: '/nx/find-work/most-recent', state: 'feedMostRecent' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const SORT_VALUES = new Set(['recency', 'relevance', 'client_total_charge', 'client_total_reviews']);
|
|
32
|
+
|
|
33
|
+
export function unwrapBrowserResult(value) {
|
|
34
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) {
|
|
35
|
+
return value.data;
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isPlainObject(value) {
|
|
41
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function coerceInt(value) {
|
|
45
|
+
if (value === undefined || value === null || value === '') return NaN;
|
|
46
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
47
|
+
return Number.isFinite(n) && Number.isInteger(n) ? n : NaN;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function requireQuery(value, label = 'query') {
|
|
51
|
+
const q = String(value ?? '').trim();
|
|
52
|
+
if (!q) throw new ArgumentError(`upwork ${label} cannot be empty`);
|
|
53
|
+
return q;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function requirePositiveInt(value, defaultValue, label) {
|
|
57
|
+
const raw = value ?? defaultValue;
|
|
58
|
+
const n = coerceInt(raw);
|
|
59
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
60
|
+
throw new ArgumentError(`upwork ${label} must be a positive integer`);
|
|
61
|
+
}
|
|
62
|
+
return n;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function requireBoundedInt(value, defaultValue, min, max, label) {
|
|
66
|
+
const n = requirePositiveInt(value, defaultValue, label);
|
|
67
|
+
if (n < min) throw new ArgumentError(`upwork ${label} must be >= ${min}`);
|
|
68
|
+
if (n > max) throw new ArgumentError(`upwork ${label} must be <= ${max}`);
|
|
69
|
+
return n;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Upwork job ids are the ciphertext form starting with `~01` or `~02`
|
|
74
|
+
* (the encoded uid surfaced everywhere in URLs and search results).
|
|
75
|
+
* Accepts a bare ciphertext or a full `/jobs/~02…` URL.
|
|
76
|
+
*/
|
|
77
|
+
export function requireCiphertext(value) {
|
|
78
|
+
let id = String(value ?? '').trim();
|
|
79
|
+
if (!id) throw new ArgumentError('upwork job id is required');
|
|
80
|
+
const urlMatch = id.match(/~0[12]\d+/);
|
|
81
|
+
if (urlMatch) id = urlMatch[0];
|
|
82
|
+
if (!CIPHERTEXT_PATTERN.test(id)) {
|
|
83
|
+
throw new ArgumentError(`upwork job id "${value}" is not a valid ciphertext (expected ~01… or ~02… followed by digits)`);
|
|
84
|
+
}
|
|
85
|
+
return id;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function requireFeedTab(value, defaultValue = 'best-matches') {
|
|
89
|
+
const v = String(value ?? defaultValue).trim().toLowerCase();
|
|
90
|
+
if (!FEED_TABS[v]) {
|
|
91
|
+
throw new ArgumentError(`upwork tab must be one of ${Object.keys(FEED_TABS).join(' / ')}, got "${value}"`);
|
|
92
|
+
}
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function requireSort(value, defaultValue = 'recency') {
|
|
97
|
+
const v = String(value ?? defaultValue).trim().toLowerCase();
|
|
98
|
+
if (!SORT_VALUES.has(v)) {
|
|
99
|
+
throw new ArgumentError(`upwork sort must be one of ${Array.from(SORT_VALUES).join(' / ')}, got "${value}"`);
|
|
100
|
+
}
|
|
101
|
+
return v;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the Upwork search URL. Only forwards filters the user actually
|
|
106
|
+
* supplied so the URL stays canonical and round-trippable.
|
|
107
|
+
*/
|
|
108
|
+
export function buildSearchUrl({ query, location, category, sort, page, perPage }) {
|
|
109
|
+
const params = new URLSearchParams();
|
|
110
|
+
params.set('q', query);
|
|
111
|
+
if (location) params.set('location', location);
|
|
112
|
+
if (category) params.set('category2_uid', category);
|
|
113
|
+
if (sort && sort !== 'recency') params.set('sort', sort);
|
|
114
|
+
if (perPage && perPage !== 10) params.set('per_page', String(perPage));
|
|
115
|
+
if (page && page > 1) params.set('page', String(page));
|
|
116
|
+
return `${UPWORK_ORIGIN}/nx/search/jobs/?${params.toString()}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildFeedUrl(tab) {
|
|
120
|
+
const t = FEED_TABS[tab];
|
|
121
|
+
if (!t) throw new ArgumentError(`unknown feed tab "${tab}"`);
|
|
122
|
+
return `${UPWORK_ORIGIN}${t.path}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function feedStateKey(tab) {
|
|
126
|
+
const t = FEED_TABS[tab];
|
|
127
|
+
if (!t) throw new ArgumentError(`unknown feed tab "${tab}"`);
|
|
128
|
+
return t.state;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildJobUrl(ciphertext) {
|
|
132
|
+
return `${UPWORK_ORIGIN}/jobs/${ciphertext}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isValidCiphertext(value) {
|
|
136
|
+
return CIPHERTEXT_PATTERN.test(String(value ?? '').trim());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Strip Upwork's `<span class="highlight">…</span>` markup that wraps
|
|
141
|
+
* matched query terms in search results, then collapse whitespace.
|
|
142
|
+
* Empty / null returns ''.
|
|
143
|
+
*/
|
|
144
|
+
export function stripHighlight(text) {
|
|
145
|
+
if (text == null) return '';
|
|
146
|
+
return String(text)
|
|
147
|
+
.replace(/<span class="highlight">/g, '')
|
|
148
|
+
.replace(/<\/span>/g, '')
|
|
149
|
+
.replace(/\s+/g, ' ')
|
|
150
|
+
.trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Decode `tierText` codes into stable lowercase labels.
|
|
155
|
+
* - search rows use the i18n-keyed form: `jsn_Entry_205` / `_Intermediate_206` / `_Expert_207`
|
|
156
|
+
* - feed rows already pass through the rendered label: `Entry level` / `Intermediate` / `Expert`
|
|
157
|
+
* - detail uses a numeric `contractorTier`: 1 / 2 / 3
|
|
158
|
+
* Returns 'entry' | 'intermediate' | 'expert' | '' (unknown).
|
|
159
|
+
*/
|
|
160
|
+
export function decodeExperienceLevel(value) {
|
|
161
|
+
if (value == null || value === '') return '';
|
|
162
|
+
if (typeof value === 'number') {
|
|
163
|
+
if (value === 1) return 'entry';
|
|
164
|
+
if (value === 2) return 'intermediate';
|
|
165
|
+
if (value === 3) return 'expert';
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
const v = String(value).toLowerCase();
|
|
169
|
+
if (v.includes('entry')) return 'entry';
|
|
170
|
+
if (v.includes('intermediate')) return 'intermediate';
|
|
171
|
+
if (v.includes('expert')) return 'expert';
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Decode the `engagement` workload code. Search rows ship it as
|
|
177
|
+
* `usnuxt_Engagement_421.fullTime` / `.partTime`; detail surfaces it
|
|
178
|
+
* pre-rendered as `More than 30 hrs/week`. Returns 'full-time' /
|
|
179
|
+
* 'part-time' / '' or passes the rendered string through.
|
|
180
|
+
*/
|
|
181
|
+
export function decodeWorkload(value) {
|
|
182
|
+
if (value == null || value === '') return '';
|
|
183
|
+
const v = String(value);
|
|
184
|
+
const suffix = v.includes('.') ? v.split('.').pop() : v;
|
|
185
|
+
const lower = suffix.toLowerCase();
|
|
186
|
+
if (lower === 'fulltime' || lower.includes('full')) return 'full-time';
|
|
187
|
+
if (lower === 'parttime' || lower.includes('part')) return 'part-time';
|
|
188
|
+
if (lower.includes('hrs/week') || lower.includes('hours')) return suffix.trim();
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Decode `proposalsTier` to a compact bucket label. Search ships it as
|
|
194
|
+
* `usnuxt_JobProposalTier_418.lessThan5` etc; feed ships it pre-rendered
|
|
195
|
+
* as `15 to 20` / `5 to 10`. Returns the bucket like '<5' / '5-10' /
|
|
196
|
+
* '20-50' / '50+', or '' if unrecognized.
|
|
197
|
+
*/
|
|
198
|
+
export function decodeProposalsTier(value) {
|
|
199
|
+
if (value == null || value === '') return '';
|
|
200
|
+
const v = String(value);
|
|
201
|
+
const suffix = v.includes('.') ? v.split('.').pop() : v;
|
|
202
|
+
const s = suffix.trim();
|
|
203
|
+
if (/^lessThan(\d+)$/i.test(s)) return `<${s.match(/\d+/)[0]}`;
|
|
204
|
+
if (/^(\d+)plus$/i.test(s)) return `${s.match(/\d+/)[0]}+`;
|
|
205
|
+
const range = s.match(/^(\d+)\s*(?:to|-|–)\s*(\d+)$/i);
|
|
206
|
+
if (range) return `${range[1]}-${range[2]}`;
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format the budget into a single human-readable column.
|
|
212
|
+
* - hourly (type 2): "$40-$70/hr" or "$30/hr" or "" (no budget set)
|
|
213
|
+
* - fixed (type 1): "$200" or "" (no amount)
|
|
214
|
+
* Used by search/feed; detail uses formatBudgetFromDetail (different shape).
|
|
215
|
+
*/
|
|
216
|
+
export function formatBudget(job) {
|
|
217
|
+
const type = job?.type;
|
|
218
|
+
const min = Number(job?.hourlyBudget?.min) || 0;
|
|
219
|
+
const max = Number(job?.hourlyBudget?.max) || 0;
|
|
220
|
+
const amount = Number(job?.amount?.amount) || 0;
|
|
221
|
+
if (type === 2) {
|
|
222
|
+
if (min > 0 && max > 0 && max !== min) return `$${min}-$${max}/hr`;
|
|
223
|
+
if (max > 0) return `$${max}/hr`;
|
|
224
|
+
if (min > 0) return `$${min}/hr`;
|
|
225
|
+
return '';
|
|
226
|
+
}
|
|
227
|
+
if (type === 1) return amount > 0 ? `$${amount}` : '';
|
|
228
|
+
return '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Detail page uses `extendedBudgetInfo.{hourlyBudgetMin,Max}` + `budget.amount`. */
|
|
232
|
+
export function formatBudgetFromDetail(job) {
|
|
233
|
+
const type = job?.type;
|
|
234
|
+
const min = Number(job?.extendedBudgetInfo?.hourlyBudgetMin) || 0;
|
|
235
|
+
const max = Number(job?.extendedBudgetInfo?.hourlyBudgetMax) || 0;
|
|
236
|
+
const amount = Number(job?.budget?.amount) || 0;
|
|
237
|
+
if (type === 2) {
|
|
238
|
+
if (min > 0 && max > 0 && max !== min) return `$${min}-$${max}/hr`;
|
|
239
|
+
if (max > 0) return `$${max}/hr`;
|
|
240
|
+
if (min > 0) return `$${min}/hr`;
|
|
241
|
+
return '';
|
|
242
|
+
}
|
|
243
|
+
if (type === 1) return amount > 0 ? `$${amount}` : '';
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function jobType(type) {
|
|
248
|
+
if (type === 1) return 'fixed';
|
|
249
|
+
if (type === 2) return 'hourly';
|
|
250
|
+
return '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Join skills/attrs into a comma-separated string. Search rows have
|
|
255
|
+
* `attrs[].prettyName`; feed rows additionally have `skills[].prefLabel`;
|
|
256
|
+
* detail has neither (skills live elsewhere). The function picks
|
|
257
|
+
* whichever array is populated and dedupes.
|
|
258
|
+
*/
|
|
259
|
+
export function formatSkills(job) {
|
|
260
|
+
const candidates = [];
|
|
261
|
+
const arrs = [job?.attrs, job?.skills, job?.ontologySkills];
|
|
262
|
+
for (const arr of arrs) {
|
|
263
|
+
if (!Array.isArray(arr)) continue;
|
|
264
|
+
for (const s of arr) {
|
|
265
|
+
const name = (s?.prettyName ?? s?.prefLabel ?? s?.name ?? '').trim();
|
|
266
|
+
if (name && !candidates.includes(name)) candidates.push(name);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return candidates.join(', ');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Normalize a search/feed job entry into the shared LIST_COLUMNS row shape.
|
|
274
|
+
* Returns null when the row lacks a round-trippable ciphertext identity.
|
|
275
|
+
*/
|
|
276
|
+
export function jobToListRow(job, rank) {
|
|
277
|
+
const id = String(job?.ciphertext ?? '').trim();
|
|
278
|
+
if (!isValidCiphertext(id)) return null;
|
|
279
|
+
const client = job?.client || {};
|
|
280
|
+
const country = client?.location?.country || '';
|
|
281
|
+
const rating = Number(client?.totalFeedback);
|
|
282
|
+
return {
|
|
283
|
+
rank,
|
|
284
|
+
id,
|
|
285
|
+
title: stripHighlight(job?.title),
|
|
286
|
+
type: jobType(job?.type),
|
|
287
|
+
budget: formatBudget(job),
|
|
288
|
+
experienceLevel: decodeExperienceLevel(job?.tierText ?? job?.tier),
|
|
289
|
+
proposalsTier: decodeProposalsTier(job?.proposalsTier),
|
|
290
|
+
skills: formatSkills(job),
|
|
291
|
+
clientCountry: country,
|
|
292
|
+
clientRating: Number.isFinite(rating) && rating > 0 ? rating : null,
|
|
293
|
+
publishedOn: job?.publishedOn || job?.createdOn || '',
|
|
294
|
+
url: buildJobUrl(id),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function jobsToListRows(jobs, { offset = 0, limit } = {}) {
|
|
299
|
+
const rows = [];
|
|
300
|
+
const source = limit ? jobs.slice(0, limit) : jobs;
|
|
301
|
+
for (const [index, job] of source.entries()) {
|
|
302
|
+
const rank = offset + index + 1;
|
|
303
|
+
const row = jobToListRow(job, rank);
|
|
304
|
+
if (!row) {
|
|
305
|
+
throw new CommandExecutionError(`Upwork result at rank ${rank} did not include a valid ciphertext id; cannot produce round-trippable detail rows.`);
|
|
306
|
+
}
|
|
307
|
+
rows.push(row);
|
|
308
|
+
}
|
|
309
|
+
return rows;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export const LIST_COLUMNS = [
|
|
313
|
+
'rank', 'id', 'title', 'type', 'budget',
|
|
314
|
+
'experienceLevel', 'proposalsTier', 'skills',
|
|
315
|
+
'clientCountry', 'clientRating', 'publishedOn', 'url',
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
export const DETAIL_COLUMNS = [
|
|
319
|
+
'id', 'title', 'type', 'budget', 'experienceLevel', 'workload',
|
|
320
|
+
'category', 'skills', 'description',
|
|
321
|
+
'clientCountry', 'clientSpent', 'clientHires', 'clientRating',
|
|
322
|
+
'proposalsCount', 'publishedOn', 'url',
|
|
323
|
+
];
|