/** * Jooble aggregator API. * * https://jooble.org/api/about — `POST https://jooble.org/api/{key}` with a * JSON body of `{ keywords, location, page, ResultOnPage }`. * * Requires JOOBLE_API_KEY (`joobleApiKey` setting). */ import type { ExtractorManifest, ExtractorRunResult, } from "@shared/types/extractors"; import type { CreateJobInput } from "@shared/types/jobs"; const API_BASE = "https://jooble.org/api"; interface JoobleJob { id?: number | string; title?: string; location?: string; snippet?: string; salary?: string; source?: string; type?: string; link?: string; company?: string; updated?: string; } interface JoobleResponse { totalCount?: number; jobs?: JoobleJob[]; } function asString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function mapJob(raw: JoobleJob): CreateJobInput | null { const jobUrl = asString(raw.link); if (!jobUrl) return null; return { source: "jooble", sourceJobId: raw.id != null ? String(raw.id) : undefined, title: asString(raw.title) ?? "Unknown Title", employer: asString(raw.company) ?? "Unknown Employer", jobUrl, applicationLink: jobUrl, location: asString(raw.location), jobType: asString(raw.type), salary: asString(raw.salary), datePosted: asString(raw.updated), jobDescription: asString(raw.snippet), companyDescription: asString(raw.source), }; } async function fetchPage(args: { apiKey: string; keywords: string; location?: string; page: number; resultOnPage: number; }): Promise { const response = await fetch( `${API_BASE}/${encodeURIComponent(args.apiKey)}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ keywords: args.keywords, location: args.location ?? "", page: String(args.page), ResultOnPage: String(args.resultOnPage), }), }, ); if (!response.ok) { throw new Error(`Jooble request failed with status ${response.status}`); } return (await response.json()) as JoobleResponse; } export const manifest: ExtractorManifest = { id: "jooble", displayName: "Jooble", providesSources: ["jooble"], requiredEnvVars: ["JOOBLE_API_KEY"], async run(context): Promise { if (context.shouldCancel?.()) return { success: true, jobs: [] }; const apiKey = context.settings.joobleApiKey?.trim() || process.env.JOOBLE_API_KEY?.trim(); if (!apiKey) { return { success: false, jobs: [], error: "Jooble extractor requires JOOBLE_API_KEY", }; } const maxJobsPerTerm = context.settings.joobleMaxJobsPerTerm ? Number.parseInt(context.settings.joobleMaxJobsPerTerm, 10) : 100; const resultOnPage = 50; const terms = context.searchTerms.length > 0 ? context.searchTerms : [""]; const location = context.settings.searchCities?.split("|")[0]?.trim() || undefined; 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].trim(); context.onProgress?.({ phase: "list", termsProcessed: i, termsTotal: terms.length, currentUrl: term || "(all)", detail: `Jooble: term ${i + 1}/${terms.length}`, }); let collected = 0; let page = 1; while (collected < maxJobsPerTerm && page < 50) { if (context.shouldCancel?.()) break; const body = await fetchPage({ apiKey, keywords: term, location, page, resultOnPage, }); const items = Array.isArray(body.jobs) ? body.jobs : []; if (items.length === 0) break; for (const raw of items) { const mapped = mapJob(raw); 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 (items.length < resultOnPage) break; page += 1; } context.onProgress?.({ phase: "list", termsProcessed: i + 1, termsTotal: terms.length, currentUrl: term || "(all)", jobPagesProcessed: out.length, detail: `Jooble: 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;