/** * Remotive public remote-jobs API. * * https://remotive.com/api/remote-jobs?limit=N&search=term * * No auth. Returns up to `limit` results per call with a `search` keyword * filter. We iterate pipeline search terms as the `search` parameter. */ import type { ExtractorManifest, ExtractorRunResult, } from "@shared/types/extractors"; import type { CreateJobInput } from "@shared/types/jobs"; const API_URL = "https://remotive.com/api/remote-jobs"; interface RemotiveJob { id?: number; url?: string; title?: string; company_name?: string; company_logo?: string; category?: string; tags?: string[]; job_type?: string; publication_date?: string; candidate_required_location?: string; salary?: string; description?: string; } interface RemotiveResponse { jobs?: RemotiveJob[]; } function asString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function normalizeJobType(raw: string | undefined): string | undefined { if (!raw) return undefined; return raw.replace(/_/g, " ").trim() || undefined; } function mapJob(raw: RemotiveJob): CreateJobInput | null { const jobUrl = asString(raw.url); if (!jobUrl) return null; const tags = Array.isArray(raw.tags) ? raw.tags.filter((t): t is string => typeof t === "string" && t.length > 0) : []; return { source: "remotive", sourceJobId: raw.id != null ? String(raw.id) : undefined, title: asString(raw.title) ?? "Unknown Title", employer: asString(raw.company_name) ?? "Unknown Employer", jobUrl, applicationLink: jobUrl, location: asString(raw.candidate_required_location) ?? "Remote", isRemote: true, jobType: normalizeJobType(raw.job_type), companyIndustry: asString(raw.category), companyLogo: asString(raw.company_logo), datePosted: asString(raw.publication_date), salary: asString(raw.salary), jobDescription: asString(raw.description), disciplines: tags.length > 0 ? tags.join(", ") : undefined, }; } async function fetchJobs( search: string | null, limit: number, ): Promise { const url = new URL(API_URL); url.searchParams.set("limit", String(Math.min(Math.max(limit, 1), 100))); if (search) url.searchParams.set("search", search); const response = await fetch(url.toString(), { headers: { Accept: "application/json" }, }); if (!response.ok) { throw new Error(`Remotive request failed with status ${response.status}`); } const body = (await response.json()) as RemotiveResponse; return Array.isArray(body.jobs) ? body.jobs : []; } export const manifest: ExtractorManifest = { id: "remotive", displayName: "Remotive", providesSources: ["remotive"], async run(context): Promise { if (context.shouldCancel?.()) return { success: true, jobs: [] }; const maxJobsPerTerm = context.settings.remotiveMaxJobsPerTerm ? Number.parseInt(context.settings.remotiveMaxJobsPerTerm, 10) : 100; const terms = context.searchTerms.length > 0 ? context.searchTerms : [null]; const seen = new Set(); const out: CreateJobInput[] = []; try { for (let i = 0; i < terms.length; i += 1) { if (context.shouldCancel?.()) break; const term = terms[i]; const search = term ? term.trim() : null; context.onProgress?.({ phase: "list", termsProcessed: i, termsTotal: terms.length, currentUrl: search ?? "(all remote)", detail: `Remotive: term ${i + 1}/${terms.length}`, }); const raw = await fetchJobs(search, maxJobsPerTerm); let collected = 0; for (const item of raw) { if (collected >= maxJobsPerTerm) break; const mapped = mapJob(item); if (!mapped) continue; const key = mapped.sourceJobId || mapped.jobUrl; if (seen.has(key)) continue; seen.add(key); out.push(mapped); collected += 1; } context.onProgress?.({ phase: "list", termsProcessed: i + 1, termsTotal: terms.length, currentUrl: search ?? "(all remote)", jobPagesProcessed: out.length, detail: `Remotive: completed term ${i + 1}/${terms.length} (${collected} found)`, }); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return { success: false, jobs: out, error: message }; } return { success: true, jobs: out }; }, }; export default manifest;