Files
santifer--career-ops/providers/justjoin.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

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;
},
};