104 lines
3.8 KiB
JavaScript
104 lines
3.8 KiB
JavaScript
// @ts-check
|
|
/** @typedef {import('./_types.js').Provider} Provider */
|
|
|
|
// Workable provider — hits the public markdown feed at /<slug>/jobs.md.
|
|
// Workable's documented JSON API requires an auth token; the markdown feed
|
|
// is the only no-auth public surface. Auto-detects from careers_url pattern
|
|
// `https://apply.workable.com/<slug>`. A tracked_companies entry can also
|
|
// set `provider: workable` explicitly to bypass detection.
|
|
|
|
const ALLOWED_WORKABLE_HOSTS = new Set(['apply.workable.com']);
|
|
|
|
function assertWorkableUrl(url) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error(`workable: invalid URL: ${url}`);
|
|
}
|
|
if (parsed.protocol !== 'https:') throw new Error(`workable: URL must use HTTPS: ${url}`);
|
|
if (!ALLOWED_WORKABLE_HOSTS.has(parsed.hostname)) {
|
|
throw new Error(`workable: untrusted hostname "${parsed.hostname}" — must be one of: ${[...ALLOWED_WORKABLE_HOSTS].join(', ')}`);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function resolveFeedUrl(entry) {
|
|
const raw = typeof entry.careers_url === 'string' ? entry.careers_url : '';
|
|
if (!raw) return null;
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (parsed.protocol !== 'https:') return null;
|
|
if (parsed.hostname !== 'apply.workable.com') return null;
|
|
const slug = parsed.pathname.split('/').filter(Boolean)[0];
|
|
if (!slug) return null;
|
|
return `https://apply.workable.com/${slug}/jobs.md`;
|
|
}
|
|
|
|
/** @type {Provider} */
|
|
export default {
|
|
id: 'workable',
|
|
|
|
detect(entry) {
|
|
const feedUrl = resolveFeedUrl(entry);
|
|
return feedUrl ? { url: feedUrl } : null;
|
|
},
|
|
|
|
async fetch(entry, ctx) {
|
|
const feedUrl = resolveFeedUrl(entry);
|
|
if (!feedUrl) throw new Error(`workable: cannot derive feed URL for ${entry.name}`);
|
|
assertWorkableUrl(feedUrl);
|
|
// redirect:'error' prevents SSRF via server-side redirects; combined with
|
|
// assertWorkableUrl above it guarantees the final hostname stays in the allowlist.
|
|
const text = await ctx.fetchText(feedUrl, { redirect: 'error' });
|
|
return parseWorkableMarkdown(text, entry.name);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Parse Workable's public markdown feed. Exported as a named export for unit
|
|
* tests. The feed exposes a table:
|
|
* | Title | Department | Location | Type | Salary | Posted | Details |
|
|
* where `Details` holds a markdown link
|
|
* [View](https://apply.workable.com/<slug>/jobs/view/<id>.md)
|
|
* URLs are validated against `https://apply.workable.com/` — off-domain or
|
|
* non-HTTPS [View] links are skipped (not emitted).
|
|
*
|
|
* @param {string} text — markdown body
|
|
* @param {string} companyName — value to write into job.company
|
|
* @returns {Array<{title: string, url: string, company: string, location: string}>}
|
|
*/
|
|
export function parseWorkableMarkdown(text, companyName) {
|
|
if (typeof text !== 'string') return [];
|
|
const jobs = [];
|
|
for (const line of text.split('\n')) {
|
|
if (!line.startsWith('|') || !line.includes('[View]')) continue;
|
|
const cols = line.split('|').map(c => c.trim());
|
|
// Cols: ['', title, dept, location, type, salary, posted, '[View](url.md)', '']
|
|
if (cols.length < 8) continue;
|
|
const title = cols[1];
|
|
if (!title || title === 'Title') continue;
|
|
const location = cols[3] || '';
|
|
const urlMatch = line.match(/\[View\]\(([^)]+)\)/);
|
|
let url = urlMatch ? urlMatch[1] : '';
|
|
if (url.endsWith('.md')) url = url.slice(0, -3);
|
|
if (!url) continue; // skip rows with no resolvable URL (e.g., malformed [View] link)
|
|
|
|
// Validate the extracted URL — must parse as https://apply.workable.com/...
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
if (parsedUrl.protocol !== 'https:' || parsedUrl.hostname !== 'apply.workable.com') continue;
|
|
url = parsedUrl.href;
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
jobs.push({ title, url, location, company: companyName });
|
|
}
|
|
return jobs;
|
|
}
|