Add per-profile keyword sets, job source settings, sponsorship signal pills, and several ATS extractors. Fix Hiring Cafe discovery via Next.js SSR search, profile activate for comma-separated basicAuthUser aliases, and resume path backfill migrations. Update settings and hiring-cafe docs; localhost compose overlay for loopback-only deploys.
150 lines
4.3 KiB
TypeScript
150 lines
4.3 KiB
TypeScript
/**
|
|
* Working Nomads — public JSON feed.
|
|
*
|
|
* https://www.workingnomads.com/api/exposed_jobs/
|
|
*/
|
|
|
|
import type {
|
|
ExtractorManifest,
|
|
ExtractorRunResult,
|
|
} from "@shared/types/extractors";
|
|
import type { CreateJobInput } from "@shared/types/jobs";
|
|
|
|
const API_URL = "https://www.workingnomads.com/api/exposed_jobs/";
|
|
|
|
interface WorkingNomadsJob {
|
|
url?: string;
|
|
title?: string;
|
|
description?: string;
|
|
company_name?: string;
|
|
category_name?: string;
|
|
tags?: string;
|
|
location?: string;
|
|
pub_date?: string;
|
|
}
|
|
|
|
function asString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function stripHtml(html: string): string {
|
|
return html
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/ /g, " ")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function matchesTerm(job: WorkingNomadsJob, term: string): boolean {
|
|
const lower = term.toLowerCase();
|
|
if (job.title?.toLowerCase().includes(lower)) return true;
|
|
if (job.company_name?.toLowerCase().includes(lower)) return true;
|
|
if (job.category_name?.toLowerCase().includes(lower)) return true;
|
|
if (job.tags?.toLowerCase().includes(lower)) return true;
|
|
const description = job.description ? stripHtml(job.description) : "";
|
|
if (description.toLowerCase().includes(lower)) return true;
|
|
return false;
|
|
}
|
|
|
|
function mapJob(raw: WorkingNomadsJob): CreateJobInput | null {
|
|
const jobUrl = asString(raw.url);
|
|
if (!jobUrl) return null;
|
|
|
|
const title = asString(raw.title) ?? "Unknown Title";
|
|
const employer = asString(raw.company_name) ?? "Unknown Employer";
|
|
const location = asString(raw.location) ?? "Remote";
|
|
const description = raw.description ? stripHtml(raw.description) : undefined;
|
|
|
|
return {
|
|
source: "workingnomads",
|
|
sourceJobId: jobUrl.split("/").filter(Boolean).pop(),
|
|
title,
|
|
employer,
|
|
jobUrl,
|
|
applicationLink: jobUrl,
|
|
location,
|
|
isRemote: true,
|
|
datePosted: asString(raw.pub_date),
|
|
jobDescription: description,
|
|
companyIndustry: asString(raw.category_name),
|
|
disciplines: asString(raw.tags),
|
|
};
|
|
}
|
|
|
|
export const manifest: ExtractorManifest = {
|
|
id: "workingnomads",
|
|
displayName: "Working Nomads",
|
|
providesSources: ["workingnomads"],
|
|
async run(context): Promise<ExtractorRunResult> {
|
|
if (context.shouldCancel?.()) return { success: true, jobs: [] };
|
|
|
|
const maxJobs = context.settings.workingnomadsMaxJobsPerTerm
|
|
? Number.parseInt(context.settings.workingnomadsMaxJobsPerTerm, 10)
|
|
: 100;
|
|
const cap = Number.isFinite(maxJobs)
|
|
? Math.min(Math.max(maxJobs, 1), 500)
|
|
: 100;
|
|
|
|
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
|
|
|
|
context.onProgress?.({
|
|
phase: "list",
|
|
termsProcessed: 0,
|
|
termsTotal: 1,
|
|
currentUrl: API_URL,
|
|
detail: "Working Nomads: fetching exposed_jobs API",
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(API_URL, {
|
|
headers: { Accept: "application/json", "User-Agent": "JobOps/1.0" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Working Nomads request failed with status ${response.status}`,
|
|
);
|
|
}
|
|
|
|
const body = (await response.json()) as unknown;
|
|
const rows = Array.isArray(body) ? body : [];
|
|
|
|
const seen = new Set<string>();
|
|
const out: CreateJobInput[] = [];
|
|
|
|
for (const row of rows as WorkingNomadsJob[]) {
|
|
if (out.length >= cap) break;
|
|
if (terms.length > 0 && !terms.some((term) => matchesTerm(row, term))) {
|
|
continue;
|
|
}
|
|
const mapped = mapJob(row);
|
|
if (!mapped) continue;
|
|
const key = mapped.sourceJobId || mapped.jobUrl;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(mapped);
|
|
}
|
|
|
|
context.onProgress?.({
|
|
phase: "list",
|
|
termsProcessed: 1,
|
|
termsTotal: 1,
|
|
currentUrl: API_URL,
|
|
jobPagesProcessed: out.length,
|
|
detail: `Working Nomads: ${out.length} matched (${rows.length} total)`,
|
|
});
|
|
|
|
return { success: true, jobs: out };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
return { success: false, jobs: [], error: message };
|
|
}
|
|
},
|
|
};
|
|
|
|
export default manifest;
|