feat(freehire-search): search returns each hit's full description (#251)

The skill queried /api/v1/jobs/search, whose `description` is the search
index's truncated preview — and the CLI dropped it entirely, so a result
carried only title/company/location/date/url. Reading a posting therefore
meant a `detail` call per hit, which is exactly what job-scraper's Step 2
prescribes: "fetch full detail with that portal's `detail` command".

freehire exposes a search endpoint for programmatic consumers,
/api/v1/agent/jobs/search: same query, ranking, facets and pagination, but
asked to (`include_description=true`) it replaces the preview with the
posting's full description read from the database, rendered as
`description_format=markdown|text|html`. Reproduce the difference:

  curl -s "https://freehire.me/api/v1/jobs/search?q=golang&limit=1" \
    | jq -r '.data[0].description | length'          # preview, capped
  curl -s "https://freehire.me/api/v1/agent/jobs/search?q=golang&limit=1\
&include_description=true&description_format=markdown" \
    | jq -r '.data[0].description | length'          # full text

So `search` now calls that endpoint, always asking for full descriptions,
and each JSON result carries `description` verbatim — no client-side HTML
stripping, since the API already rendered it. Markdown is the default
because it preserves the headings and requirement lists /rank reasons over;
`--description-format text|html` selects the others. The flag is validated
client-side: the API answers an unrecognized format with raw HTML rather
than an error, so a typo would silently change the output instead of
failing.

`table` and `plain` stay description-free — a full posting body would swamp
a scannable list — and `detail` is untouched, for looking one posting up by
slug (including a closed one, absent from search).

One behaviour change beyond the endpoint: a 404 from the search path used
to be folded into an empty result set. On the agent endpoint a 404 means
the instance predates it — a self-hosted freehire behind FREEHIRE_API_URL —
so it is now reported as an error naming the path, instead of a plausible
"no results" that hides the misconfiguration.

Tests cover the requested URL and params, verbatim (unstripped) markdown,
the null-when-absent case, the 404-is-an-error contract, and the flag
validation. All network-free.
This commit is contained in:
Ilya Strelov
2026-07-28 21:19:24 +02:00
committed by GitHub
parent 1c74a57c5e
commit e3af401087
9 changed files with 220 additions and 22 deletions
+28 -5
View File
@@ -66,9 +66,10 @@ at the hosted API.
## When to use this skill
- Search for tech job openings by keyword, in a given region/country or remotely
- Search for tech job openings by keyword, in a given region/country or remotely
each result comes back with its **full description**, no per-hit follow-up needed
- Filter by seniority, category, skills, or recency (posted within N days)
- Get the full description of a specific freehire posting by its slug
- Look one freehire posting up by its slug (including a closed one)
## Commands
@@ -84,6 +85,17 @@ Key flags:
- `--page <n>` — 1-indexed page. Default 1.
- `--limit <n>` / `-n <n>` — results per page (API limit). Default 25.
- `--format json|table|plain` — default `json`.
- `--description-format markdown|text|html` — how each result's full description is
rendered. Default `markdown`, which keeps the posting's headings and requirement
lists intact. `json` output only.
**Search results already carry the full description.** This skill queries freehire's
agent search endpoint, which replaces the index's truncated preview with each
posting's complete text, so a search of 20 roles is 1 request rather than 1 + 20.
Do **not** loop `detail` over search hits to read their descriptions — reach for
`detail` only to look one posting up by slug (e.g. from the tracker, or a posting
already closed and therefore absent from search). Full descriptions are verbose:
keep `--limit` modest, and pre-filter on title/company before reading bodies.
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).
@@ -113,6 +125,10 @@ also pass a full `https://freehire.me/jobs/<slug>` URL. Returns the full (HTML-s
description, skills, region/country, and — when the posting is enriched — seniority,
category, employment type, and salary.
Use it for a posting you already have a slug for — a tracked application, a shared
link, or a closed posting search no longer lists. Re-fetching a hit that `search`
just returned only re-reads a description you already have.
## Usage examples
```bash
@@ -128,6 +144,9 @@ bun run .agents/skills/freehire-search/cli/src/cli.ts search --category devops -
# 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
# Descriptions as plain text instead of Markdown
bun run .agents/skills/freehire-search/cli/src/cli.ts search -q "platform engineer" --limit 5 --description-format text
# Full details for a specific job
bun run .agents/skills/freehire-search/cli/src/cli.ts detail golang-zensar-2bxu6dxm --format plain
```
@@ -136,14 +155,15 @@ bun run .agents/skills/freehire-search/cli/src/cli.ts detail golang-zensar-2bxu6
| Format | Best for |
|--------|----------|
| `json` | Default — programmatic use, passing a result's `id` (slug) to `detail` |
| `json` | Default — programmatic use; the only format carrying each hit's description |
| `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`.
`date`, `url`, and `description` (missing values are `null`). `table` and `plain`
omit the description — it would swamp a scannable list. All errors are written to
**stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
## Partial data
@@ -172,3 +192,6 @@ dictionaries never guess). So:
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).
- `search` calls `/api/v1/agent/jobs/search` (public, like the rest). A self-hosted
instance older than that endpoint answers 404, and the CLI reports it as an error
naming the endpoint — never as an empty result set.