mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-18 17:16:26 +00:00
224 lines
7.3 KiB
TypeScript
224 lines
7.3 KiB
TypeScript
// Data source: the freehire.dev 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.dev"
|
|||
|
|
|
|||
|
|
/** 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.dev)"
|
|||
|
|
|
|||
|
|
/** The shared API response envelope: {data, meta, error}. */
|
|||
|
|
export interface Envelope<T> {
|
|||
|
|
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<T>(path: string): Promise<Envelope<T> | 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",
|
|||
|
|
})
|
|||
|
|
} 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<T> | 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<void> {
|
|||
|
|
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 <slug>` consumes) and `date` is the posting date; missing values
|
|||
|
|
* are `null`, never omitted. The extra facet fields are a permitted superset.
|
|||
|
|
*/
|
|||
|
|
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[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 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,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 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/<slug> 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
|
|||
|
|
}
|