9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
/**
|
||
* Upwork adapter utilities.
|
||
*
|
||
* Upwork is a Nuxt (Vue) SSR app behind Cloudflare. Every adapter runs
|
||
* through the user's logged-in browser session (Strategy.COOKIE,
|
||
* browser: true) because bare fetches hit a `__cf_bm` challenge and
|
||
* because most surfaces only render data for an authenticated user.
|
||
*
|
||
* The list pages (search, best-matches feed) ship their full result
|
||
* payload inside `window.__NUXT__.state` — we read straight from that
|
||
* global instead of DOM-scraping rendered cards. Job detail uses the
|
||
* Vuex store (`window.$nuxt.$store.state.jobDetails.*`). All helpers
|
||
* below are pure (arg validation, decoders, URL builders, row mappers)
|
||
* so they stay unit-testable without a browser.
|
||
*/
|
||
|
||
import {
|
||
ArgumentError,
|
||
CommandExecutionError,
|
||
} from '@jackwener/opencli/errors';
|
||
|
||
export const UPWORK_ORIGIN = 'https://www.upwork.com';
|
||
|
||
const CIPHERTEXT_PATTERN = /^~0[12]\d{15,21}$/;
|
||
|
||
const FEED_TABS = {
|
||
'best-matches': { path: '/nx/find-work/best-matches', state: 'feedBestMatch' },
|
||
'most-recent': { path: '/nx/find-work/most-recent', state: 'feedMostRecent' },
|
||
};
|
||
|
||
const SORT_VALUES = new Set(['recency', 'relevance', 'client_total_charge', 'client_total_reviews']);
|
||
|
||
export function unwrapBrowserResult(value) {
|
||
if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) {
|
||
return value.data;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function isPlainObject(value) {
|
||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function coerceInt(value) {
|
||
if (value === undefined || value === null || value === '') return NaN;
|
||
const n = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(n) && Number.isInteger(n) ? n : NaN;
|
||
}
|
||
|
||
export function requireQuery(value, label = 'query') {
|
||
const q = String(value ?? '').trim();
|
||
if (!q) throw new ArgumentError(`upwork ${label} cannot be empty`);
|
||
return q;
|
||
}
|
||
|
||
export function requirePositiveInt(value, defaultValue, label) {
|
||
const raw = value ?? defaultValue;
|
||
const n = coerceInt(raw);
|
||
if (!Number.isInteger(n) || n <= 0) {
|
||
throw new ArgumentError(`upwork ${label} must be a positive integer`);
|
||
}
|
||
return n;
|
||
}
|
||
|
||
export function requireBoundedInt(value, defaultValue, min, max, label) {
|
||
const n = requirePositiveInt(value, defaultValue, label);
|
||
if (n < min) throw new ArgumentError(`upwork ${label} must be >= ${min}`);
|
||
if (n > max) throw new ArgumentError(`upwork ${label} must be <= ${max}`);
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* Upwork job ids are the ciphertext form starting with `~01` or `~02`
|
||
* (the encoded uid surfaced everywhere in URLs and search results).
|
||
* Accepts a bare ciphertext or a full `/jobs/~02…` URL.
|
||
*/
|
||
export function requireCiphertext(value) {
|
||
let id = String(value ?? '').trim();
|
||
if (!id) throw new ArgumentError('upwork job id is required');
|
||
const urlMatch = id.match(/~0[12]\d+/);
|
||
if (urlMatch) id = urlMatch[0];
|
||
if (!CIPHERTEXT_PATTERN.test(id)) {
|
||
throw new ArgumentError(`upwork job id "${value}" is not a valid ciphertext (expected ~01… or ~02… followed by digits)`);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
export function requireFeedTab(value, defaultValue = 'best-matches') {
|
||
const v = String(value ?? defaultValue).trim().toLowerCase();
|
||
if (!FEED_TABS[v]) {
|
||
throw new ArgumentError(`upwork tab must be one of ${Object.keys(FEED_TABS).join(' / ')}, got "${value}"`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
export function requireSort(value, defaultValue = 'recency') {
|
||
const v = String(value ?? defaultValue).trim().toLowerCase();
|
||
if (!SORT_VALUES.has(v)) {
|
||
throw new ArgumentError(`upwork sort must be one of ${Array.from(SORT_VALUES).join(' / ')}, got "${value}"`);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
/**
|
||
* Build the Upwork search URL. Only forwards filters the user actually
|
||
* supplied so the URL stays canonical and round-trippable.
|
||
*/
|
||
export function buildSearchUrl({ query, location, category, sort, page, perPage }) {
|
||
const params = new URLSearchParams();
|
||
params.set('q', query);
|
||
if (location) params.set('location', location);
|
||
if (category) params.set('category2_uid', category);
|
||
if (sort && sort !== 'recency') params.set('sort', sort);
|
||
if (perPage && perPage !== 10) params.set('per_page', String(perPage));
|
||
if (page && page > 1) params.set('page', String(page));
|
||
return `${UPWORK_ORIGIN}/nx/search/jobs/?${params.toString()}`;
|
||
}
|
||
|
||
export function buildFeedUrl(tab) {
|
||
const t = FEED_TABS[tab];
|
||
if (!t) throw new ArgumentError(`unknown feed tab "${tab}"`);
|
||
return `${UPWORK_ORIGIN}${t.path}`;
|
||
}
|
||
|
||
export function feedStateKey(tab) {
|
||
const t = FEED_TABS[tab];
|
||
if (!t) throw new ArgumentError(`unknown feed tab "${tab}"`);
|
||
return t.state;
|
||
}
|
||
|
||
export function buildJobUrl(ciphertext) {
|
||
return `${UPWORK_ORIGIN}/jobs/${ciphertext}`;
|
||
}
|
||
|
||
export function isValidCiphertext(value) {
|
||
return CIPHERTEXT_PATTERN.test(String(value ?? '').trim());
|
||
}
|
||
|
||
/**
|
||
* Strip Upwork's `<span class="highlight">…</span>` markup that wraps
|
||
* matched query terms in search results, then collapse whitespace.
|
||
* Empty / null returns ''.
|
||
*/
|
||
export function stripHighlight(text) {
|
||
if (text == null) return '';
|
||
return String(text)
|
||
.replace(/<span class="highlight">/g, '')
|
||
.replace(/<\/span>/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Decode `tierText` codes into stable lowercase labels.
|
||
* - search rows use the i18n-keyed form: `jsn_Entry_205` / `_Intermediate_206` / `_Expert_207`
|
||
* - feed rows already pass through the rendered label: `Entry level` / `Intermediate` / `Expert`
|
||
* - detail uses a numeric `contractorTier`: 1 / 2 / 3
|
||
* Returns 'entry' | 'intermediate' | 'expert' | '' (unknown).
|
||
*/
|
||
export function decodeExperienceLevel(value) {
|
||
if (value == null || value === '') return '';
|
||
if (typeof value === 'number') {
|
||
if (value === 1) return 'entry';
|
||
if (value === 2) return 'intermediate';
|
||
if (value === 3) return 'expert';
|
||
return '';
|
||
}
|
||
const v = String(value).toLowerCase();
|
||
if (v.includes('entry')) return 'entry';
|
||
if (v.includes('intermediate')) return 'intermediate';
|
||
if (v.includes('expert')) return 'expert';
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Decode the `engagement` workload code. Search rows ship it as
|
||
* `usnuxt_Engagement_421.fullTime` / `.partTime`; detail surfaces it
|
||
* pre-rendered as `More than 30 hrs/week`. Returns 'full-time' /
|
||
* 'part-time' / '' or passes the rendered string through.
|
||
*/
|
||
export function decodeWorkload(value) {
|
||
if (value == null || value === '') return '';
|
||
const v = String(value);
|
||
const suffix = v.includes('.') ? v.split('.').pop() : v;
|
||
const lower = suffix.toLowerCase();
|
||
if (lower === 'fulltime' || lower.includes('full')) return 'full-time';
|
||
if (lower === 'parttime' || lower.includes('part')) return 'part-time';
|
||
if (lower.includes('hrs/week') || lower.includes('hours')) return suffix.trim();
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Decode `proposalsTier` to a compact bucket label. Search ships it as
|
||
* `usnuxt_JobProposalTier_418.lessThan5` etc; feed ships it pre-rendered
|
||
* as `15 to 20` / `5 to 10`. Returns the bucket like '<5' / '5-10' /
|
||
* '20-50' / '50+', or '' if unrecognized.
|
||
*/
|
||
export function decodeProposalsTier(value) {
|
||
if (value == null || value === '') return '';
|
||
const v = String(value);
|
||
const suffix = v.includes('.') ? v.split('.').pop() : v;
|
||
const s = suffix.trim();
|
||
if (/^lessThan(\d+)$/i.test(s)) return `<${s.match(/\d+/)[0]}`;
|
||
if (/^(\d+)plus$/i.test(s)) return `${s.match(/\d+/)[0]}+`;
|
||
const range = s.match(/^(\d+)\s*(?:to|-|–)\s*(\d+)$/i);
|
||
if (range) return `${range[1]}-${range[2]}`;
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* Format the budget into a single human-readable column.
|
||
* - hourly (type 2): "$40-$70/hr" or "$30/hr" or "" (no budget set)
|
||
* - fixed (type 1): "$200" or "" (no amount)
|
||
* Used by search/feed; detail uses formatBudgetFromDetail (different shape).
|
||
*/
|
||
export function formatBudget(job) {
|
||
const type = job?.type;
|
||
const min = Number(job?.hourlyBudget?.min) || 0;
|
||
const max = Number(job?.hourlyBudget?.max) || 0;
|
||
const amount = Number(job?.amount?.amount) || 0;
|
||
if (type === 2) {
|
||
if (min > 0 && max > 0 && max !== min) return `$${min}-$${max}/hr`;
|
||
if (max > 0) return `$${max}/hr`;
|
||
if (min > 0) return `$${min}/hr`;
|
||
return '';
|
||
}
|
||
if (type === 1) return amount > 0 ? `$${amount}` : '';
|
||
return '';
|
||
}
|
||
|
||
/** Detail page uses `extendedBudgetInfo.{hourlyBudgetMin,Max}` + `budget.amount`. */
|
||
export function formatBudgetFromDetail(job) {
|
||
const type = job?.type;
|
||
const min = Number(job?.extendedBudgetInfo?.hourlyBudgetMin) || 0;
|
||
const max = Number(job?.extendedBudgetInfo?.hourlyBudgetMax) || 0;
|
||
const amount = Number(job?.budget?.amount) || 0;
|
||
if (type === 2) {
|
||
if (min > 0 && max > 0 && max !== min) return `$${min}-$${max}/hr`;
|
||
if (max > 0) return `$${max}/hr`;
|
||
if (min > 0) return `$${min}/hr`;
|
||
return '';
|
||
}
|
||
if (type === 1) return amount > 0 ? `$${amount}` : '';
|
||
return '';
|
||
}
|
||
|
||
export function jobType(type) {
|
||
if (type === 1) return 'fixed';
|
||
if (type === 2) return 'hourly';
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Join skills/attrs into a comma-separated string. Search rows have
|
||
* `attrs[].prettyName`; feed rows additionally have `skills[].prefLabel`;
|
||
* detail has neither (skills live elsewhere). The function picks
|
||
* whichever array is populated and dedupes.
|
||
*/
|
||
export function formatSkills(job) {
|
||
const candidates = [];
|
||
const arrs = [job?.attrs, job?.skills, job?.ontologySkills];
|
||
for (const arr of arrs) {
|
||
if (!Array.isArray(arr)) continue;
|
||
for (const s of arr) {
|
||
const name = (s?.prettyName ?? s?.prefLabel ?? s?.name ?? '').trim();
|
||
if (name && !candidates.includes(name)) candidates.push(name);
|
||
}
|
||
}
|
||
return candidates.join(', ');
|
||
}
|
||
|
||
/**
|
||
* Normalize a search/feed job entry into the shared LIST_COLUMNS row shape.
|
||
* Returns null when the row lacks a round-trippable ciphertext identity.
|
||
*/
|
||
export function jobToListRow(job, rank) {
|
||
const id = String(job?.ciphertext ?? '').trim();
|
||
if (!isValidCiphertext(id)) return null;
|
||
const client = job?.client || {};
|
||
const country = client?.location?.country || '';
|
||
const rating = Number(client?.totalFeedback);
|
||
return {
|
||
rank,
|
||
id,
|
||
title: stripHighlight(job?.title),
|
||
type: jobType(job?.type),
|
||
budget: formatBudget(job),
|
||
experienceLevel: decodeExperienceLevel(job?.tierText ?? job?.tier),
|
||
proposalsTier: decodeProposalsTier(job?.proposalsTier),
|
||
skills: formatSkills(job),
|
||
clientCountry: country,
|
||
clientRating: Number.isFinite(rating) && rating > 0 ? rating : null,
|
||
publishedOn: job?.publishedOn || job?.createdOn || '',
|
||
url: buildJobUrl(id),
|
||
};
|
||
}
|
||
|
||
export function jobsToListRows(jobs, { offset = 0, limit } = {}) {
|
||
const rows = [];
|
||
const source = limit ? jobs.slice(0, limit) : jobs;
|
||
for (const [index, job] of source.entries()) {
|
||
const rank = offset + index + 1;
|
||
const row = jobToListRow(job, rank);
|
||
if (!row) {
|
||
throw new CommandExecutionError(`Upwork result at rank ${rank} did not include a valid ciphertext id; cannot produce round-trippable detail rows.`);
|
||
}
|
||
rows.push(row);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
export const LIST_COLUMNS = [
|
||
'rank', 'id', 'title', 'type', 'budget',
|
||
'experienceLevel', 'proposalsTier', 'skills',
|
||
'clientCountry', 'clientRating', 'publishedOn', 'url',
|
||
];
|
||
|
||
export const DETAIL_COLUMNS = [
|
||
'id', 'title', 'type', 'budget', 'experienceLevel', 'workload',
|
||
'category', 'skills', 'description',
|
||
'clientCountry', 'clientSpent', 'clientHires', 'clientRating',
|
||
'proposalsCount', 'publishedOn', 'url',
|
||
];
|