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,173 @@
|
||||
---
|
||||
name: freehire-search
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Use this skill to search live software / tech / data / engineering job listings
|
||||
across many countries and markets (and remote) via the freehire.dev aggregator's
|
||||
public API, or to look up a specific posting. It aggregates roles from ~50 ATS
|
||||
platforms into one schema, so a single skill covers many markets — but its faceted
|
||||
filtering (skills, category, seniority) is tuned tech-first, so scope triggers to
|
||||
technical roles. Trigger phrases: find a tech job, software job search, developer
|
||||
jobs, engineering vacancies, data/ML jobs, DevOps roles, remote developer jobs,
|
||||
"are there any <tech role> jobs in <place>", look up this freehire job posting.
|
||||
context: fork
|
||||
allowed-tools: Bash(bun run .agents/skills/freehire-search/cli/src/cli.ts *)
|
||||
---
|
||||
|
||||
# freehire Search Skill
|
||||
|
||||
Search live job listings from the **[freehire.dev](https://freehire.dev)** job
|
||||
aggregator — an open-source IT job board that normalizes postings from ~50 ATS
|
||||
platforms across many countries into one schema. No authentication, no API key,
|
||||
and **zero runtime dependencies** — it runs with just `bun`. The market is chosen
|
||||
per query via facet flags (`--region`, `--country`), so the same skill works for a
|
||||
forker in any market out of the box.
|
||||
|
||||
> This is a country-agnostic worked example of the repo's job-portal-skill pattern,
|
||||
> like `linkedin-search`. Unlike the HTML-scraping portals, it queries freehire's
|
||||
> public JSON API, so results are structured (skills, seniority, region facets)
|
||||
> rather than parsed from markup.
|
||||
|
||||
## ⚠️ Scope: tech-focused
|
||||
|
||||
freehire's corpus already includes some non-tech postings (it crawls whole company
|
||||
career pages), **but its faceted filtering — skills, categories, and seniority
|
||||
dictionaries — is tuned tech-first today**, so this skill scopes its triggers to
|
||||
software / data / engineering / tech roles, where the filtering is strong. Non-tech
|
||||
coverage exists but is still maturing; don't rely on this skill for general
|
||||
(non-technical) job coverage yet.
|
||||
|
||||
## ℹ️ Hosted-service dependency (best-effort, no SLA)
|
||||
|
||||
This skill depends on a third-party hosted service, freehire.dev. Reads are
|
||||
**public and unauthenticated** — the same zero-signup bar as `linkedin-search`.
|
||||
|
||||
**freehire.dev is a personal project but actively maintained; it runs on a
|
||||
best-effort basis (no formal SLA).** If the API is unreachable, the CLI fails
|
||||
gracefully — a non-zero exit with a clear error message — so an outage degrades
|
||||
this source rather than breaking the surrounding workflow.
|
||||
|
||||
**Self-hosting / swappable base URL.** The freehire backend is a separate
|
||||
MIT-licensed repo — [`strelov1/freehire`](https://github.com/strelov1/freehire)
|
||||
(Go + PostgreSQL + Meilisearch) — that stands up with one command via Docker
|
||||
Compose (`make up` → API on `:8080`, same `/api/v1/...` paths). The skill honors a
|
||||
base-URL env var, `FREEHIRE_API_URL` (default `https://freehire.dev`), so pointing
|
||||
it at a local instance is a one-line change:
|
||||
|
||||
```bash
|
||||
FREEHIRE_API_URL=http://localhost:8080 bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "go"
|
||||
```
|
||||
|
||||
Caveat: standing up the *API* is light, but keeping a *full, continuously-fresh*
|
||||
mirror (millions of postings across ~50 platforms) is resource-heavy — a
|
||||
self-hoster would either crawl a scoped subset of sources or point the env var back
|
||||
at the hosted API.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- Search for tech job openings by keyword, in a given region/country or remotely
|
||||
- Filter by seniority, category, skills, or recency (posted within N days)
|
||||
- Get the full description of a specific freehire posting by its slug
|
||||
|
||||
## Commands
|
||||
|
||||
### Search job listings
|
||||
|
||||
```bash
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts search [-q "<keywords>"] [facet flags]
|
||||
```
|
||||
|
||||
Key flags:
|
||||
- `--query <text>` / `-q <text>` — keyword search (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 <n>` — results per page (API limit). Default 25.
|
||||
- `--format json|table|plain` — default `json`.
|
||||
|
||||
Facet filters (values come from freehire's controlled vocabularies; comma-separate for OR within a facet):
|
||||
- `--region <codes>` — macro-region, e.g. `global`, `eu`, `us`, `apac`, `latam`, `cis`. `--region eu,us`. Use `none` to match jobs whose region could **not** be resolved (see "Partial data" below).
|
||||
- `--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`
|
||||
|
||||
> **Location is a facet, not free text.** Unlike `linkedin-search`'s `--location`,
|
||||
> freehire filters geography through the structured `--region`/`--country`/`--city`
|
||||
> facets. Discover the live values for a market at
|
||||
> [`/api/v1/jobs/facets`](https://freehire.dev/api/v1/jobs/facets) (append `?q=<role>`
|
||||
> to scope it) — never invent facet values.
|
||||
|
||||
### Fetch full job detail
|
||||
|
||||
```bash
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts detail <slug|url> [--format json|plain]
|
||||
```
|
||||
|
||||
`slug` is the `id` from a `search` result (e.g. `golang-zensar-2bxu6dxm`). You may
|
||||
also pass a full `https://freehire.dev/jobs/<slug>` URL. Returns the full (HTML-stripped)
|
||||
description, skills, region/country, and — when the posting is enriched — seniority,
|
||||
category, employment type, and salary.
|
||||
|
||||
## Usage examples
|
||||
|
||||
```bash
|
||||
# Senior backend roles, table view
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
|
||||
|
||||
# Remote React roles in the EU
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "react" --remote remote --region eu --format table
|
||||
|
||||
# DevOps roles in Germany posted in the last 14 days
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts search --category devops --country DE --jobage 14 --format table
|
||||
|
||||
# ML/AI roles anywhere, fully remote
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "machine learning" --category ml_ai --remote remote --format table
|
||||
|
||||
# Full details for a specific job
|
||||
bun run .agents/skills/freehire-search/cli/src/cli.ts detail golang-zensar-2bxu6dxm --format plain
|
||||
```
|
||||
|
||||
## Output formats
|
||||
|
||||
| Format | Best for |
|
||||
|--------|----------|
|
||||
| `json` | Default — programmatic use, passing a result's `id` (slug) to `detail` |
|
||||
| `table` | Quick human-readable scanning |
|
||||
| `plain` | Reading a single job's full detail (`detail` command) |
|
||||
|
||||
Search JSON is `{ "meta": { "count", "page", "total" }, "results": [...] }`; each
|
||||
result carries at least `id` (the freehire slug), `title`, `company`, `location`,
|
||||
`date`, and `url` (missing values are `null`). All errors are written to **stderr**
|
||||
as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||
|
||||
## Partial data
|
||||
|
||||
Facets are derived per-posting and can be **incomplete** — geography especially.
|
||||
A job may resolve its `work_mode` (e.g. `remote`) but leave its **region or
|
||||
country undetermined** when the source's location text is ambiguous (freehire's
|
||||
dictionaries never guess). So:
|
||||
|
||||
- A missing region/country means "not resolved", **not** "not applicable" —
|
||||
filtering on `--region eu` silently drops jobs whose region wasn't resolved,
|
||||
even if they are in fact EU. Widen or drop the facet if you need those back.
|
||||
- There is a dedicated facet value for the unresolved bucket: `--region none`
|
||||
matches jobs with **no** resolved region — useful to sweep up remote roles that
|
||||
never pinned a geography. It ORs with real regions, e.g. `--region eu,none`.
|
||||
- `result.regions` / `countries` / `cities` may be empty arrays for the same
|
||||
reason; treat empty as unknown, not as "none of the above".
|
||||
|
||||
## Notes
|
||||
|
||||
- Data is from freehire.dev's public API — no credentials required. Only per-user
|
||||
tracking (apply/save) needs a key, and this skill deliberately does not touch it:
|
||||
it is **search + detail only**.
|
||||
- `id` in search results is the freehire `public_slug` — pass it as-is to `detail`.
|
||||
- `date` is the posting date (`posted_at`); it may be `null` for undated postings.
|
||||
- Facet values are controlled vocabularies. Use `/api/v1/jobs/facets` to see the
|
||||
live values (with counts) for a query before filtering.
|
||||
- The API retries 429/5xx with exponential backoff; an unreachable API exits
|
||||
non-zero with a clear message (best-effort service, see the dependency note above).
|
||||
@@ -0,0 +1,87 @@
|
||||
# freehire-cli
|
||||
|
||||
CLI for searching the [freehire.dev](https://freehire.dev) job aggregator across
|
||||
**many markets** (tech-focused), via its public JSON API.
|
||||
|
||||
**Data source**: freehire.dev REST API (`/api/v1/jobs/search`, `/api/v1/jobs/facets`, `/api/v1/jobs/{slug}`).
|
||||
**Authentication**: None required — reads are public (only tracking mutations need a key, and those are out of scope here).
|
||||
**Dependencies**: None (plain `bun` + `fetch`). `bun install` is optional and only pulls dev type defs.
|
||||
|
||||
> **Hosted-service dependency.** This skill talks to freehire.dev, a personal
|
||||
> project maintained on a **best-effort basis with no formal SLA**. If the API is
|
||||
> unreachable the CLI exits non-zero with a clear error rather than hanging, so an
|
||||
> outage degrades gracefully instead of breaking the caller. Point `FREEHIRE_API_URL`
|
||||
> at a self-hosted [freehire](https://github.com/strelov1/freehire) backend to swap
|
||||
> the source.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd .agents/skills/freehire-search/cli
|
||||
bun install # optional — only installs TypeScript dev types
|
||||
```
|
||||
|
||||
The CLI runs without any install because it has zero runtime dependencies.
|
||||
|
||||
## Self-hosting / base URL
|
||||
|
||||
The base URL defaults to `https://freehire.dev` and is overridable with an env var:
|
||||
|
||||
```bash
|
||||
FREEHIRE_API_URL=http://localhost:8080 bun run src/cli.ts search -q "go"
|
||||
```
|
||||
|
||||
The freehire backend is MIT-licensed and stands up with one command via Docker
|
||||
Compose (`make up` → API on `:8080`, same `/api/v1/...` paths).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search` | Search jobs by keyword and facet filters |
|
||||
| `detail` | Fetch full detail for a single job by its slug |
|
||||
|
||||
`search` accepts `--format json|table|plain` (default `json`); `detail` accepts `--format json|plain`.
|
||||
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` with exit code `1`.
|
||||
|
||||
## Quick examples
|
||||
|
||||
```bash
|
||||
# Senior backend roles, table view
|
||||
bun run src/cli.ts search -q "backend engineer" --seniority senior --limit 10 --format table
|
||||
|
||||
# Remote React roles in the EU
|
||||
bun run src/cli.ts search -q "react" --remote remote --region eu --format table
|
||||
|
||||
# DevOps roles in Germany posted in the last 14 days
|
||||
bun run src/cli.ts search --category devops --country DE --jobage 14 --format table
|
||||
|
||||
# Full detail for one job (slug from a search result's id)
|
||||
bun run src/cli.ts detail golang-zensar-2bxu6dxm --format plain
|
||||
```
|
||||
|
||||
See `../SKILL.md` for the full flag reference and the hosted-dependency note.
|
||||
|
||||
## Search flags
|
||||
|
||||
| Flag | Alias | Description |
|
||||
|------|-------|-------------|
|
||||
| `--query` | `-q` | Keywords (title / skill / role). Full-text; optional. |
|
||||
| `--jobage` | | Posted within N days (`posted_within_days`). |
|
||||
| `--page` | | 1-indexed page. Default 1. |
|
||||
| `--limit` | `-n` | Results per page (API limit). Default 25. |
|
||||
| `--region` | | Macro-region(s), comma = OR (e.g. `eu,us`). |
|
||||
| `--country` | | ISO-3166 alpha-2 code(s). |
|
||||
| `--city` | | City name(s). |
|
||||
| `--seniority` | | Seniority level(s). |
|
||||
| `--category` | | Role category(ies). |
|
||||
| `--skill` | | Canonical skill(s). |
|
||||
| `--company` | | Company slug. |
|
||||
| `--remote` | | `remote` \| `hybrid` \| `onsite` (`work_mode`). |
|
||||
| `--facet` | | Any other facet as `key=value` (repeatable). |
|
||||
| `--format` | | `json` \| `table` \| `plain`. |
|
||||
|
||||
Facet values come from freehire's controlled vocabularies. Discover the live
|
||||
values (with counts) for a market at
|
||||
[`/api/v1/jobs/facets`](https://freehire.dev/api/v1/jobs/facets), or narrow it,
|
||||
e.g. `https://freehire.dev/api/v1/jobs/facets?q=react`.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "freehire-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI for searching the freehire.dev job aggregator's public JSON API across many markets (tech-focused) — no authentication, zero runtime dependencies. Base URL is swappable via FREEHIRE_API_URL for self-hosting.",
|
||||
"type": "module",
|
||||
"main": "src/cli.ts",
|
||||
"bin": {
|
||||
"freehire-search": "src/cli.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/cli.ts",
|
||||
"test": "bun test --timeout 30000",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
|
||||
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
|
||||
.replace(/ /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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { runCLI } from "./helpers";
|
||||
|
||||
// These assert on validation error codes that are emitted BEFORE any network
|
||||
// call (or independently of it), so the suite is network-free: a valid-flag case
|
||||
// still runs offline because it only checks the ABSENCE of a validation error.
|
||||
|
||||
function parsedStderr(stderr: string): { error?: string; code?: string } {
|
||||
try {
|
||||
return JSON.parse(stderr);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
describe("freehire CLI flag validation", () => {
|
||||
describe("numeric flag validation", () => {
|
||||
for (const name of ["jobage", "page", "limit"]) {
|
||||
test(`--${name} non-numeric exits 1 with BAD_ARG`, async () => {
|
||||
const result = await runCLI(["search", `--${name}`, "foo"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const err = parsedStderr(result.stderr);
|
||||
expect(err.code).toBe("BAD_ARG");
|
||||
expect(err.error).toMatch(new RegExp(name));
|
||||
});
|
||||
}
|
||||
|
||||
test("valid integers produce no BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]);
|
||||
expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("--facet validation", () => {
|
||||
test("a facet without '=' exits 1 with BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--facet", "novalue"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detail argument validation", () => {
|
||||
test("missing slug exits 1 with NO_ID", async () => {
|
||||
const result = await runCLI(["detail"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("NO_ID");
|
||||
});
|
||||
|
||||
test("an unparseable slug exits 1 with BAD_ID (no network)", async () => {
|
||||
const result = await runCLI(["detail", "not a slug!"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("BAD_ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("command dispatch", () => {
|
||||
test("unknown command exits 1 with BAD_CMD", async () => {
|
||||
const result = await runCLI(["frobnicate"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("BAD_CMD");
|
||||
});
|
||||
|
||||
test("no command prints help and exits 1", async () => {
|
||||
const result = await runCLI([]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toMatch(/USAGE/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { runSearch } from "../src/commands/search";
|
||||
import { runDetail } from "../src/commands/detail";
|
||||
import type { FreehireJob } from "../src/helpers";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
|
||||
function captureStdout(): { get: () => string } {
|
||||
let buf = "";
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
buf += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
return { get: () => buf };
|
||||
}
|
||||
|
||||
function mockFetch(status: number, body: unknown): void {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
}
|
||||
|
||||
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: "<p>Build things</p>",
|
||||
skills: ["go"],
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
});
|
||||
|
||||
const searchOpts = {
|
||||
jobage: 9999,
|
||||
page: 1,
|
||||
limit: 25,
|
||||
format: "json" as const,
|
||||
regions: [] as string[],
|
||||
countries: [] as string[],
|
||||
cities: [] as string[],
|
||||
seniority: [] as string[],
|
||||
category: [] as string[],
|
||||
skills: [] as string[],
|
||||
facets: {} as Record<string, string[]>,
|
||||
};
|
||||
|
||||
describe("runSearch (mocked fetch)", () => {
|
||||
test("emits the contract envelope with meta.count/page/total", async () => {
|
||||
mockFetch(200, { data: [job()], meta: { total: 42, limit: 25, offset: 0 } });
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
expect(code).toBe(0);
|
||||
|
||||
const parsed = JSON.parse(out.get());
|
||||
expect(parsed.meta).toEqual({ count: 1, page: 1, total: 42 });
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].id).toBe("backend-engineer-acme-ab12cd34");
|
||||
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
|
||||
});
|
||||
|
||||
test("empty result set yields an empty results array", async () => {
|
||||
mockFetch(200, { data: [], meta: { total: 0 } });
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "nothing-matches-xyz" });
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(out.get()).results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("network failure exits 1 with SEARCH_FAILED", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as typeof fetch;
|
||||
let err = "";
|
||||
const origErr = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
err += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
process.stderr.write = origErr;
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(JSON.parse(err).code).toBe("SEARCH_FAILED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDetail (mocked fetch)", () => {
|
||||
test("prints the reshaped detail with a cleaned description", async () => {
|
||||
mockFetch(200, { data: job() });
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runDetail({ id: "backend-engineer-acme-ab12cd34", format: "json" });
|
||||
expect(code).toBe(0);
|
||||
|
||||
const parsed = JSON.parse(out.get());
|
||||
expect(parsed.id).toBe("backend-engineer-acme-ab12cd34");
|
||||
expect(parsed.description).toBe("Build things");
|
||||
expect(parsed.cities).toEqual(["Berlin"]);
|
||||
});
|
||||
|
||||
test("404 exits 1 with NOT_FOUND", async () => {
|
||||
mockFetch(404, { error: "not found" });
|
||||
let err = "";
|
||||
const origErr = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
err += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
const code = await runDetail({ id: "does-not-exist", format: "json" });
|
||||
process.stderr.write = origErr;
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(JSON.parse(err).code).toBe("NOT_FOUND");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { join } from "path";
|
||||
|
||||
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
|
||||
|
||||
export interface CLIResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export async function runCLI(args: string[], env: Record<string, string> = {}): Promise<CLIResult> {
|
||||
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
||||
}
|
||||
|
||||
export function parseJSON<T = unknown>(result: CLIResult): T {
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 90000–120000");
|
||||
});
|
||||
|
||||
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é")).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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# freehire.dev API reference
|
||||
|
||||
The endpoints, parameters, and response shapes this skill depends on. This is the
|
||||
file to update if the freehire API changes. Base URL defaults to
|
||||
`https://freehire.dev` and is overridable via the `FREEHIRE_API_URL` env var.
|
||||
|
||||
## Authentication
|
||||
|
||||
None for reads. `GET /api/v1/jobs/*` and `/companies/*` are public; only per-user
|
||||
tracking mutations (`apply`/`save`/`me`) require a bearer API key, and this skill
|
||||
does not use them.
|
||||
|
||||
Verified against the live API:
|
||||
|
||||
| Endpoint | Status |
|
||||
|----------|--------|
|
||||
| `GET /api/v1/jobs/search` | 200 |
|
||||
| `GET /api/v1/jobs/facets` | 200 |
|
||||
| `GET /api/v1/jobs/{slug}` | 200 |
|
||||
| `GET /api/v1/auth/me` | 401 (auth required — not used here) |
|
||||
|
||||
## Envelope
|
||||
|
||||
Every response is `{ "data": ..., "meta": {...}, "error": "..." }`. Lists put the
|
||||
array in `data` and pagination in `meta` (`{ total, limit, offset }`); a single
|
||||
item puts the object in `data`. Errors are `{ "error": "<message>" }` with a 4xx/5xx
|
||||
status (e.g. 404 → `{ "error": "not found" }`).
|
||||
|
||||
## `GET /api/v1/jobs/search`
|
||||
|
||||
Full-text + facet search over open jobs. Returns `data: [job, …]` with
|
||||
`meta.total` = the total match count.
|
||||
|
||||
Query parameters used by the skill:
|
||||
|
||||
| Param | Maps to CLI flag | Notes |
|
||||
|-------|------------------|-------|
|
||||
| `q` | `--query` / `-q` | Keyword full-text query. |
|
||||
| `limit` | `--limit` / `-n` | Page size. Default 25 in the CLI. |
|
||||
| `offset` | (derived) | `offset = (page - 1) * limit`. |
|
||||
| `semantic_ratio` | (fixed `0`) | Keyword search; the semantic index is opt-in. |
|
||||
| `posted_within_days` | `--jobage` | Restrict to postings from the last N days. |
|
||||
| `regions` | `--region` | Repeatable; OR within the facet. Values like `global`, `eu`, `us`, `apac`, `latam`, `cis`. |
|
||||
| `countries` | `--country` | Repeatable; ISO-3166 alpha-2 (lowercased). |
|
||||
| `cities` | `--city` | Repeatable; display-name city. |
|
||||
| `seniority` | `--seniority` | Repeatable; `junior`, `middle`, `senior`, `staff`, … |
|
||||
| `category` | `--category` | Repeatable; `backend`, `frontend`, `fullstack`, `devops`, `ml_ai`, … |
|
||||
| `skills` | `--skill` | Repeatable; canonical skill names. |
|
||||
| `company_slug` | `--company` | Single company. |
|
||||
| `work_mode` | `--remote` | `remote` \| `hybrid` \| `onsite`. |
|
||||
| any facet param | `--facet key=value` | Escape hatch for the long tail (e.g. `salary_min`, `visa_sponsorship`, `employment_type`, `english_level`). |
|
||||
|
||||
Repeated params (`?seniority=senior&seniority=staff`) are ORed within a facet;
|
||||
different facets are ANDed (geography ORs into one location group). Deep paging is
|
||||
bounded server-side (`offset + limit ≤ 10000`).
|
||||
|
||||
### Job object (the fields the skill reads)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"public_slug": "golang-zensar-2bxu6dxm", // -> result.id, and detail's <slug>
|
||||
"source": "oracle",
|
||||
"external_id": "…",
|
||||
"url": "https://…", // the real posting URL (ATS host)
|
||||
"title": "GOLANG",
|
||||
"company": "Zensar",
|
||||
"company_slug": "zensar",
|
||||
"location": "India", // free-text ATS location
|
||||
"description": "<ul><li>…</li></ul>", // HTML; the skill strips it for detail
|
||||
"skills": ["go", "kubernetes", …], // dictionary facet (top-level)
|
||||
"work_mode": "remote", // may be absent
|
||||
"regions": ["apac"], // dictionary/hybrid facet
|
||||
"countries": ["in"],
|
||||
"cities": [],
|
||||
"collections": [],
|
||||
"posted_at": "2026-07-06T00:00:00Z", // -> result.date (nullable)
|
||||
"created_at": "2026-07-06T15:25:…Z",
|
||||
"enrichment": { // nested, typed; {} when unenriched
|
||||
"seniority": "senior",
|
||||
"category": "backend",
|
||||
"employment_type": "full_time",
|
||||
"salary_min": 90000, "salary_max": 120000, "salary_currency": "EUR"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The internal numeric id is deliberately never exposed; `public_slug` is the stable
|
||||
identifier.
|
||||
|
||||
## `GET /api/v1/jobs/{slug}`
|
||||
|
||||
A single job by its `public_slug`. Returns the same job object in `data`. A closed
|
||||
posting is still served here (with a non-null `closed_at`); a missing slug is 404
|
||||
`{ "error": "not found" }`. The skill's `detail` command maps a 404 to a
|
||||
`NOT_FOUND` error on stderr.
|
||||
|
||||
## `GET /api/v1/jobs/facets`
|
||||
|
||||
The market's facet-value distributions under an optional filter — each facet's live
|
||||
values with counts. `data.facets` is `{ <facet>: { <value>: <count> } }`. This skill
|
||||
does not call it programmatically, but it is the vocabulary source the SKILL.md
|
||||
points users to (`?q=<role>` scopes the counts). Example:
|
||||
`GET /api/v1/jobs/facets?q=react`.
|
||||
|
||||
## Parsing notes
|
||||
|
||||
- The response is JSON, so there is no HTML card parsing (unlike the scraping
|
||||
portals). The only markup handling is stripping the `description`'s HTML into
|
||||
readable text (`cleanHtml` in `cli/src/helpers.ts`).
|
||||
- Fetch uses a browser-ish User-Agent, `Accept: application/json`, and exponential
|
||||
backoff with jitter on 429/5xx (max 6 retries). A connection error (API
|
||||
unreachable) fails fast with a clear message — no retry, since it is not
|
||||
transient server load — which is the graceful-degradation contract: an outage
|
||||
degrades this source quickly instead of hanging the caller.
|
||||
@@ -126,6 +126,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tool:
|
||||
- freehire-search
|
||||
- jobbank-search
|
||||
- jobdanmark-search
|
||||
- jobindex-search
|
||||
|
||||
Reference in New Issue
Block a user