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

135 lines
5.0 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// EchoJobs provider — board-wide public JSON feed of tech jobs aggregated from
// company ATS boards (https://echojobs.io/api/jobs). Public, zero-auth, paginated
// (`?page=N&per_page=M`). The broad feed is fetched so scan.mjs's title_filter
// gates on the configured titles; pages are pulled until one comes back short or
// the page cap is reached (default 3, override with `max_pages`).
//
// Each row's `url` is the ORIGINAL ATS posting (e.g. jobs.ashbyhq.com/…), so —
// unlike the feed host — job URLs are not pinned to echojobs.io; only the feed
// fetch is host-locked. The url is display-only and never server-fetched here.
//
// Wire in via a `job_boards:` entry with `provider: echojobs`.
const FEED_BASE = 'https://echojobs.io/api/jobs';
const TRUSTED_HOST = 'echojobs.io';
const PER_PAGE = 100;
const DEFAULT_MAX_PAGES = 3;
const MAX_PAGES_CAP = 50;
/** @param {string} url */
function assertEchojobsUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`echojobs: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`echojobs: URL must use HTTPS: ${url}`);
if (parsed.hostname !== TRUSTED_HOST) {
throw new Error(`echojobs: 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;
}
/**
* Normalize a single EchoJobs feed item. Exported for tests.
*
* Field mapping → the normalized Job shape:
* - title: `title`, trimmed (items without one are dropped).
* - url: `url` — an absolute `https:` posting URL on the company's own ATS
* host (NOT echojobs.io), used as the dedup key. Non-https/malformed
* URLs drop the item.
* - company: `company_name`, falling back to the portal entry name, then "EchoJobs".
* - location: the joined `locations` array; falls back to "Remote" when the
* posting has no listed place but `remote_type` is remote/hybrid.
* - postedAt: `posted_at` (already epoch ms) when a positive finite number.
*
* @param {any} j
* @param {string} [fallbackCompany]
* @returns {{ title: string, url: string, company: string, location: string, postedAt?: number } | null}
*/
export function normalizeEchojobsJob(j, fallbackCompany) {
if (!j || typeof j !== 'object') return null;
const title = typeof j.title === 'string' ? j.title.trim() : '';
if (!title) return null;
// url must be an absolute https link; it lives on the company's ATS host, so
// it is NOT restricted to echojobs.io.
let url = '';
const rawUrl = typeof j.url === 'string' ? j.url.trim() : '';
if (rawUrl) {
try {
const parsed = new URL(rawUrl);
if (parsed.protocol === 'https:') url = parsed.href;
} catch {
// malformed URL → leave url = '' → dropped below
}
}
if (!url) return null;
const company =
typeof j.company_name === 'string' && j.company_name.trim()
? j.company_name.trim()
: fallbackCompany || 'EchoJobs';
let location = '';
if (Array.isArray(j.locations)) {
location = j.locations
.filter((l) => typeof l === 'string' && l.trim())
.map((l) => l.trim())
.join(', ');
}
if (!location && (j.remote_type === 'remote' || j.remote_type === 'hybrid')) location = 'Remote';
/** @type {{ title: string, url: string, company: string, location: string, postedAt?: number }} */
const job = { title, url, company, location };
// `posted_at` is already epoch milliseconds.
if (Number.isFinite(j.posted_at) && j.posted_at > 0) job.postedAt = j.posted_at;
return job;
}
/** @type {Provider} */
export default {
id: 'echojobs',
detect(entry) {
return entry?.provider === 'echojobs' ? { url: FEED_BASE } : null;
},
async fetch(entry, ctx) {
const maxPages = resolveMaxPages(entry);
const fallbackCompany = entry?.name;
const out = [];
for (let page = 1; page <= maxPages; page++) {
// Validate the URL actually fetched (not just a constant) so the host pin
// is meaningful, then redirect:'error' blocks SSRF via server-side
// redirects — together they keep every page request on echojobs.io.
const url = assertEchojobsUrl(`${FEED_BASE}?per_page=${PER_PAGE}&page=${page}`);
const json = /** @type {any} */ (await ctx.fetchJson(url, { redirect: 'error' }));
if (!json || !Array.isArray(json.jobs)) {
throw new Error(
`echojobs: unexpected API response on page ${page} — expected { jobs: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`,
);
}
for (const j of json.jobs) {
const normalized = normalizeEchojobsJob(j, fallbackCompany);
if (normalized) out.push(normalized);
}
if (json.jobs.length < PER_PAGE) break; // short page → last page reached
}
return out;
},
};