145 lines
4.9 KiB
JavaScript
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;
|
|
},
|
|
};
|