mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
CLI for searching the [freehire.me](https://freehire.me) job aggregator across
|
||||
**many markets** (tech-focused), via its public JSON API.
|
||||
|
||||
**Data source**: freehire.me REST API (`/api/v1/jobs/search`, `/api/v1/jobs/facets`, `/api/v1/jobs/{slug}`).
|
||||
**Data source**: freehire.me REST API (`/api/v1/agent/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.
|
||||
|
||||
@@ -44,6 +44,11 @@ Compose (`make up` → API on `:8080`, same `/api/v1/...` paths).
|
||||
`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`.
|
||||
|
||||
`search` hits the API's agent endpoint, so every JSON result already carries the
|
||||
posting's **full** description (Markdown by default, `--description-format
|
||||
text|html` to change it). `detail` remains for looking a single posting up by
|
||||
slug — including a closed one, which search does not return.
|
||||
|
||||
## Quick examples
|
||||
|
||||
```bash
|
||||
@@ -80,6 +85,7 @@ See `../SKILL.md` for the full flag reference and the hosted-dependency note.
|
||||
| `--remote` | | `remote` \| `hybrid` \| `onsite` (`work_mode`). |
|
||||
| `--facet` | | Any other facet as `key=value` (repeatable). |
|
||||
| `--format` | | `json` \| `table` \| `plain`. |
|
||||
| `--description-format` | | `markdown` (default) \| `text` \| `html` — how each result's full description is rendered (`json` output only). |
|
||||
|
||||
Facet values come from freehire's controlled vocabularies. Discover the live
|
||||
values (with counts) for a market at
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ describe("freehire CLI flag validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("--description-format validation", () => {
|
||||
test("an unsupported format exits 1 with BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--description-format", "tekst"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const err = parsedStderr(result.stderr);
|
||||
expect(err.code).toBe("BAD_ARG");
|
||||
expect(err.error).toMatch(/description-format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--facet validation", () => {
|
||||
test("a facet without '=' exits 1 with BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--facet", "novalue"]);
|
||||
|
||||
@@ -15,12 +15,32 @@ function captureStdout(): { get: () => string } {
|
||||
return { get: () => buf };
|
||||
}
|
||||
|
||||
function mockFetch(status: number, body: unknown): void {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
/** Stub fetch with a canned response; the return value exposes the URL it was called with. */
|
||||
function mockFetch(status: number, body: unknown): { url: () => string } {
|
||||
let requested = "";
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
});
|
||||
}) as typeof fetch;
|
||||
return { url: () => requested };
|
||||
}
|
||||
|
||||
/** The query params of the URL the mocked fetch was called with. */
|
||||
function requestedParams(mock: { url: () => string }): URLSearchParams {
|
||||
return new URL(mock.url()).searchParams;
|
||||
}
|
||||
|
||||
function captureStderr(): { get: () => string; restore: () => void } {
|
||||
let buf = "";
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
buf += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
return { get: () => buf, restore: () => (process.stderr.write = original) };
|
||||
}
|
||||
|
||||
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
|
||||
@@ -56,6 +76,7 @@ const searchOpts = {
|
||||
page: 1,
|
||||
limit: 25,
|
||||
format: "json" as const,
|
||||
descriptionFormat: "markdown" as const,
|
||||
regions: [] as string[],
|
||||
countries: [] as string[],
|
||||
cities: [] as string[],
|
||||
@@ -80,6 +101,61 @@ describe("runSearch (mocked fetch)", () => {
|
||||
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
|
||||
});
|
||||
|
||||
test("queries the agent endpoint asking for full descriptions", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(new URL(mock.url()).pathname).toBe("/api/v1/agent/jobs/search");
|
||||
expect(requestedParams(mock).get("include_description")).toBe("true");
|
||||
expect(requestedParams(mock).get("description_format")).toBe("markdown");
|
||||
});
|
||||
|
||||
test("asks for the requested description format", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, descriptionFormat: "text", query: "backend" });
|
||||
|
||||
expect(requestedParams(mock).get("description_format")).toBe("text");
|
||||
});
|
||||
|
||||
test("carries each hit's description verbatim, in the server's format", async () => {
|
||||
const markdown = "## About the role\n\n- Write Go\n- Ship things";
|
||||
mockFetch(200, { data: [job({ description: markdown })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBe(markdown);
|
||||
});
|
||||
|
||||
test("a hit with no description carries null, not an empty string", async () => {
|
||||
mockFetch(200, { data: [job({ description: "" })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBeNull();
|
||||
});
|
||||
|
||||
// A self-hosted freehire predating /agent/jobs/search answers 404, which apiGet
|
||||
// maps to null. Reporting that as "no results" would hide a broken endpoint
|
||||
// behind an empty, plausible-looking result set.
|
||||
test("a 404 from the search endpoint is an error, not an empty result set", async () => {
|
||||
mockFetch(404, { error: "not found" });
|
||||
const err = captureStderr();
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
err.restore();
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(out.get()).toBe("");
|
||||
expect(JSON.parse(err.get()).error).toMatch(/agent\/jobs\/search/);
|
||||
});
|
||||
|
||||
test("empty result set yields an empty results array", async () => {
|
||||
mockFetch(200, { data: [], meta: { total: 0 } });
|
||||
const out = captureStdout();
|
||||
|
||||
@@ -14,7 +14,8 @@ Verified against the live API:
|
||||
|
||||
| Endpoint | Status |
|
||||
|----------|--------|
|
||||
| `GET /api/v1/jobs/search` | 200 |
|
||||
| `GET /api/v1/agent/jobs/search` | 200 |
|
||||
| `GET /api/v1/jobs/search` | 200 (the web variant; not used by this skill) |
|
||||
| `GET /api/v1/jobs/facets` | 200 |
|
||||
| `GET /api/v1/jobs/{slug}` | 200 |
|
||||
| `GET /api/v1/auth/me` | 401 (auth required — not used here) |
|
||||
@@ -26,10 +27,37 @@ 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/agent/jobs/search`
|
||||
|
||||
The endpoint the skill's `search` command uses. Full-text + facet search over open
|
||||
jobs, returning `data: [job, …]` with `meta.total` = the estimated match count.
|
||||
|
||||
It runs the **same query** as the web-facing `/api/v1/jobs/search` — same `q`, same
|
||||
facets, same ranking, same pagination guard (`offset + limit ≤ 10000`) — and differs
|
||||
in one respect: asked to, it replaces the search index's truncated `description`
|
||||
preview with the posting's **full** description read from the database. That is what
|
||||
lets a search of N roles stay one request instead of N + 1.
|
||||
|
||||
Two extra parameters control it:
|
||||
|
||||
| Param | Maps to CLI flag | Notes |
|
||||
|-------|------------------|-------|
|
||||
| `include_description` | (always `true`) | Without it the endpoint serves the index preview, same as the web search. |
|
||||
| `description_format` | `--description-format` | `markdown` (the skill's default), `text`, or `html`. **An unrecognized value is not an error** — the API falls back to `html`, so the CLI validates the flag itself. |
|
||||
|
||||
Hydration is best-effort per hit: a result whose row has vanished from the database
|
||||
(the index lagging a just-removed job) keeps the preview rather than being dropped,
|
||||
so `description` is a full text in practice but never guaranteed to be.
|
||||
|
||||
A `404` from this path means the instance predates the endpoint (a self-hosted
|
||||
freehire behind `FREEHIRE_API_URL`), not a missing job; the CLI reports it as an
|
||||
error naming the path rather than as an empty result set.
|
||||
|
||||
## `GET /api/v1/jobs/search`
|
||||
|
||||
Full-text + facet search over open jobs. Returns `data: [job, …]` with
|
||||
`meta.total` = the total match count.
|
||||
The web variant of the same search — identical query surface, but `description` is
|
||||
always the index's truncated preview. The skill does not call it; it is listed here
|
||||
because the shared parameters below are documented against both.
|
||||
|
||||
Query parameters used by the skill:
|
||||
|
||||
@@ -66,7 +94,8 @@ bounded server-side (`offset + limit ≤ 10000`).
|
||||
"company": "Zensar",
|
||||
"company_slug": "zensar",
|
||||
"location": "India", // free-text ATS location
|
||||
"description": "<ul><li>…</li></ul>", // HTML; the skill strips it for detail
|
||||
"description": "- …", // agent search: full text in the requested
|
||||
// format; elsewhere HTML, stripped client-side
|
||||
"skills": ["go", "kubernetes", …], // dictionary facet (top-level)
|
||||
"work_mode": "remote", // may be absent
|
||||
"regions": ["apac"], // dictionary/hybrid facet
|
||||
@@ -105,8 +134,10 @@ points users to (`?q=<role>` scopes the counts). Example:
|
||||
## 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`).
|
||||
portals). The only markup handling left client-side is `detail`'s: `/jobs/{slug}`
|
||||
serves HTML, which `cleanHtml` (`cli/src/helpers.ts`) strips into readable text.
|
||||
Search descriptions arrive already rendered by the API and are passed through
|
||||
verbatim — stripping them again would undo the Markdown structure.
|
||||
- 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
|
||||
|
||||
@@ -13,6 +13,13 @@ per-file diff commands.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **freehire-search: full descriptions come back with the search** - `search` now calls
|
||||
freehire's agent search endpoint (`/api/v1/agent/jobs/search`), which serves each hit's
|
||||
complete description instead of the search index's truncated preview. A 20-role search is
|
||||
one request rather than 1 + 20 `detail` calls, and `/scrape`'s Step 2 no longer needs a
|
||||
per-hit fetch for this portal. `--description-format markdown|text|html` (default
|
||||
`markdown`) selects the rendering; `table` and `plain` output is unchanged.
|
||||
|
||||
- **Custom templates: any compile-to-PDF toolchain (Typst, ...)** - `/add-template` no longer
|
||||
hardcodes a `lualatex`/`xelatex`/`pdflatex` engine enum. Custom templates now declare a
|
||||
source extension and a full compile command, so Typst (`typst compile`) registers the same
|
||||
|
||||
Reference in New Issue
Block a user