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
+1
View File
@@ -10,6 +10,7 @@ No runtime dependencies. It uses Bun's built-in SQLite, HTTP server and HTMLRewr
|---|---|---|
| slo-tech.com (Ljubljana) | HTML scrape | ~20 newest jobs |
| startup.si | HTML scrape (no public API or feed) | Site lists every posting since 2019 on one page, so only the last 90 days are kept (`startupSi(days)` in `src/scrapers/index.ts`) |
| adecco.com (Slovenia, IT) | Internal JSON API the site's job search uses (no public API) | Filtered by category; add more with `adecco("<CategoryFilter value>", "<label>")` |
## Run
+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,
}));
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared helpers for scrapers.
const USER_AGENT =
export const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
// Upper half (0xA0-0xFF) of ISO-8859-2, which Bun's TextDecoder does not support.
+2
View File
@@ -1,3 +1,4 @@
import { adecco } from "./adecco";
import { sloTech } from "./slotech";
import { startupSi } from "./startupsi";
import type { Scraper } from "./types";
@@ -10,6 +11,7 @@ import type { Scraper } from "./types";
export const scrapers: Scraper[] = [
sloTech("Ljubljana"),
startupSi(),
adecco("ADCSICAT014", "IT"),
];
export type { Scraper, ScrapedJob } from "./types";