// Data source: the freehire.me public REST API (JSON, `{data, meta}` envelope). // Reads are unauthenticated — no API key, the same bar as linkedin-search — and // unlike the HTML-scraping portals there is no markup to parse: we fetch JSON and // reshape it into the portal-skill contract's result fields. The base URL is // swappable via FREEHIRE_API_URL for self-hosting. export const DEFAULT_BASE_URL = "https://freehire.me" /** API base URL: FREEHIRE_API_URL (for a self-hosted instance) or the default. */ export function baseUrl(): string { const raw = (process.env.FREEHIRE_API_URL ?? "").trim() return (raw || DEFAULT_BASE_URL).replace(/\/+$/, "") } export function writeError(error: string, code: string): void { process.stderr.write(JSON.stringify({ error, code }) + "\n") } const UA = "freehire-search-skill/1.0 (+https://freehire.me)" /** The shared API response envelope: {data, meta, error}. */ export interface Envelope { data: T meta?: { total?: number; limit?: number; offset?: number } error?: string } /** * GET a JSON envelope from the freehire API. Retries 429/5xx (transient server * states) with backoff; returns `null` on a 404. A connection failure fails fast * with a clear message — no retry, so an outage degrades this source quickly * rather than hanging the caller (the graceful-degradation contract). */ export async function apiGet(path: string): Promise | null> { const url = `${baseUrl()}${path}` const maxRetries = 6 let delay = 500 for (let attempt = 0; attempt <= maxRetries; attempt++) { let response: Response try { response = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json" }, redirect: "follow", signal: AbortSignal.timeout(15000), }) } catch (e) { // Connection refused / DNS failure / timeout: the API is unreachable. throw new Error( `could not reach the freehire API at ${baseUrl()} (${e instanceof Error ? e.message : String(e)})`, ) } if (response.status === 429 || response.status >= 500) { if (attempt === maxRetries) { throw new Error(`freehire API request failed: ${response.status} ${response.statusText}`) } await sleep(delay + Math.floor(Math.random() * 500)) delay = Math.min(delay * 2, 8000) continue } if (response.status === 404) return null // Read the body once, tolerantly: an error response's JSON gives us its // `error` message; a 2xx must parse (a malformed one is surfaced, not swallowed). const body = (await response.json().catch(() => null)) as Envelope | null if (!response.ok) { throw new Error(body?.error || `freehire API request failed: ${response.status} ${response.statusText}`) } if (!body) throw new Error("freehire API returned an unparseable response body") return body } // Unreachable in practice; the loop returns or throws on the last attempt. throw new Error("freehire API request failed after retries") } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)) } /** * A freehire job — the fields this skill reads (the wire shape carries more). */ export interface FreehireJob { public_slug: string source: string external_id: string url: string title: string company: string company_slug: string location: string description: string skills: string[] work_mode?: string regions: string[] countries: string[] cities: string[] posted_at: string | null created_at: string | null // Always present in the wire shape (an unenriched job serializes it as `{}`); // the individual fields are what may be absent. enrichment: { seniority?: string category?: string employment_type?: string salary_min?: number salary_max?: number salary_currency?: string } } /** * A search result in the portal-skill contract shape. `id` is the public_slug * (what `detail ` consumes) and `date` is the posting date; missing values * are `null`, never omitted. The extra facet fields are a permitted superset. * * `description` is the posting's full text in the format the search asked the API * for — the agent search endpoint hydrates it server-side, so it arrives already * rendered and is passed through verbatim rather than run through `cleanHtml`. */ export interface JobResult { id: string title: string company: string | null company_slug: string | null location: string | null date: string | null url: string work_mode: string | null regions: string[] countries: string[] skills: string[] description: string | null } /** A job detail: the search result plus the cleaned description and enrichment. */ export interface JobDetailResult extends JobResult { cities: string[] seniority: string | null category: string | null employment_type: string | null salary: string | null description: string | null } /** Reshape a freehire job into the contract search-result fields. */ export function toResult(j: FreehireJob): JobResult { return { id: j.public_slug, title: j.title || "(untitled)", company: j.company || null, company_slug: j.company_slug || null, location: j.location || null, date: j.posted_at, url: j.url, work_mode: j.work_mode || null, regions: j.regions, countries: j.countries, skills: j.skills, description: j.description || null, } } /** Reshape a freehire job into the detail result (adds cleaned description + enrichment). */ export function toDetail(j: FreehireJob): JobDetailResult { const e = j.enrichment return { ...toResult(j), cities: j.cities, seniority: e.seniority || null, category: e.category || null, employment_type: e.employment_type || null, salary: formatSalary(e), description: cleanHtml(j.description), } } /** Human-readable salary line from the enrichment fields, or null when absent. */ function formatSalary(e: FreehireJob["enrichment"]): string | null { if (e.salary_min == null && e.salary_max == null) return null const cur = e.salary_currency ? `${e.salary_currency} ` : "" if (e.salary_min != null && e.salary_max != null) return `${cur}${e.salary_min}–${e.salary_max}` return `${cur}${e.salary_min ?? e.salary_max}` } function numericEntity(cp: number): string { return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "" } function decodeHtmlEntities(text: string): string { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10))) .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16))) .replace(/ /g, " ") } /** * Strip a freehire description's HTML into readable prose: block/line-break tags * become newlines, entities are decoded, tags removed. Null for empty input. */ export function cleanHtml(html: string | null | undefined): string | null { if (!html) return null const withBreaks = html .replace(/<\s*br\s*\/?>/gi, "\n") .replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n") const text = decodeHtmlEntities(withBreaks.replace(/<[^>]+>/g, " ")) .replace(/[ \t]+/g, " ") .replace(/ *\n */g, "\n") .replace(/\n{3,}/g, "\n\n") .trim() return text || null } /** Extract a freehire public slug from a bare slug or a /jobs/ URL. */ export function normalizeSlug(input: string): string | null { const trimmed = input.trim() if (!trimmed) return null const m = trimmed.match(/\/jobs\/([^/?#]+)/) if (m) return m[1] // A bare slug: lowercase alphanumerics and hyphens (no path/scheme). if (/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) return trimmed return null }