Files
career-ops-vbleroy/providers/trudvsem.mjs
T
redlineo d295c7f139
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s
init
2026-07-13 10:14:00 +03:00

145 lines
4.9 KiB
JavaScript

// @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;
},
};