Add Adecco (Slovenia, IT) scraper

Adecco has no public API; use the internal JSON endpoint its job search
page calls, filtered by job category. Results are paginated and checked
against the requested category, since the API silently ignores unknown
filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 21:06:34 +02:00
co-authored by Claude Opus 5.5
parent f6e5605108
commit 88420b54e7
4 changed files with 93 additions and 1 deletions
+89
View File
@@ -0,0 +1,89 @@
import { USER_AGENT } from "./http";
import type { ScrapedJob, Scraper } from "./types";
// Adecco has no public API, but its job search page loads results from this internal JSON endpoint
// (a Solr-style query passed as `queryString`). Unknown filter fields are silently ignored by the
// API, so results are checked against the requested category.
const API_URL = "https://www.adecco.com/api/data/jobs/summarized";
const JOB_PAGE = "https://www.adecco.com/sl-si/prosta-delovna-mesta";
const MAX_PAGES = 50; // 10 jobs per page
interface AdeccoJob {
jobId: string;
jobTitle: string;
jobLocation: string | null;
cityName: string | null;
postedDate: string | null;
jobCategoryId: string | null;
jobSubCategoryId: string | null;
contractTypeTitle: string | null;
employmentTypeTitle: string | null;
industryTypeTitle: string | null;
}
interface AdeccoResponse {
jobs?: AdeccoJob[];
pagination?: { total: number; nextRange: number };
}
/** The site builds "<title>-<location>/<jobid>" URLs; only the job ID matters, the slug is cosmetic. */
function jobUrl(j: AdeccoJob): string {
const slug = `${j.jobTitle}-${j.jobLocation ?? ""}`
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^A-Za-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase();
return `${JOB_PAGE}/${slug}/${j.jobId.toLowerCase()}`;
}
/**
* Scraper for adecco.com (Slovenia), filtered by job category.
* `category` is the CategoryFilter value from the site's URL, e.g. "ADCSICAT014" (IT).
*/
export function adecco(category: string, label: string): Scraper {
return {
id: `adecco-si-${category.toLowerCase()}`,
name: `Adecco (${label})`,
async scrape() {
const raw: AdeccoJob[] = [];
for (let page = 0, range = 0; page < MAX_PAGES; page++) {
const res = await fetch(API_URL, {
method: "POST",
headers: { "User-Agent": USER_AGENT, "content-type": "text/plain;charset=UTF-8" },
body: JSON.stringify({
queryString: `&fq=JobCategoryId:${category}`,
range,
siteName: "adecco",
brand: "adecco",
countryCode: "SI",
languageCode: "sl-SI",
}),
});
if (!res.ok) throw new Error(`POST ${API_URL} -> HTTP ${res.status}`);
const data = (await res.json()) as AdeccoResponse;
if (!Array.isArray(data.jobs)) throw new Error("unexpected API response (no `jobs` array)");
raw.push(...data.jobs);
const total = data.pagination?.total ?? 0;
if (data.jobs.length === 0 || raw.length >= total || !data.pagination?.nextRange) break;
range = data.pagination.nextRange;
}
const jobs = raw.filter((j) => j.jobCategoryId === category || j.jobSubCategoryId === category);
if (raw.length > 0 && jobs.length === 0) {
throw new Error(`category filter ${category} was ignored by the API (query format changed?)`);
}
return jobs.map((j) => ({
externalId: j.jobId,
title: j.jobTitle.trim(),
url: jobUrl(j),
location: j.cityName || j.jobLocation || undefined,
tags: [j.contractTypeTitle, j.employmentTypeTitle, j.industryTypeTitle].filter(
(t): t is string => !!t?.trim(),
),
postedAt: j.postedDate ?? undefined,
}));
},
};
}