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:
@@ -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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user