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