Files
santifer--career-ops/providers/thehub.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

141 lines
5.5 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// The Hub provider — board-wide aggregator feed (Nordic / EU startups):
// https://thehub.io/api/jobs
// Response shape: { docs: [ { id, key, title, company: { name, ... },
// location: { address, locality, country }, absoluteJobUrl, isRemote,
// publishedAt, createdAt, ... } ], total, page, pages, limit }
//
// Paginated 15/page via `?page=N` (1-indexed); the response carries `pages`, so
// iteration is bounded by min(pages, max_pages). Default cap is modest (the
// board is ~1000 jobs / ~67 pages); override with `max_pages` on the entry.
//
// Wire in via a `job_boards:` entry with `provider: thehub`.
const FEED_BASE = 'https://thehub.io/api/jobs';
const TRUSTED_HOST = 'thehub.io';
const PER_PAGE = 15;
const DEFAULT_MAX_PAGES = 3;
const MAX_PAGES_CAP = 67;
/** @param {string} url */
function assertHubUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`thehub: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`thehub: URL must use HTTPS: ${url}`);
if (parsed.hostname !== TRUSTED_HOST) {
throw new Error(`thehub: untrusted hostname "${parsed.hostname}" — must be ${TRUSTED_HOST}`);
}
return url;
}
/** Resolve the page cap: a positive integer `max_pages` on the entry, capped. */
function resolveMaxPages(entry) {
const v = entry?.max_pages;
if (Number.isInteger(v) && v > 0) return Math.min(v, MAX_PAGES_CAP);
return DEFAULT_MAX_PAGES;
}
// NaN-safe Date.parse — `|| undefined` would also coerce a valid epoch 0.
function toEpochMs(value) {
if (typeof value !== 'string' || !value) return undefined;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
/**
* Normalize a single The Hub job. Exported for unit tests.
*
* Field mapping → the normalized Job shape:
* - title: `title`, trimmed (postings without one are dropped).
* - url: `absoluteJobUrl` — host-locked to `thehub.io` (The Hub always
* serves its canonical posting page there; the per-posting `link`
* field is an external apply URL and is NOT used). An off-host or
* non-https URL drops the posting. url is the dedup key and is
* display-only (written to the pipeline/history, never fetched here).
* - company: `company.name`, falling back to the portal entry name, then
* "The Hub".
* - location: `location.address`, else assembled from `location.locality` /
* `location.country`; "Remote" is appended when `isRemote` is true.
* - postedAt: `publishedAt` (else `createdAt`) ISO date → epoch ms (omitted
* when absent/unparseable).
*
* @param {any} j
* @param {string} [fallbackCompany]
* @returns {{ title: string, url: string, company: string, location: string, postedAt?: number } | null}
*/
export function normalizeHubJob(j, fallbackCompany) {
if (!j || typeof j !== 'object') return null;
const title = typeof j.title === 'string' ? j.title.trim() : '';
if (!title) return null;
let url = '';
const rawUrl = typeof j.absoluteJobUrl === 'string' ? j.absoluteJobUrl.trim() : '';
if (rawUrl) {
try {
const parsed = new URL(rawUrl);
if (parsed.protocol === 'https:' && parsed.hostname === TRUSTED_HOST) url = parsed.href;
} catch {
// malformed URL → leave url = '' → dropped below
}
}
if (!url) return null;
const company =
j.company && typeof j.company === 'object' && typeof j.company.name === 'string' && j.company.name.trim()
? j.company.name.trim()
: typeof fallbackCompany === 'string' && fallbackCompany.trim()
? fallbackCompany.trim()
: 'The Hub';
const loc = j.location && typeof j.location === 'object' ? j.location : {};
const address = typeof loc.address === 'string' ? loc.address.trim() : '';
const locality = typeof loc.locality === 'string' ? loc.locality.trim() : '';
const country = typeof loc.country === 'string' ? loc.country.trim() : '';
const base = address || [locality, country].filter(Boolean).join(', ');
const location = [base, j.isRemote === true ? 'Remote' : ''].filter(Boolean).join(', ');
/** @type {{ title: string, url: string, company: string, location: string, postedAt?: number }} */
const job = { title, url, company, location };
const postedAt = toEpochMs(j.publishedAt) ?? toEpochMs(j.createdAt);
if (postedAt !== undefined) job.postedAt = postedAt;
return job;
}
/** @type {Provider} */
export default {
id: 'thehub',
async fetch(entry, ctx) {
assertHubUrl(FEED_BASE);
const maxPages = resolveMaxPages(entry);
const fallbackCompany = entry?.name;
const out = [];
for (let page = 1; page <= maxPages; page++) {
const url = `${FEED_BASE}?page=${page}`;
// redirect:'error' prevents SSRF via server-side redirects
const json = await ctx.fetchJson(url, { redirect: 'error' });
if (!json || !Array.isArray(json.docs)) {
throw new Error(
`thehub: unexpected API response on page ${page} — expected { docs: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`,
);
}
for (const j of json.docs) {
const normalized = normalizeHubJob(j, fallbackCompany);
if (normalized) out.push(normalized);
}
// Stop at the last page: a short page, or page >= the reported total pages.
if (json.docs.length < PER_PAGE) break;
if (Number.isInteger(json.pages) && page >= json.pages) break;
}
return out;
},
};