import type { JobExtract } from "../shared/types"; const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; const MAX_BYTES = 4_000_000; const MAX_TEXT = 24_000; /** Fetch a job posting URL and extract readable text, preferring JobPosting JSON-LD. */ export async function extractJob(url: string): Promise { const res = await fetch(url, { redirect: "follow", headers: { "User-Agent": UA, Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", }, }); if (!res.ok) throw new Error(`upstream responded ${res.status} ${res.statusText}`); const raw = await readCapped(res); const contentType = res.headers.get("content-type") ?? ""; if (!contentType.includes("html")) { return { url, title: url, text: tidy(raw).slice(0, MAX_TEXT), source: "plain" }; } const posting = findJobPostingLd(raw); if (posting) return { url, title: posting.title, text: posting.text.slice(0, MAX_TEXT), source: "json-ld" }; const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(raw); const title = titleMatch ? tidy(decodeEntities(titleMatch[1])) : url; return { url, title: title || url, text: stripHtml(raw).slice(0, MAX_TEXT), source: "html" }; } /** Stream the body with a byte cap so an unbounded response cannot exhaust memory. */ async function readCapped(res: Response): Promise { if (!res.body) return ""; const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let bytes = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; bytes += value.length; chunks.push(value); if (bytes >= MAX_BYTES) { await reader.cancel(); break; } } const merged = new Uint8Array(Math.min(bytes, MAX_BYTES)); let offset = 0; for (const chunk of chunks) { const slice = chunk.subarray(0, Math.min(chunk.length, merged.length - offset)); merged.set(slice, offset); offset += slice.length; if (offset >= merged.length) break; } return new TextDecoder("utf-8", { fatal: false, ignoreBOM: false }).decode(merged); } /** Walk every