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,102 @@
import { describe, test, expect } from "bun:test";
import { cleanHtml, normalizeSlug, toResult, toDetail, type FreehireJob } from "../src/helpers";
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
return {
public_slug: "backend-engineer-acme-ab12cd34",
source: "greenhouse",
external_id: "acme:1",
url: "https://boards.greenhouse.io/acme/jobs/1",
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
description: "<ul><li>Build APIs</li><li>Ship &amp; iterate</li></ul>",
skills: ["go", "kubernetes"],
work_mode: "remote",
regions: ["eu"],
countries: ["de"],
cities: ["Berlin"],
posted_at: "2026-07-06T00:00:00Z",
created_at: "2026-07-06T15:00:00Z",
enrichment: {},
...overrides,
};
}
describe("toResult — reshape into the portal-skill contract", () => {
test("maps public_slug -> id and posted_at -> date", () => {
const r = toResult(job());
expect(r.id).toBe("backend-engineer-acme-ab12cd34");
expect(r.date).toBe("2026-07-06T00:00:00Z");
});
test("carries the required contract fields", () => {
const r = toResult(job());
expect(r).toMatchObject({
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
url: "https://boards.greenhouse.io/acme/jobs/1",
});
});
test("missing values are null, not omitted", () => {
const r = toResult(job({ company: "", location: "", posted_at: null, work_mode: undefined }));
expect(r.company).toBeNull();
expect(r.location).toBeNull();
expect(r.date).toBeNull();
expect(r.work_mode).toBeNull();
});
});
describe("toDetail — adds cleaned description + enrichment", () => {
test("strips HTML and decodes entities in the description", () => {
const d = toDetail(job());
expect(d.description).toBe("Build APIs\nShip & iterate");
});
test("surfaces enrichment fields and a formatted salary", () => {
const d = toDetail(
job({
enrichment: { seniority: "senior", category: "backend", employment_type: "full_time", salary_min: 90000, salary_max: 120000, salary_currency: "EUR" },
}),
);
expect(d.seniority).toBe("senior");
expect(d.category).toBe("backend");
expect(d.salary).toBe("EUR 90000120000");
});
test("null enrichment fields when the enrichment object is empty", () => {
const d = toDetail(job({ enrichment: {} }));
expect(d.seniority).toBeNull();
expect(d.salary).toBeNull();
});
});
describe("cleanHtml", () => {
test("preserves paragraph breaks between blocks", () => {
expect(cleanHtml("<p>One</p><p>Two</p>")).toBe("One\nTwo");
});
test("decodes hex numeric entities", () => {
expect(cleanHtml("Caf&#xE9;")).toBe("Café");
});
test("returns null for empty input", () => {
expect(cleanHtml("")).toBeNull();
expect(cleanHtml(null)).toBeNull();
});
});
describe("normalizeSlug", () => {
test("accepts a bare slug", () => {
expect(normalizeSlug("golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("extracts the slug from a /jobs/<slug> URL", () => {
expect(normalizeSlug("https://freehire.dev/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("rejects a non-slug string", () => {
expect(normalizeSlug("not a slug!")).toBeNull();
expect(normalizeSlug("")).toBeNull();
});
});