Files
job/scrapper/src/scrapers/http.ts
T
nikolaandClaude Opus 5.5 ad2fa2b775 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>
2026-10-12 21:29:24 +02:00

57 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared helpers for scrapers.
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.
const LATIN2_HIGH =
" Ą˘Ł¤ĽŚ§¨ŠŞŤŹ­ŽŻ°ą˛ł´ľśˇ¸šşťź˝žżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙";
function decodeLatin2(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) out += b < 0xa0 ? String.fromCharCode(b) : LATIN2_HIGH[b - 0xa0];
return out;
}
/** Fetch a page and decode it according to its declared charset. */
export async function fetchHtml(url: string): Promise<string> {
const res = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
const charset = /charset=([\w-]+)/i.exec(res.headers.get("content-type") ?? "")?.[1]?.toLowerCase() ?? "utf-8";
if (charset === "iso-8859-2" || charset === "latin2") return decodeLatin2(bytes);
try {
return new TextDecoder(charset).decode(bytes);
} catch {
return new TextDecoder("utf-8").decode(bytes);
}
}
const NAMED_ENTITIES: Record<string, string> = {
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", raquo: "»", laquo: "«",
scaron: "š", Scaron: "Š", zcaron: "ž", Zcaron: "Ž", ccaron: "č", Ccaron: "Č",
ndash: "–", mdash: "—", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bdquo: "„", hellip: "…",
};
/** Decode HTML entities (HTMLRewriter text chunks are not decoded). */
export function decodeEntities(s: string): string {
return s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e: string) => {
if (e[0] === "#") {
const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
}
return NAMED_ENTITIES[e] ?? NAMED_ENTITIES[e.toLowerCase()] ?? m;
});
}
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();