/** * 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 `` 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(//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', ];