Files
ai-job-search/.agents/skills/freehire-search/cli/src/helpers.ts
T
Ilya Strelov e3af401087 feat(freehire-search): search returns each hit's full description (#251)
The skill queried /api/v1/jobs/search, whose `description` is the search
index's truncated preview — and the CLI dropped it entirely, so a result
carried only title/company/location/date/url. Reading a posting therefore
meant a `detail` call per hit, which is exactly what job-scraper's Step 2
prescribes: "fetch full detail with that portal's `detail` command".

freehire exposes a search endpoint for programmatic consumers,
/api/v1/agent/jobs/search: same query, ranking, facets and pagination, but
asked to (`include_description=true`) it replaces the preview with the
posting's full description read from the database, rendered as
`description_format=markdown|text|html`. Reproduce the difference:

  curl -s "https://freehire.me/api/v1/jobs/search?q=golang&limit=1" \
    | jq -r '.data[0].description | length'          # preview, capped
  curl -s "https://freehire.me/api/v1/agent/jobs/search?q=golang&limit=1\
&include_description=true&description_format=markdown" \
    | jq -r '.data[0].description | length'          # full text

So `search` now calls that endpoint, always asking for full descriptions,
and each JSON result carries `description` verbatim — no client-side HTML
stripping, since the API already rendered it. Markdown is the default
because it preserves the headings and requirement lists /rank reasons over;
`--description-format text|html` selects the others. The flag is validated
client-side: the API answers an unrecognized format with raw HTML rather
than an error, so a typo would silently change the output instead of
failing.

`table` and `plain` stay description-free — a full posting body would swamp
a scannable list — and `detail` is untouched, for looking one posting up by
slug (including a closed one, absent from search).

One behaviour change beyond the endpoint: a 404 from the search path used
to be folded into an empty result set. On the agent endpoint a 404 means
the instance predates it — a self-hosted freehire behind FREEHIRE_API_URL —
so it is now reported as an error naming the path, instead of a plausible
"no results" that hides the misconfiguration.

Tests cover the requested URL and params, verbatim (unstripped) markdown,
the null-when-absent case, the 404-is-an-error contract, and the flag
validation. All network-free.
2026-07-28 21:19:24 +02:00

231 lines
7.7 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.
// 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<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",
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<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.
*
* `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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
.replace(/&nbsp;/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
}