Add freehire-search: country-agnostic freehire.dev aggregator skill (#85)

* feat(freehire-search): add country-agnostic freehire.dev aggregator skill

Adds a portal-search skill over the freehire.dev public JSON API — an
open-source IT job aggregator normalizing ~50 ATS platforms across many
markets into one schema. Like linkedin-search it is country-agnostic and
zero-dependency (plain bun + fetch), but it queries a JSON API rather than
scraping HTML, so results carry structured facets (skills/seniority/region).

Honors the portal-skill contract: search + detail commands, --format
json|table|plain, stderr JSON errors with exit 1, backoff on 429/5xx. Reads
are public (no API key) — the same zero-signup bar as linkedin-search. The
hosted-service dependency (best-effort, no SLA) is labeled prominently in
SKILL.md, and FREEHIRE_API_URL swaps the base URL for a self-hosted backend.

Scoped tech-first: triggers cover software/data/engineering roles, where the
faceted filtering is strong; non-tech coverage exists but is still maturing.

Network-free tests (mocked fetch + pure reshape/parse functions); CI matrix
updated to typecheck the new CLI.

* refactor(freehire-search): clarity pass on cli flag parsing

No behavior change. Replace a nested ternary and a comma-operator side effect
in a ternary with explicit if/else, and fix a comment that described facets
while sitting on the alias map.

* refactor(freehire-search): tighten to boundary contracts, trim comments

- Validate/normalize at boundaries, trust the declared types inside: drop the
  redundant '?? []' guards on facet arrays the wire contract already guarantees,
  and the re-filter in buildQuery (commaList already stripped empties).
- Model enrichment as always-present (an unenriched job serializes it as {}),
  removing the '?? {}' guard.
- Replace the positional table-row builder with a declarative column list; add a
  shared shortDate and a labeled-field helper for detail's plain output.
- Extract stringFlag for the string-or-bare-boolean flags (--remote/--query/...).
- Dedup the response parse in apiGet to a single tolerant read (drop safeJson).
- SKILL.md: document partial data + the 'none' unspecified-region facet.
- Trim restating comments to the reference skills' density.
This commit is contained in:
Ilya Strelov
2026-07-09 06:04:35 +02:00
committed by GitHub
parent e16afac7b9
commit b8d35a4b69
14 changed files with 1354 additions and 0 deletions
@@ -0,0 +1,189 @@
#!/usr/bin/env bun
// Self-contained CLI for searching the freehire.dev aggregator's public JSON API.
// No external CLI framework and zero runtime dependencies, so it runs anywhere
// `bun` is available with nothing installed beyond the repo clone.
//
// Hosted-service dependency: reads are public (no API key), but they hit
// freehire.dev — a personal project maintained best-effort (no formal SLA). Point
// FREEHIRE_API_URL at a self-hosted freehire backend to swap the source.
import { runSearch, type SearchOpts } from "./commands/search.js"
import { runDetail, type DetailOpts } from "./commands/detail.js"
import { baseUrl } from "./helpers.js"
interface Flags {
_: string[]
[k: string]: string | boolean | string[]
}
// Short-flag aliases.
const ALIAS: Record<string, string> = { q: "query", n: "limit" }
function parseFlags(argv: string[]): Flags {
const flags: Flags = { _: [] }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith("-")) {
;(flags._ as string[]).push(a)
continue
}
const name = a.replace(/^-+/, "")
const key = ALIAS[name] ?? name
const next = argv[i + 1]
// A flag with no following value (or another flag next) is a boolean.
let value: string | boolean = true
if (next !== undefined && !next.startsWith("-")) {
value = next
i++
}
// --facet repeats; collect into an array. Everything else is last-wins.
if (key === "facet") {
const acc = Array.isArray(flags.facet) ? flags.facet : []
if (typeof value === "string") acc.push(value)
flags.facet = acc
} else {
flags[key] = value
}
}
return flags
}
type FlagValue = string | boolean | string[] | undefined
/**
* A flag's string value. A bare flag (set without a value, i.e. `true`) yields
* `whenBare` — e.g. `--remote` alone means work_mode "remote".
*/
function stringFlag(raw: FlagValue, whenBare?: string): string | undefined {
if (typeof raw === "string") return raw
if (raw === true) return whenBare
return undefined
}
/** Split a comma-separated facet value ("eu,us") into a trimmed value list. */
function commaList(raw: FlagValue): string[] {
if (typeof raw !== "string") return []
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
}
const HELP = `freehire-cli — search the freehire.dev job aggregator (many markets, tech-focused)
USAGE
bun run src/cli.ts search [-q "<keywords>"] [facet flags] [--format json|table|plain]
bun run src/cli.ts detail <slug|url> [--format json|plain]
SEARCH FLAGS
--query, -q <text> Keywords (title, skill, role). Full-text; optional.
--jobage <days> Posted within N days (maps to posted_within_days).
--page <n> 1-indexed page. Default 1.
--limit, -n <n> Results per page (API limit). Default 25.
--format <fmt> json (default) | table | plain.
FACET FILTERS (values from freehire.dev's controlled vocabularies; comma = OR)
--region <codes> Macro-region: global, eu, us, apac, latam, cis, ... e.g. --region eu,us
--country <codes> ISO-3166 alpha-2, e.g. --country DE,GB
--city <names> City name(s), e.g. --city Berlin
--seniority <levels> junior, middle, senior, staff, principal, lead, ...
--category <cats> backend, frontend, fullstack, devops, ml_ai, qa, ...
--skill <names> Canonical skill(s), e.g. --skill go,kubernetes
--company <slug> Company slug (from a result's company_slug).
--remote <mode> remote | hybrid | onsite (work_mode facet).
--facet <key=value> Any other facet param (repeatable), e.g. --facet salary_min=100000
DETAIL
<slug|url> A freehire public slug (from a search result's id/slug)
or a full https://freehire.dev/jobs/<slug> URL.
EXAMPLES
bun run src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
bun run src/cli.ts search -q "react" --remote remote --region eu --format table
bun run src/cli.ts search --category devops --country DE --jobage 14 --format table
bun run src/cli.ts detail golang-zensar-2bxu6dxm --format plain
Reads are public (no API key). Source: ${baseUrl()} — a personal project,
best-effort, no SLA. Override with FREEHIRE_API_URL to use a self-hosted backend.
`
function parseIntFlag(name: string, raw: string | boolean | string[]): number | null {
const val = parseInt(raw as string, 10)
if (isNaN(val)) {
process.stderr.write(JSON.stringify({ error: `--${name} must be a number, got "${raw}"`, code: "BAD_ARG" }) + "\n")
return null
}
return val
}
async function main(): Promise<number> {
const argv = process.argv.slice(2)
const flags = parseFlags(argv)
const cmd = (flags._ as string[])[0]
if (!cmd || flags.help || flags.h) {
process.stdout.write(HELP)
return cmd ? 0 : 1
}
if (cmd === "search") {
const fmt = (flags.format as string) || "json"
for (const name of ["jobage", "page", "limit"] as const) {
if (flags[name] !== undefined) {
const v = parseIntFlag(name, flags[name])
if (v === null) return 1
flags[name] = String(v)
}
}
// Generic --facet key=value list -> param -> values.
const facets: Record<string, string[]> = {}
const rawFacets = Array.isArray(flags.facet) ? flags.facet : []
for (const kv of rawFacets) {
const eq = kv.indexOf("=")
if (eq <= 0) {
process.stderr.write(JSON.stringify({ error: `invalid --facet "${kv}", want key=value`, code: "BAD_ARG" }) + "\n")
return 1
}
const key = kv.slice(0, eq)
const vals = commaList(kv.slice(eq + 1))
facets[key] = (facets[key] ?? []).concat(vals)
}
const opts: SearchOpts = {
query: stringFlag(flags.query),
jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999,
page: flags.page ? Math.max(1, parseInt(flags.page as string, 10)) : 1,
limit: flags.limit ? Math.max(1, parseInt(flags.limit as string, 10)) : 25,
format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"],
regions: commaList(flags.region),
countries: commaList(flags.country),
cities: commaList(flags.city),
seniority: commaList(flags.seniority),
category: commaList(flags.category),
skills: commaList(flags.skill),
company: stringFlag(flags.company),
// --remote <mode> takes the given work_mode; a bare --remote means "remote".
workMode: stringFlag(flags.remote, "remote"),
facets,
}
return runSearch(opts)
}
if (cmd === "detail") {
const id = (flags._ as string[])[1]
if (!id) {
process.stderr.write(JSON.stringify({ error: "detail requires a <slug|url>", code: "NO_ID" }) + "\n")
return 1
}
const fmt = (flags.format as string) || "json"
const opts: DetailOpts = { id, format: fmt === "plain" ? "plain" : "json" }
return runDetail(opts)
}
process.stderr.write(JSON.stringify({ error: `Unknown command "${cmd}"`, code: "BAD_CMD" }) + "\n")
return 1
}
main().then((code) => process.exit(code))
@@ -0,0 +1,65 @@
import { apiGet, normalizeSlug, toDetail, writeError, type FreehireJob, type JobDetailResult } from "../helpers.js"
export interface DetailOpts {
id: string // a freehire public slug or a /jobs/<slug> URL
format: "json" | "plain"
}
/** A human-readable rendering of one job: header, present fields, description. */
function renderPlain(job: JobDetailResult): string {
const lines = [job.title, `${job.company ?? "—"} · ${job.location ?? "—"}`]
const field = (label: string, value: string | null) => {
if (value) lines.push(`${label}: ${value}`)
}
field("Posted", job.date && job.date.slice(0, 10))
field("Seniority", job.seniority)
field("Category", job.category)
field("Employment", job.employment_type)
field("Salary", job.salary)
field("Skills", job.skills.length ? job.skills.join(", ") : null)
lines.push("", job.description ?? "(no description)", "", `URL: ${job.url}`, `slug: ${job.id}`)
return lines.join("\n")
}
export async function runDetail(opts: DetailOpts): Promise<number> {
const slug = normalizeSlug(opts.id)
if (!slug) {
writeError(`could not parse a freehire slug from "${opts.id}"`, "BAD_ID")
return 1
}
try {
const env = await apiGet<FreehireJob>(`/api/v1/jobs/${encodeURIComponent(slug)}`)
if (!env) {
writeError("job not found", "NOT_FOUND")
return 1
}
const job = toDetail(env.data)
if (opts.format === "plain") {
const lines = [
job.title,
`${job.company || "—"} · ${job.location || "—"}`,
job.date ? `Posted: ${job.date.slice(0, 10)}` : "",
job.seniority ? `Seniority: ${job.seniority}` : "",
job.category ? `Category: ${job.category}` : "",
job.employment_type ? `Employment: ${job.employment_type}` : "",
job.salary ? `Salary: ${job.salary}` : "",
job.skills.length ? `Skills: ${job.skills.join(", ")}` : "",
"",
job.description || "(no description)",
"",
`URL: ${job.url}`,
`slug: ${job.id}`,
].filter((l) => l !== "")
process.stdout.write(lines.join("\n") + "\n")
} else {
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "DETAIL_FAILED")
return 1
}
}
@@ -0,0 +1,117 @@
import { apiGet, toResult, writeError, type FreehireJob, type JobResult } from "../helpers.js"
export interface SearchOpts {
query?: string
jobage: number
page: number
limit: number
format: "json" | "table" | "plain"
// Facet filters (already parsed into value lists; empty means unset).
regions: string[]
countries: string[]
cities: string[]
seniority: string[]
category: string[]
skills: string[]
company?: string
workMode?: string // work_mode facet: remote | hybrid | onsite
// Arbitrary facet escape hatch: param -> values, for the long tail of the vocabulary.
facets: Record<string, string[]>
}
function buildQuery(opts: SearchOpts): URLSearchParams {
const p = new URLSearchParams()
if (opts.query) p.set("q", opts.query)
p.set("limit", String(opts.limit))
p.set("offset", String((opts.page - 1) * opts.limit))
p.set("semantic_ratio", "0") // keyword search; the semantic index is opt-in
if (opts.jobage > 0 && opts.jobage < 9999) p.set("posted_within_days", String(opts.jobage))
if (opts.workMode) p.set("work_mode", opts.workMode)
if (opts.company) p.set("company_slug", opts.company)
// Named facets and the generic --facet escape hatch append the same way; values
// are already split into lists, so each becomes one repeated query param.
const facets: Array<[string, string[]]> = [
["regions", opts.regions],
["countries", opts.countries],
["cities", opts.cities],
["seniority", opts.seniority],
["category", opts.category],
["skills", opts.skills],
...Object.entries(opts.facets),
]
for (const [param, values] of facets) {
for (const value of values) p.append(param, value)
}
return p
}
/** The date portion (YYYY-MM-DD) of an ISO timestamp, or "—" when absent. */
function shortDate(date: string | null): string {
return date ? date.slice(0, 10) : "—"
}
// Table columns: header, width, and the cell value. The SLUG column is sized to
// the longest slug so it is never truncated — a cut slug can't be looked up in
// `detail`; the fixed-width columns truncate for scanning.
interface Column {
header: string
width: number
cell: (r: JobResult) => string
}
function renderTable(rows: JobResult[]): string {
if (rows.length === 0) return "No results."
const columns: Column[] = [
{ header: "SLUG", width: Math.max(4, ...rows.map((r) => r.id.length)), cell: (r) => r.id },
{ header: "TITLE", width: 38, cell: (r) => r.title },
{ header: "COMPANY", width: 22, cell: (r) => r.company ?? "—" },
{ header: "LOCATION", width: 20, cell: (r) => r.location ?? "—" },
{ header: "DATE", width: 10, cell: (r) => shortDate(r.date) },
]
const row = (cells: string[]) => cells.map((c, i) => c.slice(0, columns[i].width).padEnd(columns[i].width)).join(" ")
const header = row(columns.map((c) => c.header))
const body = rows.map((r) => row(columns.map((c) => c.cell(r))))
return [header, "-".repeat(header.length), ...body].join("\n")
}
function renderPlain(rows: JobResult[]): string {
if (rows.length === 0) return "No results."
const block = (r: JobResult) =>
[
r.title,
` ${r.company ?? "—"} · ${r.location ?? "—"} · ${shortDate(r.date)}`,
` slug: ${r.id}`,
` ${r.url}`,
].join("\n")
return rows.map(block).join("\n\n")
}
export async function runSearch(opts: SearchOpts): Promise<number> {
try {
const env = await apiGet<FreehireJob[]>(`/api/v1/jobs/search?${buildQuery(opts).toString()}`)
// The search endpoint returns an envelope; a null (404) is treated as empty.
const jobs = env?.data ?? []
const rows = jobs.map(toResult)
const total = env?.meta?.total ?? rows.length
if (opts.format === "table") {
process.stdout.write(renderTable(rows) + "\n")
} else if (opts.format === "plain") {
process.stdout.write(renderPlain(rows) + "\n")
} else {
process.stdout.write(
JSON.stringify(
{ meta: { count: rows.length, page: opts.page, total }, results: rows },
null,
2,
) + "\n",
)
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED")
return 1
}
}
@@ -0,0 +1,223 @@
// 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(/&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
}