2026-07-09 01:04:35 -03:00
|
|
|
import { apiGet, toResult, writeError, type FreehireJob, type JobResult } from "../helpers.js"
|
|
|
|
|
|
2026-07-28 16:19:24 -03:00
|
|
|
// 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"]
|
|
|
|
|
|
2026-07-09 01:04:35 -03:00
|
|
|
export interface SearchOpts {
|
|
|
|
|
query?: string
|
|
|
|
|
jobage: number
|
|
|
|
|
page: number
|
|
|
|
|
limit: number
|
|
|
|
|
format: "json" | "table" | "plain"
|
2026-07-28 16:19:24 -03:00
|
|
|
descriptionFormat: DescriptionFormat
|
2026-07-09 01:04:35 -03:00
|
|
|
// 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
|
2026-07-28 16:19:24 -03:00
|
|
|
// 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)
|
2026-07-09 01:04:35 -03:00
|
|
|
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 {
|
2026-07-28 16:19:24 -03:00
|
|
|
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
|
2026-07-09 01:04:35 -03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|