mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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.
142 lines
5.3 KiB
TypeScript
142 lines
5.3 KiB
TypeScript
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[]
|
|
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
|
|
// 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)
|
|
|
|
// 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[]>(`${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")
|
|
} 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
|
|
}
|
|
}
|