init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,144 @@
|
||||
// @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;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user