Files
ai-job-search/.agents/skills/freehire-search/cli/src/helpers.ts
T
Sahil Kumar SahuandClaude Opus 5.5 9187f894a5 fix(freehire-search): keep the salary period so a yearly figure isn't read as monthly (#504)
formatSalary printed only the currency and amounts, although freehire's
enrichment records the pay period. `detail software-developer-trainee-codifi-fra635ux`
printed `INR 300000–300000` while the API returns `salary_period: "year"`
for that posting, so a yearly salary screened against a monthly figure
read as twelve times its real monthly value.

Append the period when freehire records one (`INR 300000–300000/year`,
`INR 12000/month`); a record without a period prints exactly as before.
Two new parsing.test.ts cases pin it; both fail on the old formatter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:22:04 +02:00

237 lines
8.0 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
salary_period?: string // e.g. "year", "month"
}
}
/**
* 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.
* The period is kept when freehire records one ("INR 300000–300000/year"):
* without it a yearly figure is indistinguishable from a monthly one.
*/
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} ` : ""
const per = e.salary_period ? `/${e.salary_period}` : ""
if (e.salary_min != null && e.salary_max != null) return `${cur}${e.salary_min}–${e.salary_max}${per}`
return `${cur}${e.salary_min ?? e.salary_max}${per}`
}
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
}