mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user