init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,129 @@
|
||||
// @ts-check
|
||||
/** @typedef {import('./_types.js').Provider} Provider */
|
||||
|
||||
// Himalayas provider - board-wide remote jobs API
|
||||
// (https://himalayas.app/jobs/api?limit=50). Returns { jobs: [...] }. The
|
||||
// full feed is fetched so scan.mjs's title_filter / location_filter can do
|
||||
// the local gating consistently with other zero-token board providers.
|
||||
//
|
||||
// Wire in via a `job_boards:` entry with `provider: himalayas`.
|
||||
|
||||
const FEED_URL = 'https://himalayas.app/jobs/api?limit=50';
|
||||
const TRUSTED_HOST = 'himalayas.app';
|
||||
|
||||
/** @param {string} url */
|
||||
function assertHimalayasUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`himalayas: invalid URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== 'https:') throw new Error(`himalayas: URL must use HTTPS: ${url}`);
|
||||
if (parsed.hostname !== TRUSTED_HOST) {
|
||||
throw new Error(`himalayas: untrusted hostname "${parsed.hostname}" - must be ${TRUSTED_HOST}`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cleanHimalayasUrl(value) {
|
||||
const raw = cleanText(value);
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const trusted = host === TRUSTED_HOST || host.endsWith(`.${TRUSTED_HOST}`);
|
||||
return parsed.protocol === 'https:' && trusted ? parsed.href : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function locationText(value) {
|
||||
if (!Array.isArray(value)) return '';
|
||||
return value
|
||||
.filter(v => typeof v === 'string' && v.trim())
|
||||
.map(v => v.trim())
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
// Himalayas pubDate is currently epoch seconds. Accept milliseconds and
|
||||
// parseable date strings too so the parser survives small API shape changes.
|
||||
function toEpochMs(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @type {Provider} */
|
||||
export default {
|
||||
id: 'himalayas',
|
||||
|
||||
detect(entry) {
|
||||
return entry?.provider === 'himalayas' ? { url: FEED_URL } : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches and normalizes postings from the Himalayas public feed.
|
||||
* @param {{ provider?: string }} entry - The job_boards entry being processed.
|
||||
* @param {{ fetchJson: (url: string, opts?: { redirect?: 'error'|'follow'|'manual' }) => Promise<any> }} ctx - HTTP context.
|
||||
* @returns {Promise<Array<{title: string, url: string, company: string, location: string, postedAt?: number}>>}
|
||||
*/
|
||||
async fetch(entry, ctx) {
|
||||
const feedUrl = assertHimalayasUrl(FEED_URL);
|
||||
// redirect:'error' prevents SSRF via server-side redirects; combined with
|
||||
// assertHimalayasUrl above it keeps the request pinned to himalayas.app.
|
||||
const json = await ctx.fetchJson(feedUrl, { redirect: 'error' });
|
||||
if (!json || !Array.isArray(json.jobs)) {
|
||||
throw new Error(`himalayas: unexpected API response - expected { jobs: [...] }, got keys: [${json ? Object.keys(json).join(', ') : 'null'}]`);
|
||||
}
|
||||
return parseHimalayasResponse(json);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse Himalayas' public jobs API response. Exported for unit tests.
|
||||
*
|
||||
* Shape: `{ jobs: [...] }`, where each job currently carries `title`,
|
||||
* `companyName`, `locationRestrictions`, `applicationLink`, `guid`,
|
||||
* `pubDate`, and `companySlug`. `applicationLink` is preferred over `guid`
|
||||
* and used as the dedup key after HTTPS + host validation.
|
||||
*
|
||||
* @param {unknown} json - raw parsed API response
|
||||
* @returns {Array<{title: string, url: string, company: string, location: string, postedAt?: number}>}
|
||||
*/
|
||||
export function parseHimalayasResponse(json) {
|
||||
if (!json || typeof json !== 'object' || !Array.isArray(json.jobs)) return [];
|
||||
|
||||
const jobs = [];
|
||||
for (const item of json.jobs) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
|
||||
const title = cleanText(item.title);
|
||||
if (!title) continue;
|
||||
|
||||
const url = cleanHimalayasUrl(item.applicationLink) || cleanHimalayasUrl(item.guid);
|
||||
if (!url) continue;
|
||||
|
||||
jobs.push({
|
||||
title,
|
||||
url,
|
||||
company: cleanText(item.companyName),
|
||||
location: locationText(item.locationRestrictions),
|
||||
postedAt: toEpochMs(item.pubDate),
|
||||
});
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
Reference in new issue
Block a user