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