/** * Jobicy remote-jobs feed. * * Public, unauthenticated JSON endpoint: * https://jobicy.com/api/v2/remote-jobs?count=50 * * The feed is intentionally remote-only; we still pass each `searchTerm` as a * `tag` so the same pipeline-level term iteration drives results. We do *not* * try to invent a country filter — Jobicy postings are remote-friendly by * design and the registry already restricts ukOnly extractors elsewhere. */ import type { ExtractorManifest, ExtractorRunResult, } from "@shared/types/extractors"; import type { CreateJobInput } from "@shared/types/jobs"; const API_URL = "https://jobicy.com/api/v2/remote-jobs"; interface JobicyRawJob { id?: number | string; url?: string; jobTitle?: string; companyName?: string; companyLogo?: string; jobIndustry?: string[] | string; jobType?: string[] | string; jobGeo?: string; jobLevel?: string; jobExcerpt?: string; jobDescription?: string; pubDate?: string; annualSalaryMin?: number | string; annualSalaryMax?: number | string; salaryCurrency?: string; } interface JobicyResponse { jobs?: JobicyRawJob[]; } function asString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function joinList(value: unknown): string | undefined { if (Array.isArray(value)) { const cleaned = value .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean); return cleaned.length > 0 ? cleaned.join(", ") : undefined; } return asString(value); } function toNumberOrUndefined(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function mapJob(raw: JobicyRawJob): CreateJobInput | null { const jobUrl = asString(raw.url); if (!jobUrl) return null; const employer = asString(raw.companyName) ?? "Unknown Employer"; const title = asString(raw.jobTitle) ?? "Unknown Title"; const minSalary = toNumberOrUndefined(raw.annualSalaryMin); const maxSalary = toNumberOrUndefined(raw.annualSalaryMax); return { source: "jobicy", sourceJobId: raw.id != null ? String(raw.id) : undefined, title, employer, jobUrl, applicationLink: jobUrl, location: asString(raw.jobGeo) ?? "Remote", isRemote: true, jobType: joinList(raw.jobType), jobLevel: asString(raw.jobLevel), companyIndustry: joinList(raw.jobIndustry), companyLogo: asString(raw.companyLogo), datePosted: asString(raw.pubDate), jobDescription: asString(raw.jobDescription) ?? asString(raw.jobExcerpt), salaryMinAmount: minSalary, salaryMaxAmount: maxSalary, salaryCurrency: asString(raw.salaryCurrency), salaryInterval: minSalary || maxSalary ? "yearly" : undefined, }; } async function fetchJobicy( tag: string | null, count: number, ): Promise { const url = new URL(API_URL); url.searchParams.set("count", String(Math.min(Math.max(count, 1), 50))); if (tag) url.searchParams.set("tag", tag); const response = await fetch(url.toString(), { headers: { Accept: "application/json" }, }); if (!response.ok) { throw new Error(`Jobicy request failed with status ${response.status}`); } const body = (await response.json()) as JobicyResponse; return Array.isArray(body.jobs) ? body.jobs : []; } export const manifest: ExtractorManifest = { id: "jobicy", displayName: "Jobicy (Remote)", providesSources: ["jobicy"], async run(context): Promise { if (context.shouldCancel?.()) return { success: true, jobs: [] }; const maxJobsPerTerm = context.settings.jobicyMaxJobsPerTerm ? Number.parseInt(context.settings.jobicyMaxJobsPerTerm, 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 tag = term ? term.trim().toLowerCase() : null; context.onProgress?.({ phase: "list", termsProcessed: i, termsTotal: terms.length, currentUrl: tag ?? "(all remote)", detail: `Jobicy: term ${i + 1}/${terms.length}`, }); // Jobicy caps `count` at 50 per call; loop until we either hit the // requested cap or the feed runs out (length < take). let collected = 0; let safetyHops = 0; while (collected < maxJobsPerTerm && safetyHops < 10) { const take = Math.min(50, maxJobsPerTerm - collected); const raw = await fetchJobicy(tag, take); if (raw.length === 0) break; for (const item of raw) { 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; if (collected >= maxJobsPerTerm) break; } if (raw.length < take) break; safetyHops += 1; } context.onProgress?.({ phase: "list", termsProcessed: i + 1, termsTotal: terms.length, currentUrl: tag ?? "(all remote)", jobPagesProcessed: out.length, detail: `Jobicy: 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;