Some checks failed
CI / Linting (Biome) (push) Failing after 36s
CI / Tests (push) Successful in 5m54s
CI / Type Check (adzuna-extractor) (push) Successful in 1m6s
CI / Type Check (gradcracker-extractor) (push) Successful in 1m9s
CI / Type Check (hiringcafe-extractor) (push) Successful in 1m5s
CI / Type Check (orchestrator) (push) Successful in 1m21s
CI / Type Check (startupjobs-extractor) (push) Successful in 1m4s
CI / Type Check (ukvisajobs-extractor) (push) Successful in 1m4s
CI / Documentation (push) Successful in 1m52s
Adds extractor packages: arbeitnow, ashby, careerjet, fourdayweek,
greenhouse, himalayas, jobicy, jooble, lever, reed, remoteok, remotive,
themuse, usajobs, weworkremotely, workday — each with manifest, package
metadata and README.
Pipeline / shared:
- shared/job-fingerprint: stable hash for cross-source dedup, with tests
- discover-jobs: dedup via fingerprint and richer per-source merging
- jobs repository: fingerprint-aware upsert / lookup
- settings-registry, settings types/routes, demo-defaults: knobs for the
new sources
- shared extractors index: register the new manifests
- location-support, profiles route: small fixes for the new sources
Tooling:
- scripts/smoke-extractors.ts to sanity-check each source locally
- scripts/jobber-cron-{cherepaha,dobkin}.env.example: per-host cron
templates (CHANGEME placeholders only)
- .env.example: documented env vars for the new extractors
- .gitignore: ignore extractors/*/storage/ runtime caches (was ukvisajobs only)
Co-authored-by: Cursor <cursoragent@cursor.com>
178 lines
4.9 KiB
TypeScript
178 lines
4.9 KiB
TypeScript
/**
|
|
* 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<JoobleResponse> {
|
|
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<ExtractorRunResult> {
|
|
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<string>();
|
|
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;
|