Files
ai-job-search/.agents/skills/linkedin-search/cli/src/commands/search.ts
T
MaverickHQandClaude Opus 5.5 67d1b84003 fix(portals): keep hidden markup and invisible characters out of CLI output
Every portal CLI turned HTML into text with a bare tag strip, so the
text inside comments, <style>/<script> blocks and hidden or
display:none elements came out as visible text. Unicode tag
characters, bidi controls and zero-width characters passed through
untouched, raw from the portal's JSON or decoded from numeric
entities. A listing could carry an instruction that renders as nothing
on the page but reads as text to the model.

Each CLI now runs HTML through visibleHtml, extending the helper
jobindex already used for one of its page shapes, and strips invisible
characters at the one function every output format reads. The
/add-portal contract names both, with linkedin-search as the copy
source.

Each CLI's tests/hidden-text.test.ts drives the real path (the CLI in
a fresh process, the command handler, or the normalizer the handler
calls) and fails on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:07:33 +01:00

89 lines
2.5 KiB
TypeScript

import {
SEARCH_URL,
htmlFetch,
parseJobCards,
jobageToTPR,
minutesToTPR,
stripInvisible,
workTypeFlag,
writeError,
type JobCard,
} from "../helpers.js"
export interface SearchOpts {
query?: string
location: string
jobage: number
jobageMinutes?: number
remote?: string // "remote" | "hybrid" | "onsite"
page: number
limit?: number
format: "json" | "table" | "plain"
}
function buildUrl(opts: SearchOpts): string {
const params = new URLSearchParams()
if (opts.query) params.set("keywords", opts.query)
if (opts.location) params.set("location", opts.location)
const tpr = opts.jobageMinutes !== undefined ? minutesToTPR(opts.jobageMinutes) : jobageToTPR(opts.jobage)
if (tpr) params.set("f_TPR", tpr)
const wt = workTypeFlag(opts.remote)
if (wt) params.set("f_WT", wt)
params.set("start", String((opts.page - 1) * 10))
return `${SEARCH_URL}?${params.toString()}`
}
function renderTable(cards: JobCard[]): string {
if (cards.length === 0) return "No results."
const rows = cards.map((c) => {
const title = (c.title || "").slice(0, 42).padEnd(42)
const company = (c.company || "—").slice(0, 26).padEnd(26)
const loc = (c.location || "—").slice(0, 24).padEnd(24)
const date = c.date || "—"
return `${c.id.padEnd(11)} ${title} ${company} ${loc} ${date}`
})
const header =
"ID".padEnd(11) +
" " +
"TITLE".padEnd(42) +
" " +
"COMPANY".padEnd(26) +
" " +
"LOCATION".padEnd(24) +
" DATE"
return [header, "-".repeat(header.length), ...rows].join("\n")
}
export async function runSearch(opts: SearchOpts): Promise<number> {
try {
const html = await htmlFetch(buildUrl(opts))
let cards = stripInvisible(parseJobCards(html))
if (opts.limit !== undefined && opts.limit >= 0) cards = cards.slice(0, opts.limit)
if (opts.format === "table") {
process.stdout.write(renderTable(cards) + "\n")
} else if (opts.format === "plain") {
process.stdout.write(
cards
.map(
(c) =>
`${c.title}\n ${c.company || "—"} · ${c.location || "—"} · ${c.date || "—"}\n id: ${c.id}\n ${c.url}`,
)
.join("\n\n") + "\n",
)
} else {
process.stdout.write(
JSON.stringify(
{ meta: { count: cards.length, page: opts.page }, results: cards },
null,
2,
) + "\n",
)
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED")
return 1
}
}