168 lines
5.3 KiB
JavaScript
168 lines
5.3 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// NoFluffJobs provider — hits the public search posting API.
|
|
// It intentionally returns only the core scanner job fields; richer skill and
|
|
// salary metadata can be added later if the provider contract is expanded.
|
|
|
|
const ALLOWED_HOSTS = new Set(['nofluffjobs.com']);
|
|
const API_URL = 'https://nofluffjobs.com/api/search/posting';
|
|
const JOB_BASE = 'https://nofluffjobs.com/pl/job/';
|
|
const PAGE_SIZE = 20;
|
|
const MAX_PAGES = 5;
|
|
|
|
function assertNoFluffUrl(url) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error(`nofluffjobs: invalid URL: ${url}`);
|
|
}
|
|
if (parsed.protocol !== 'https:') throw new Error(`nofluffjobs: URL must use HTTPS: ${url}`);
|
|
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
|
|
throw new Error(`nofluffjobs: untrusted hostname "${parsed.hostname}" — must be nofluffjobs.com`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function detectUrl(entry) {
|
|
const url = entry.api || entry.careers_url || '';
|
|
if (typeof url !== 'string' || !url.trim()) return null;
|
|
try {
|
|
return { url: assertNoFluffUrl(url).href };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeLocation(posting) {
|
|
const parts = [];
|
|
if (posting?.fullyRemote || posting?.location?.fullyRemote) parts.push('Remote');
|
|
if (Array.isArray(posting?.location?.places)) {
|
|
for (const place of posting.location.places) {
|
|
const city = String(place?.city || '').trim();
|
|
const province = String(place?.province || '').trim();
|
|
const country = String(place?.country?.name || '').trim();
|
|
if (city) parts.push(city);
|
|
else if (province) parts.push(province);
|
|
else if (country) parts.push(country);
|
|
}
|
|
}
|
|
return [...new Set(parts.filter(Boolean))].join(', ');
|
|
}
|
|
|
|
function postedAtMillis(value) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : undefined;
|
|
}
|
|
|
|
function buildRequest(entry, pageTo) {
|
|
const apiUrl = assertNoFluffUrl(entry.api || API_URL);
|
|
apiUrl.pathname = '/api/search/posting';
|
|
apiUrl.search = '';
|
|
apiUrl.searchParams.set('sort', 'newest');
|
|
apiUrl.searchParams.set('withSalaryMatch', 'true');
|
|
apiUrl.searchParams.set('pageTo', String(pageTo));
|
|
apiUrl.searchParams.set('pageSize', String(Number(entry.page_size || PAGE_SIZE)));
|
|
apiUrl.searchParams.set('salaryCurrency', String(entry.currency || 'PLN').toUpperCase());
|
|
apiUrl.searchParams.set('salaryPeriod', String(entry.salary_period || 'month'));
|
|
apiUrl.searchParams.set('region', String(entry.region || 'pl'));
|
|
apiUrl.searchParams.set('language', String(entry.language || 'pl-PL'));
|
|
|
|
const rawSearch = String(entry.search || entry.raw_search || '').trim();
|
|
const body = rawSearch
|
|
? {
|
|
criteria: '',
|
|
url: { searchParam: rawSearch },
|
|
rawSearch,
|
|
pageSize: Number(entry.page_size || PAGE_SIZE),
|
|
withSalaryMatch: true,
|
|
}
|
|
: {
|
|
criteriaSearch: {
|
|
country: [],
|
|
withSalaryMatch: [],
|
|
city: [],
|
|
more: [],
|
|
employment: [],
|
|
requirement: [],
|
|
salary: [],
|
|
jobPosition: [],
|
|
applicationStatus: [],
|
|
province: [],
|
|
company: [],
|
|
id: [],
|
|
category: [],
|
|
keyword: [],
|
|
jobLanguage: [],
|
|
seniority: [],
|
|
},
|
|
pageSize: Number(entry.page_size || PAGE_SIZE),
|
|
withSalaryMatch: true,
|
|
};
|
|
|
|
return { url: apiUrl.href, body };
|
|
}
|
|
|
|
export function parseNoFluffJobsResponse(json) {
|
|
if (!json || !Array.isArray(json.postings)) {
|
|
throw new Error(`nofluffjobs: unexpected API response — expected { postings: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
|
|
}
|
|
return json.postings
|
|
.filter(posting => posting && typeof posting === 'object')
|
|
.map(posting => {
|
|
const title = String(posting.title || '').trim();
|
|
const company = String(posting.name || '').trim();
|
|
const slug = String(posting.url || posting.id || '').trim();
|
|
if (!title || !slug) return null;
|
|
return {
|
|
title,
|
|
url: `${JOB_BASE}${slug}`,
|
|
company,
|
|
location: normalizeLocation(posting),
|
|
postedAt: postedAtMillis(posting.posted),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/** @type {Provider} */
|
|
export default {
|
|
id: 'nofluffjobs',
|
|
|
|
detect(entry) {
|
|
return detectUrl(entry);
|
|
},
|
|
|
|
async fetch(entry, ctx) {
|
|
if (!detectUrl(entry)) throw new Error('nofluffjobs: careers_url or api must be a trusted nofluffjobs.com URL');
|
|
|
|
const maxPages = Number(entry.max_pages || MAX_PAGES);
|
|
const jobs = [];
|
|
const seen = new Set();
|
|
|
|
for (let pageTo = 1; pageTo <= maxPages; pageTo++) {
|
|
const { url, body } = buildRequest(entry, pageTo);
|
|
const json = await ctx.fetchJson(url, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
headers: {
|
|
accept: 'application/json, text/plain, */*',
|
|
'content-type': 'application/infiniteSearch+json',
|
|
},
|
|
redirect: 'error',
|
|
});
|
|
for (const job of parseNoFluffJobsResponse(json)) {
|
|
if (seen.has(job.url)) continue;
|
|
seen.add(job.url);
|
|
jobs.push(job);
|
|
}
|
|
const totalPages = Number(json?.totalPages || 0);
|
|
if (totalPages && pageTo >= totalPages) break;
|
|
if (json.postings.length === 0) break;
|
|
}
|
|
|
|
return jobs;
|
|
},
|
|
};
|