Files
Jobber/extractors/workingnomads/manifest.ts
T
ilia 84e6835b11
CI / skip-ci-check (push) Successful in 10s
CI / secret-scan (push) Successful in 14s
CI / docker-ci (push) Successful in 17s
feat: keyword sets, sponsorship signals, and extractor/profile fixes
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.
2026-06-11 10:23:39 -04:00

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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/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;