Add MojeDelo scraper
MojeDelo has no public API; use the internal JSON API its search page calls. Tenant headers are read from the site's public config each run. Takes a search URL copied from the site, paginates, and checks results against the requested category/region filters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ No runtime dependencies. It uses Bun's built-in SQLite, HTTP server and HTMLRewr
|
|||||||
| slo-tech.com (Ljubljana) | HTML scrape | ~20 newest jobs |
|
| 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`) |
|
| 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>")` |
|
| 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>")` |
|
||||||
|
| mojedelo.com (IT, Osrednjeslovenska) | Internal JSON API the site's search uses (no public API) | Takes a search URL copied from the site; add more with `mojeDelo("<label>", "<search URL>")` (don't rename the label later, it's part of the source ID) |
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { adecco } from "./adecco";
|
import { adecco } from "./adecco";
|
||||||
|
import { mojeDelo } from "./mojedelo";
|
||||||
import { sloTech } from "./slotech";
|
import { sloTech } from "./slotech";
|
||||||
import { startupSi } from "./startupsi";
|
import { startupSi } from "./startupsi";
|
||||||
import type { Scraper } from "./types";
|
import type { Scraper } from "./types";
|
||||||
@@ -12,6 +13,10 @@ export const scrapers: Scraper[] = [
|
|||||||
sloTech("Ljubljana"),
|
sloTech("Ljubljana"),
|
||||||
startupSi(),
|
startupSi(),
|
||||||
adecco("ADCSICAT014", "IT"),
|
adecco("ADCSICAT014", "IT"),
|
||||||
|
mojeDelo(
|
||||||
|
"IT, Osrednjeslovenska",
|
||||||
|
"https://www.mojedelo.com/iskanje-zaposlitve?jobCategoryIds=64f003ff-6d8b-4be0-b58c-4580e4eeeb8a®ionIds=d1dce9b1-9fa4-438b-b582-10d371d442e6",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
export type { Scraper, ScrapedJob } from "./types";
|
export type { Scraper, ScrapedJob } from "./types";
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { USER_AGENT } from "./http";
|
||||||
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
|
// MojeDelo is a single-page app with no public API; its search page calls this internal JSON API.
|
||||||
|
// The API requires tenant headers, which the site itself reads from a public config file.
|
||||||
|
const API = "https://api.mojedelo.com";
|
||||||
|
const CONFIG_URL = `${API}/uploaded-files/config/www.mojedelo.com/jb.globals.js`;
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const MAX_PAGES = 25;
|
||||||
|
|
||||||
|
interface Named {
|
||||||
|
id: string;
|
||||||
|
translation?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MojeDeloJob {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDate: string | null;
|
||||||
|
repostingDate: string | null;
|
||||||
|
company: { name?: string } | null;
|
||||||
|
anonymizedCompany: { name?: string } | null;
|
||||||
|
town: Named | null;
|
||||||
|
regions: Named[] | null;
|
||||||
|
jobCategories: Named[] | null;
|
||||||
|
workModes: Named[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
data?: { items?: MojeDeloJob[]; total?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tenantHeaders(): Promise<Record<string, string>> {
|
||||||
|
const res = await fetch(CONFIG_URL, { headers: { "User-Agent": USER_AGENT } });
|
||||||
|
if (!res.ok) throw new Error(`GET ${CONFIG_URL} -> HTTP ${res.status}`);
|
||||||
|
const config = await res.text();
|
||||||
|
const get = (key: string) => {
|
||||||
|
const value = new RegExp(`["']?${key}["']?\\s*:\\s*["']([^"']+)["']`).exec(config)?.[1];
|
||||||
|
if (!value) throw new Error(`${key} not found in site config (site changed?)`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
return { tenantId: get("tenantId"), channelId: get("jbChannelId"), languageId: get("defaultLanguageId") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same slug format as the site's own /oglas/<slug>/<id> links (only the ID matters). */
|
||||||
|
function slugify(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^A-Za-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scraper for mojedelo.com. Pass a search URL copied from the site (filters in the query string,
|
||||||
|
* e.g. jobCategoryIds / regionIds). `label` is shown in the UI and also forms the stable source ID,
|
||||||
|
* so don't change it later.
|
||||||
|
*/
|
||||||
|
export function mojeDelo(label: string, searchUrl: string): Scraper {
|
||||||
|
const filters = new URL(searchUrl).searchParams;
|
||||||
|
filters.delete("page");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `mojedelo-${slugify(label)}`,
|
||||||
|
name: `MojeDelo (${label})`,
|
||||||
|
async scrape() {
|
||||||
|
const headers = { "User-Agent": USER_AGENT, ...(await tenantHeaders()) };
|
||||||
|
const raw: MojeDeloJob[] = [];
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const params = new URLSearchParams(filters);
|
||||||
|
params.set("pageSize", String(PAGE_SIZE));
|
||||||
|
params.set("startFrom", String(page * PAGE_SIZE));
|
||||||
|
const url = `${API}/job-ads-search?${params}`;
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status}`);
|
||||||
|
const body = (await res.json()) as SearchResponse;
|
||||||
|
const items = body.data?.items;
|
||||||
|
if (!Array.isArray(items)) throw new Error("unexpected API response (no `data.items` array)");
|
||||||
|
raw.push(...items);
|
||||||
|
if (items.length < PAGE_SIZE || raw.length >= (body.data?.total ?? 0)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API may silently ignore filters it doesn't recognise, so check results actually match.
|
||||||
|
const wanted = (param: string) => new Set(filters.getAll(param).flatMap((v) => v.split(",")));
|
||||||
|
const categories = wanted("jobCategoryIds");
|
||||||
|
const regions = wanted("regionIds");
|
||||||
|
const matches = (list: Named[] | null, ids: Set<string>) => ids.size === 0 || !!list?.some((x) => ids.has(x.id));
|
||||||
|
const jobs = raw.filter((j) => matches(j.jobCategories, categories) && matches(j.regions, regions));
|
||||||
|
if (raw.length > 0 && jobs.length === 0) {
|
||||||
|
throw new Error("search filters were ignored by the API (query format changed?)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs.map(
|
||||||
|
(j): ScrapedJob => ({
|
||||||
|
externalId: j.id,
|
||||||
|
title: j.title.trim(),
|
||||||
|
url: `https://www.mojedelo.com/oglas/${slugify(j.title)}/${j.id}`,
|
||||||
|
company: j.anonymizedCompany?.name ?? j.company?.name ?? undefined,
|
||||||
|
location: j.town?.translation ?? j.town?.name ?? undefined,
|
||||||
|
tags: [...(j.workModes ?? []), ...(j.jobCategories ?? [])]
|
||||||
|
.map((x) => x.translation?.trim())
|
||||||
|
.filter((t): t is string => !!t),
|
||||||
|
postedAt: j.repostingDate ?? j.startDate ?? undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user