/** * QAJobsBoard (JobBoardly) — public jobs listing JSON. * * https://qajobsboard.jobboardly.com/jobs.json */ import type { ExtractorManifest, ExtractorRunResult, } from "@shared/types/extractors"; import type { CreateJobInput } from "@shared/types/jobs"; const JOBS_URL = "https://qajobsboard.jobboardly.com/jobs.json"; interface JobCategory { name?: string; } interface SalaryBand { schedule?: string; minimum?: number | null; maximum?: number | null; } interface DescriptionBlock { html?: string; } interface QaJobBoardlyJob { title?: string; arrangement?: string; location?: string; location_limits?: string[]; published_at?: string; application_link?: string; description?: DescriptionBlock; company?: { name?: string; logo?: string }; salary?: SalaryBand; categories?: JobCategory[]; links?: { self?: string }; } function asString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function decodeHtmlEntities(value: string): string { return value .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, " "); } function stripHtml(html: string): string { const noTags = html.replace(/<[^>]+>/g, " "); return decodeHtmlEntities(noTags).replace(/\s+/g, " ").trim(); } function salaryLabel(raw: SalaryBand | undefined): string | undefined { if (!raw) return undefined; const schedule = raw.schedule ? `${raw.schedule}: ` : ""; if ( typeof raw.minimum === "number" && typeof raw.maximum === "number" && Number.isFinite(raw.minimum) && Number.isFinite(raw.maximum) ) { return `${schedule}${raw.minimum}–${raw.maximum}`; } if (typeof raw.minimum === "number" && Number.isFinite(raw.minimum)) { return `${schedule}${raw.minimum}+`; } if (typeof raw.maximum === "number" && Number.isFinite(raw.maximum)) { return `${schedule}≤${raw.maximum}`; } return schedule.trim() || undefined; } function locationLabel(job: QaJobBoardlyJob): string { const limits = Array.isArray(job.location_limits) ? job.location_limits.filter( (v): v is string => typeof v === "string" && v.trim().length > 0, ) : []; if (limits.length > 0) return limits.join(", "); const loc = asString(job.location); if (loc) return loc; return "Unknown"; } function matchesTerm(job: QaJobBoardlyJob, term: string): boolean { const lower = term.toLowerCase(); if (job.title?.toLowerCase().includes(lower)) return true; const cats = Array.isArray(job.categories) ? job.categories.map((c) => c.name?.toLowerCase() ?? "").join(" ") : ""; if (cats.includes(lower)) return true; const html = job.description?.html ?? ""; if (stripHtml(html).toLowerCase().includes(lower)) return true; return false; } function mapJob(raw: QaJobBoardlyJob): CreateJobInput | null { const jobUrl = asString(raw.links?.self); if (!jobUrl) return null; const employer = asString(raw.company?.name) ?.replace(/^[\s–-]+/, "") .trim() || "Unknown Employer"; const applicationLink = asString(raw.application_link) ?? jobUrl; const descHtml = raw.description?.html; const jobDescription = descHtml ? stripHtml(descHtml) : undefined; const salary = salaryLabel(raw.salary); const cats = Array.isArray(raw.categories) ? raw.categories .map((c) => c?.name?.trim()) .filter((v): v is string => Boolean(v)) .join(", ") : undefined; return { source: "qajobsboard", sourceJobId: jobUrl.split("/").pop(), title: asString(raw.title) ?? "Unknown Title", employer, jobUrl, applicationLink, location: locationLabel(raw), isRemote: asString(raw.location)?.toLowerCase() === "remote", datePosted: asString(raw.published_at), jobDescription, jobType: asString(raw.arrangement), salary, disciplines: cats, companyLogo: asString(raw.company?.logo), }; } export const manifest: ExtractorManifest = { id: "qajobsboard", displayName: "QAJobsBoard", providesSources: ["qajobsboard"], async run(context): Promise { if (context.shouldCancel?.()) return { success: true, jobs: [] }; const maxJobs = context.settings.qajobsboardMaxJobsPerTerm ? Number.parseInt(context.settings.qajobsboardMaxJobsPerTerm, 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: JOBS_URL, detail: "QAJobsBoard: fetching jobs.json", }); try { const response = await fetch(JOBS_URL, { headers: { Accept: "application/json", "User-Agent": "JobOps/1.0" }, }); if (!response.ok) { throw new Error( `QAJobsBoard request failed with status ${response.status}`, ); } const body = (await response.json()) as unknown; const rows = Array.isArray(body) ? body : []; const seen = new Set(); const out: CreateJobInput[] = []; for (const row of rows as QaJobBoardlyJob[]) { if (out.length >= cap) break; if (terms.length > 0 && !terms.some((t) => matchesTerm(row, t))) 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: JOBS_URL, jobPagesProcessed: out.length, detail: `QAJobsBoard: ${out.length} matched (${rows.length} total listings)`, }); 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;