129 lines
4.2 KiB
JavaScript
129 lines
4.2 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// JustJoin.it provider — hits the current candidate offers API.
|
|
// Browser URLs under https://justjoin.it/job-offers/... are accepted for
|
|
// detection, but fetches use https://justjoin.it/api/candidate-api/offers.
|
|
|
|
const ALLOWED_HOSTS = new Set(['justjoin.it']);
|
|
const API_BASE = 'https://justjoin.it/api/candidate-api/offers';
|
|
const JOB_BASE = 'https://justjoin.it/job-offer/';
|
|
const PAGE_SIZE = 100;
|
|
const MAX_PAGES = 50;
|
|
|
|
function assertJustJoinUrl(url) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error(`justjoin: invalid URL: ${url}`);
|
|
}
|
|
if (parsed.protocol !== 'https:') throw new Error(`justjoin: URL must use HTTPS: ${url}`);
|
|
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
|
|
throw new Error(`justjoin: untrusted hostname "${parsed.hostname}" — must be justjoin.it`);
|
|
}
|
|
if (!parsed.pathname.startsWith('/job-offers') && parsed.pathname !== '/api/candidate-api/offers') {
|
|
throw new Error(`justjoin: URL path must be /job-offers or /api/candidate-api/offers: ${url}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function detectUrl(entry) {
|
|
const url = entry.api || entry.careers_url || '';
|
|
if (typeof url !== 'string' || !url.trim()) return null;
|
|
try {
|
|
const parsed = assertJustJoinUrl(url);
|
|
return { url: parsed.href };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeLocation(offer) {
|
|
const parts = [];
|
|
const workplace = String(offer?.workplaceType || '').trim();
|
|
if (workplace) parts.push(workplace);
|
|
if (Array.isArray(offer?.locations)) {
|
|
for (const location of offer.locations) {
|
|
const city = String(location?.city || '').trim();
|
|
if (city) parts.push(city);
|
|
}
|
|
}
|
|
const city = String(offer?.city || '').trim();
|
|
if (city) parts.push(city);
|
|
return [...new Set(parts.filter(Boolean))].join(', ');
|
|
}
|
|
|
|
function postedAtMillis(value) {
|
|
const time = Date.parse(String(value || ''));
|
|
return Number.isFinite(time) ? time : undefined;
|
|
}
|
|
|
|
function buildApiUrl(entry, from) {
|
|
const apiUrl = entry.api || API_BASE;
|
|
const parsed = assertJustJoinUrl(apiUrl);
|
|
if (parsed.pathname !== '/api/candidate-api/offers') {
|
|
parsed.pathname = '/api/candidate-api/offers';
|
|
parsed.search = '';
|
|
}
|
|
parsed.searchParams.set('from', String(from));
|
|
parsed.searchParams.set('itemsCount', String(Number(entry.page_size || PAGE_SIZE)));
|
|
parsed.searchParams.set('cityRadius', String(Number(entry.city_radius || 30)));
|
|
parsed.searchParams.set('currency', String(entry.currency || 'pln').toLowerCase());
|
|
parsed.searchParams.set('orderBy', 'descending');
|
|
parsed.searchParams.set('sortBy', 'publishedAt');
|
|
parsed.searchParams.set('keywordType', 'any');
|
|
parsed.searchParams.set('isPromoted', 'true');
|
|
return parsed.href;
|
|
}
|
|
|
|
export function parseJustJoinResponse(json) {
|
|
if (!json || !Array.isArray(json.data)) {
|
|
throw new Error(`justjoin: unexpected API response — expected { data: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
|
|
}
|
|
return json.data
|
|
.filter(offer => offer && typeof offer === 'object')
|
|
.map(offer => {
|
|
const slug = String(offer.slug || '').trim();
|
|
const title = String(offer.title || '').trim();
|
|
if (!slug || !title) return null;
|
|
return {
|
|
title,
|
|
url: `${JOB_BASE}${slug}`,
|
|
company: String(offer.companyName || '').trim(),
|
|
location: normalizeLocation(offer),
|
|
postedAt: postedAtMillis(offer.publishedAt),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/** @type {Provider} */
|
|
export default {
|
|
id: 'justjoin',
|
|
|
|
detect(entry) {
|
|
return detectUrl(entry);
|
|
},
|
|
|
|
async fetch(entry, ctx) {
|
|
if (!detectUrl(entry)) throw new Error('justjoin: careers_url or api must be a trusted justjoin.it URL');
|
|
|
|
const jobs = [];
|
|
let from = 0;
|
|
const maxPages = Number(entry.max_pages || MAX_PAGES);
|
|
for (let page = 0; page < maxPages; page++) {
|
|
const url = buildApiUrl(entry, from);
|
|
const json = await ctx.fetchJson(url, { redirect: 'error' });
|
|
const parsed = parseJustJoinResponse(json);
|
|
jobs.push(...parsed);
|
|
|
|
const next = json?.meta?.next?.cursor;
|
|
if (next == null || parsed.length === 0) break;
|
|
from = Number(next);
|
|
if (!Number.isFinite(from)) break;
|
|
}
|
|
return jobs;
|
|
},
|
|
};
|