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

86 lines
2.9 KiB
JavaScript

// @ts-check
/** @typedef {import('./_types.js').Provider} Provider */
// The Muse provider — public, zero-auth JSON jobs feed.
// Endpoint: https://www.themuse.com/api/public/jobs?page={n}
// Response shape: { results: [...], page: n, page_count: N }
// All pages are fetched sequentially and aggregated before normalizing.
//
// Wire in via a `job_boards:` entry with `provider: themuse`.
const FEED_BASE = 'https://www.themuse.com/api/public/jobs';
const TRUSTED_HOST = 'www.themuse.com';
/** @param {string} url */
function assertMuseUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`themuse: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`themuse: URL must use HTTPS: ${url}`);
if (parsed.hostname !== TRUSTED_HOST) {
throw new Error(`themuse: untrusted hostname "${parsed.hostname}" — must be ${TRUSTED_HOST}`);
}
return url;
}
/**
* Normalize a single result from the Muse API response. Exported for unit tests.
*
* Field mapping:
* name → title
* refs.landing_page → url
* company.name → company
* locations[0].name → location
*
* Returns null when required fields (title or url) are missing or invalid.
*
* @param {any} j
* @returns {{ title: string, url: string, company: string, location: string } | null}
*/
export function normalizeMuseJob(j) {
if (!j || typeof j !== 'object') return null;
const title = typeof j.name === 'string' ? j.name.trim() : '';
if (!title) return null;
const url = typeof j.refs?.landing_page === 'string' ? j.refs.landing_page.trim() : '';
if (!url || !/^https?:\/\//i.test(url)) return null;
const company =
typeof j.company?.name === 'string' && j.company.name.trim()
? j.company.name.trim()
: 'The Muse';
const location =
Array.isArray(j.locations) && j.locations.length > 0 && typeof j.locations[0]?.name === 'string'
? j.locations[0].name.trim()
: '';
return { title, url, company, location };
}
/** @type {Provider} */
export default {
id: 'themuse',
async fetch(_entry, ctx) {
assertMuseUrl(FEED_BASE);
const allResults = [];
// Fetch page 0 first to discover page_count, then iterate remaining pages.
let pageCount = 1;
for (let page = 0; page < pageCount; 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.results)) {
throw new Error(
`themuse: unexpected API response on page ${page} — expected { results: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`,
);
}
if (page === 0 && Number.isInteger(json.page_count) && json.page_count > 1) {
pageCount = Math.min(json.page_count, 100);
}
allResults.push(...json.results);
}
return allResults.map(normalizeMuseJob).filter(Boolean);
},
};