108 lines
4.2 KiB
JavaScript
108 lines
4.2 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// Amazon / AWS provider — hits the public amazon.jobs search JSON API.
|
|
// Auto-detects from a careers_url on `amazon.jobs`; the whole board is one
|
|
// global endpoint, so a tracked_companies entry narrows it with an `amazon:`
|
|
// config block whose keys are passed straight through as query params:
|
|
//
|
|
// - name: Amazon / AWS
|
|
// provider: amazon
|
|
// amazon:
|
|
// loc_query: Germany # free-text location filter
|
|
// base_query: machine learning # optional keyword filter
|
|
// category: software-development # optional facet (repeatable via array)
|
|
//
|
|
// The board is enormous (100k+ postings), so a location and/or keyword filter
|
|
// is effectively required — without one the MAX_PAGES cap just returns the most
|
|
// recent slice. result_limit is fixed at 100 (the API's hard per-page max;
|
|
// larger values return an empty `jobs`), and we page via `offset`.
|
|
|
|
const PAGE_SIZE = 100; // amazon.jobs caps result_limit at 100
|
|
const MAX_PAGES = 20; // safety cap — at most 2000 postings per entry
|
|
const ORIGIN = 'https://www.amazon.jobs';
|
|
|
|
/** @param {import('./_types.js').PortalEntry & {amazon?: Record<string, unknown>}} entry */
|
|
function buildQuery(entry, offset) {
|
|
const cfg = entry.amazon && typeof entry.amazon === 'object' ? entry.amazon : {};
|
|
const params = new URLSearchParams();
|
|
// Pass config keys through verbatim (base_query, loc_query, category, …).
|
|
// Array values are amazon.jobs facet filters and MUST use the `key[]=`
|
|
// bracket form to filter (e.g. normalized_country_code[]=DEU); a bare
|
|
// `key=DEU` is silently ignored and the board stays global.
|
|
for (const [k, v] of Object.entries(cfg)) {
|
|
if (v == null) continue;
|
|
if (Array.isArray(v)) {
|
|
const name = k.endsWith('[]') ? k : `${k}[]`;
|
|
for (const item of v) params.append(name, String(item));
|
|
} else {
|
|
params.append(k, String(v));
|
|
}
|
|
}
|
|
if (!params.has('base_query')) params.set('base_query', '');
|
|
if (!params.has('loc_query')) params.set('loc_query', '');
|
|
params.set('sort', cfg.sort ? String(cfg.sort) : 'recent');
|
|
params.set('result_limit', String(PAGE_SIZE));
|
|
params.set('offset', String(offset));
|
|
return `${ORIGIN}/en/search.json?${params.toString()}`;
|
|
}
|
|
|
|
// amazon.jobs posted_date reads "July 3, 2026" (note the padded day);
|
|
// Date.parse handles it once whitespace is collapsed. (updated_time is a
|
|
// relative string like "10 minutes" / "about 1 hour" — unparseable, skip it.)
|
|
function toEpochMs(job) {
|
|
const raw = job.posted_date;
|
|
if (!raw || typeof raw !== 'string') return undefined;
|
|
const parsed = Date.parse(raw.replace(/\s+/g, ' ').trim());
|
|
return Number.isNaN(parsed) ? undefined : parsed;
|
|
}
|
|
|
|
/** @type {Provider} */
|
|
export default {
|
|
id: 'amazon',
|
|
|
|
detect(entry) {
|
|
const url = entry.api || entry.careers_url || '';
|
|
if (typeof url !== 'string') return null;
|
|
// Match the host, not a path segment, to avoid spoofed URLs.
|
|
try {
|
|
const host = new URL(url).host.toLowerCase();
|
|
if (host === 'amazon.jobs' || host.endsWith('.amazon.jobs')) return { url };
|
|
} catch {
|
|
/* not an absolute URL */
|
|
}
|
|
return null;
|
|
},
|
|
|
|
async fetch(entry, ctx) {
|
|
const jobs = [];
|
|
const seen = new Set();
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const url = buildQuery(entry, page * PAGE_SIZE);
|
|
const json = /** @type {any} */ (await ctx.fetchJson(url, { redirect: 'error' }));
|
|
const postings = Array.isArray(json?.jobs) ? json.jobs : [];
|
|
if (postings.length === 0) break;
|
|
|
|
let fresh = 0;
|
|
for (const j of postings) {
|
|
const path = j.job_path;
|
|
if (!path || typeof path !== 'string') continue;
|
|
const url2 = /^https?:\/\//i.test(path) ? path : ORIGIN + (path.startsWith('/') ? path : '/' + path);
|
|
if (seen.has(url2)) continue;
|
|
seen.add(url2);
|
|
fresh++;
|
|
jobs.push({
|
|
title: (j.title || '').trim(),
|
|
url: url2,
|
|
company: j.company_name || entry.name,
|
|
location: (j.normalized_location || j.location || '').trim(),
|
|
postedAt: toEpochMs(j),
|
|
});
|
|
}
|
|
if (fresh === 0) break; // API ignored offset / looped
|
|
if (postings.length < PAGE_SIZE) break; // last page
|
|
}
|
|
return jobs;
|
|
},
|
|
};
|