// @ts-check /** @typedef {import('./_types.js').Provider} Provider */ // "Работа России" (trudvsem.ru) provider — hits the Russian government's // public open-data vacancy API (opendata.trudvsem.ru). Aggregates postings // submitted to the state employment service; no auth/API key required. // // Docs: https://trudvsem.ru/opendata/api // // Portal entry fields (all optional except `provider`): // searchKeywords — `text` query param, e.g. "Frontend Vue" (default: "") // region — region code for the `region` path filter (default: none — all regions) // pageSize — `limit` per page, API max is 100 (default: 100) // maxPages — maximum pages to fetch via `offset` (default: 3) const API_HOST = 'opendata.trudvsem.ru'; const API_BASE = 'https://opendata.trudvsem.ru/api/v1/vacancies'; const DEFAULT_PAGE_SIZE = 100; const DEFAULT_MAX_PAGES = 3; /** @param {string} url */ function assertTrudvsemUrl(url) { let parsed; try { parsed = new URL(url); } catch { throw new Error(`trudvsem: invalid URL: ${url}`); } if (parsed.protocol !== 'https:') throw new Error(`trudvsem: URL must use HTTPS: ${url}`); if (parsed.hostname !== API_HOST) throw new Error(`trudvsem: untrusted hostname "${parsed.hostname}" — must be ${API_HOST}`); return url; } function toEpochMs(value) { if (!value) return undefined; const parsed = Date.parse(value); return Number.isNaN(parsed) ? undefined : parsed; } /** * Parse a single trudvsem API result item into the canonical Job shape. * * The API returns results shaped like: * { results: { vacancies: [ { vacancy: { * "job-name": "...", vac_url: "https://trudvsem.ru/vacancy/card/...", * company: { name: "..." }, region: { name: "..." }, * "creation-date": "2026-07-01", date_modify: "2026-07-02T14:32:05+0300" * } } ] } } * * @param {any} item — raw `{ vacancy: {...} }` wrapper from the results array * @param {string} fallbackCompany — company name fallback from the portal entry * @returns {{title: string, url: string, company: string, location: string, postedAt: number|undefined}|null} */ export function parseTrudvsemItem(item, fallbackCompany) { const vacancy = item?.vacancy; if (!vacancy || typeof vacancy !== 'object') return null; const title = (vacancy['job-name'] || '').trim(); if (!title) return null; const rawUrl = (vacancy.vac_url || '').trim(); if (!rawUrl) return null; let url; try { const parsed = new URL(rawUrl); if (parsed.protocol !== 'https:') return null; url = parsed.href; } catch { return null; } const company = (vacancy.company?.name || fallbackCompany || '').trim(); const location = (vacancy.region?.name || '').trim(); const postedAt = toEpochMs(vacancy.date_modify || vacancy['creation-date']); return { title, url, company, location, ...(postedAt != null ? { postedAt } : {}) }; } /** * Build the search URL with query parameters. * @param {{keywords?: string, region?: string, limit?: number, offset?: number}} params * @returns {string} */ function buildSearchUrl({ keywords, region, limit, offset }) { const base = region ? `${API_BASE}/region/${encodeURIComponent(region)}` : API_BASE; const url = new URL(base); if (keywords) url.searchParams.set('text', keywords); url.searchParams.set('limit', String(limit || DEFAULT_PAGE_SIZE)); url.searchParams.set('offset', String(offset || 0)); return url.href; } /** @type {Provider} */ export default { id: 'trudvsem', detect(_entry) { // trudvsem is a job board aggregator, not a company ATS. // Use `provider: trudvsem` explicitly in portals.yml. return null; }, async fetch(entry, ctx) { const keywords = entry.searchKeywords || ''; const region = entry.region ? String(entry.region) : ''; const pageSize = Number(entry.pageSize) || DEFAULT_PAGE_SIZE; const maxPages = Number(entry.maxPages) || DEFAULT_MAX_PAGES; const fallbackCompany = entry.name || ''; const allJobs = []; for (let page = 0; page < maxPages; page++) { const offset = page * pageSize; const searchUrl = buildSearchUrl({ keywords, region, limit: pageSize, offset }); assertTrudvsemUrl(searchUrl); let json; try { json = /** @type {any} */ (await ctx.fetchJson(searchUrl, { redirect: 'error' })); } catch (err) { if (page === 0) throw err; console.error(`trudvsem: page ${page + 1} fetch failed — ${err.message}`); break; } const vacancies = Array.isArray(json?.results?.vacancies) ? json.results.vacancies : []; if (vacancies.length === 0) break; for (const item of vacancies) { const job = parseTrudvsemItem(item, fallbackCompany); if (job) allJobs.push(job); } if (vacancies.length < pageSize) break; // Be a polite citizen of a free government API await new Promise(resolve => setTimeout(resolve, 200)); } return allJobs; }, };