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
+16 -1
View File
@@ -7,7 +7,7 @@
// freehire.me — 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 { runSearch, DESCRIPTION_FORMATS, type DescriptionFormat, type SearchOpts } from "./commands/search.js"
import { runDetail, type DetailOpts } from "./commands/detail.js"
import { baseUrl } from "./helpers.js"
@@ -81,6 +81,8 @@ SEARCH FLAGS
--page <n> 1-indexed page. Default 1.
--limit, -n <n> Results per page (API limit). Default 25.
--format <fmt> json (default) | table | plain.
--description-format markdown (default) | text | html — how each result's
full description is rendered (json output only).
FACET FILTERS (values from freehire.me's controlled vocabularies; comma = OR)
--region <codes> Macro-region: global, eu, us, apac, latam, cis, ... e.g. --region eu,us
@@ -129,6 +131,18 @@ async function main(): Promise<number> {
if (cmd === "search") {
const fmt = (flags.format as string) || "json"
// Validated here rather than server-side: the API answers an unrecognized
// format with raw HTML instead of an error, so a typo would silently change
// the output rather than fail.
const descFmt = stringFlag(flags["description-format"]) ?? "markdown"
if (!DESCRIPTION_FORMATS.includes(descFmt as DescriptionFormat)) {
const supported = DESCRIPTION_FORMATS.join("|")
process.stderr.write(
JSON.stringify({ error: `--description-format must be one of ${supported}, got "${descFmt}"`, code: "BAD_ARG" }) + "\n",
)
return 1
}
for (const name of ["jobage", "page", "limit"] as const) {
if (flags[name] !== undefined) {
const v = parseIntFlag(name, flags[name])
@@ -157,6 +171,7 @@ async function main(): Promise<number> {
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"],
descriptionFormat: descFmt as DescriptionFormat,
regions: commaList(flags.region),
countries: commaList(flags.country),
cities: commaList(flags.city),
@@ -1,11 +1,23 @@
import { apiGet, toResult, writeError, type FreehireJob, type JobResult } from "../helpers.js"
// The agent variant of the job search: the same query, ranking, and facets as the
// web's /jobs/search, but each hit carries the posting's full description instead
// of the search index's truncated preview — so a run reads every result without a
// follow-up `detail` per hit.
const SEARCH_PATH = "/api/v1/agent/jobs/search"
/** How the API renders each result's full description. */
export type DescriptionFormat = "markdown" | "text" | "html"
export const DESCRIPTION_FORMATS: DescriptionFormat[] = ["markdown", "text", "html"]
export interface SearchOpts {
query?: string
jobage: number
page: number
limit: number
format: "json" | "table" | "plain"
descriptionFormat: DescriptionFormat
// Facet filters (already parsed into value lists; empty means unset).
regions: string[]
countries: string[]
@@ -25,6 +37,10 @@ function buildQuery(opts: SearchOpts): URLSearchParams {
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
// The agent endpoint serves the index's truncated preview unless asked to
// rehydrate each hit from the database, so both params travel together.
p.set("include_description", "true")
p.set("description_format", opts.descriptionFormat)
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)
@@ -90,11 +106,19 @@ function renderPlain(rows: JobResult[]): string {
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
const env = await apiGet<FreehireJob[]>(`${SEARCH_PATH}?${buildQuery(opts).toString()}`)
// A 404 here is a missing endpoint, not a missing job: a freehire instance
// older than the agent search surface answers that way, and reporting it as
// an empty result set would hide the misconfiguration behind plausible output.
if (!env) {
writeError(
`${SEARCH_PATH} not found — this freehire instance predates the agent search endpoint; upgrade it or unset FREEHIRE_API_URL to use the hosted API`,
"SEARCH_FAILED",
)
return 1
}
const rows = (env.data ?? []).map(toResult)
const total = env.meta?.total ?? rows.length
if (opts.format === "table") {
process.stdout.write(renderTable(rows) + "\n")
@@ -114,6 +114,10 @@ export interface FreehireJob {
* 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.
*
* `description` is the posting's full text in the format the search asked the API
* for — the agent search endpoint hydrates it server-side, so it arrives already
* rendered and is passed through verbatim rather than run through `cleanHtml`.
*/
export interface JobResult {
id: string
@@ -127,6 +131,7 @@ export interface JobResult {
regions: string[]
countries: string[]
skills: string[]
description: string | null
}
/** A job detail: the search result plus the cleaned description and enrichment. */
@@ -153,6 +158,7 @@ export function toResult(j: FreehireJob): JobResult {
regions: j.regions,
countries: j.countries,
skills: j.skills,
description: j.description || null,
}
}