mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user