Add Optius scraper via its official RSS feed
Optius publishes an RSS feed with region/field filters, so no HTML scraping is needed. Also move the duplicated slug helper into http.ts (output unchanged, so existing source IDs and URLs are unaffected). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ No runtime dependencies. It uses Bun's built-in SQLite, HTTP server and HTMLRewr
|
|||||||
| 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) |
|
| 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) |
|
||||||
|
| optius.com (IT, Osrednjeslovenska) | Official RSS feed | Build a feed URL at https://www.optius.com/rss/ (its IDs differ from the search page's); add more with `optius("<label>", "<feed URL>")` (same label rule as MojeDelo) |
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { USER_AGENT } from "./http";
|
import { slugify, USER_AGENT } from "./http";
|
||||||
import type { ScrapedJob, Scraper } from "./types";
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
// Adecco has no public API, but its job search page loads results from this internal JSON endpoint
|
// Adecco has no public API, but its job search page loads results from this internal JSON endpoint
|
||||||
@@ -28,12 +28,7 @@ interface AdeccoResponse {
|
|||||||
|
|
||||||
/** The site builds "<title>-<location>/<jobid>" URLs; only the job ID matters, the slug is cosmetic. */
|
/** The site builds "<title>-<location>/<jobid>" URLs; only the job ID matters, the slug is cosmetic. */
|
||||||
function jobUrl(j: AdeccoJob): string {
|
function jobUrl(j: AdeccoJob): string {
|
||||||
const slug = `${j.jobTitle}-${j.jobLocation ?? ""}`
|
const slug = slugify(`${j.jobTitle}-${j.jobLocation ?? ""}`);
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[̀-ͯ]/g, "")
|
|
||||||
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "")
|
|
||||||
.toLowerCase();
|
|
||||||
return `${JOB_PAGE}/${slug}/${j.jobId.toLowerCase()}`;
|
return `${JOB_PAGE}/${slug}/${j.jobId.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,3 +45,12 @@ export function decodeEntities(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const clean = (s: string) => decodeEntities(s).replace(/\s+/g, " ").trim();
|
export const clean = (s: string) => decodeEntities(s).replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
/** ASCII URL/ID slug: "Inženir (m/ž)" -> "inzenir-m-z". */
|
||||||
|
export const slugify = (s: string) =>
|
||||||
|
s
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^A-Za-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { adecco } from "./adecco";
|
import { adecco } from "./adecco";
|
||||||
import { mojeDelo } from "./mojedelo";
|
import { mojeDelo } from "./mojedelo";
|
||||||
|
import { optius } from "./optius";
|
||||||
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";
|
||||||
@@ -17,6 +18,7 @@ export const scrapers: Scraper[] = [
|
|||||||
"IT, Osrednjeslovenska",
|
"IT, Osrednjeslovenska",
|
||||||
"https://www.mojedelo.com/iskanje-zaposlitve?jobCategoryIds=64f003ff-6d8b-4be0-b58c-4580e4eeeb8a®ionIds=d1dce9b1-9fa4-438b-b582-10d371d442e6",
|
"https://www.mojedelo.com/iskanje-zaposlitve?jobCategoryIds=64f003ff-6d8b-4be0-b58c-4580e4eeeb8a®ionIds=d1dce9b1-9fa4-438b-b582-10d371d442e6",
|
||||||
),
|
),
|
||||||
|
optius("IT, Osrednjeslovenska", "https://www.optius.com/rss/?regija=7&podrocje=7"),
|
||||||
];
|
];
|
||||||
|
|
||||||
export type { Scraper, ScrapedJob } from "./types";
|
export type { Scraper, ScrapedJob } from "./types";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { USER_AGENT } from "./http";
|
import { slugify, USER_AGENT } from "./http";
|
||||||
import type { ScrapedJob, Scraper } from "./types";
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
// MojeDelo is a single-page app with no public API; its search page calls this internal JSON API.
|
// MojeDelo is a single-page app with no public API; its search page calls this internal JSON API.
|
||||||
@@ -43,16 +43,6 @@ async function tenantHeaders(): Promise<Record<string, string>> {
|
|||||||
return { tenantId: get("tenantId"), channelId: get("jbChannelId"), languageId: get("defaultLanguageId") };
|
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,
|
* 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,
|
* e.g. jobCategoryIds / regionIds). `label` is shown in the UI and also forms the stable source ID,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { clean, fetchHtml, slugify } from "./http";
|
||||||
|
import type { ScrapedJob, Scraper } from "./types";
|
||||||
|
|
||||||
|
// Optius publishes an official RSS feed with region/field filters, so no HTML scraping is needed.
|
||||||
|
// Build a feed URL at https://www.optius.com/rss/ (its IDs differ from the search page's IDs).
|
||||||
|
|
||||||
|
const tag = (xml: string, name: string) => new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`).exec(xml)?.[1];
|
||||||
|
const text = (s: string | undefined) => clean((s ?? "").replace(/^<!\[CDATA\[([\s\S]*)\]\]>$/, "$1"));
|
||||||
|
|
||||||
|
export function parseOptiusFeed(xml: string): ScrapedJob[] {
|
||||||
|
return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)]
|
||||||
|
.map((m): ScrapedJob | null => {
|
||||||
|
const item = m[1]!;
|
||||||
|
const url = text(tag(item, "link") ?? tag(item, "guid"));
|
||||||
|
const id = /-(\d+)-\1\/?$/.exec(url)?.[1] ?? /-(\d+)\/?$/.exec(url)?.[1];
|
||||||
|
const title = text(tag(item, "title"));
|
||||||
|
if (!id || !title) return null;
|
||||||
|
const pubDate = tag(item, "pubDate");
|
||||||
|
return {
|
||||||
|
externalId: id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
company: text(tag(item, "employer")) || undefined,
|
||||||
|
tags: [...item.matchAll(/<field(?:\s[^>]*)?>([\s\S]*?)<\/field>/g)].map((f) => text(f[1])).filter(Boolean),
|
||||||
|
postedAt: pubDate ? new Date(pubDate).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((j): j is ScrapedJob => j !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scraper for an optius.com RSS feed, e.g. "https://www.optius.com/rss/?regija=7&podrocje=7". */
|
||||||
|
export function optius(label: string, feedUrl: string): Scraper {
|
||||||
|
return {
|
||||||
|
id: `optius-${label.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()}`,
|
||||||
|
name: `Optius (${label})`,
|
||||||
|
async scrape() {
|
||||||
|
const xml = await fetchHtml(feedUrl);
|
||||||
|
if (!xml.includes("<rss")) throw new Error("response is not an RSS feed (site changed?)");
|
||||||
|
return parseOptiusFeed(xml);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user