Files
ai-job-search/.agents/skills/linkedin-search/cli/src/commands/detail.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

85 lines
2.9 KiB
TypeScript

import { DETAIL_URL, htmlFetch, parseJobDetail, stripInvisible, writeError } from "../helpers.js"
export interface DetailOpts {
id: string
format: "json" | "plain"
}
const LINKEDIN_HOST = /(^|\.)linkedin\.com$/i
/**
* Accept a raw job ID, a job URN, a LinkedIn job-view URL (any linkedin.com
* host, with or without scheme), or a bare title slug ending in the ID.
*
* A URL on any other host is rejected. The previous pattern took the first
* 6+-digit path segment from ANY URL, so a Greenhouse or Lever apply link -
* the kind a posting's own page hands out - fetched whatever LinkedIn
* posting happened to carry that number and printed it with exit 0. Host and
* path are read through real URL parsing, so look-alike hosts
* (linkedin.com.evil.io) and userinfo tricks (linkedin.com@evil.io) fall on
* the reject side too.
*/
export function normalizeId(input: string): string | null {
const trimmed = input.trim()
const urn = trimmed.match(/urn:li:jobPosting:(\d+)/)
if (urn) return urn[1]
if (/^\d{6,}$/.test(trimmed)) return trimmed
const hasScheme = /^https?:\/\//i.test(trimmed)
if (hasScheme || /^[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(trimmed)) {
let parsed: URL
try {
parsed = new URL(hasScheme ? trimmed : `https://${trimmed}`)
} catch {
return null
}
if (!LINKEDIN_HOST.test(parsed.hostname)) return null
const path = parsed.pathname.match(/\/jobs\/view\/(?:[^/]*-)?(\d{6,})\/?$/)
return path ? path[1] : null
}
// A scheme- and slash-free slug such as "software-engineer-1234567890".
const slug = trimmed.match(/^[a-z0-9-]*-(\d{6,})$/i)
return slug ? slug[1] : null
}
export async function runDetail(opts: DetailOpts): Promise<number> {
const id = normalizeId(opts.id)
if (!id) {
writeError(`Could not parse a job ID from "${opts.id}"`, "BAD_ID")
return 1
}
try {
const html = await htmlFetch(`${DETAIL_URL}/${id}`)
if (!html) {
writeError("Job not found", "NOT_FOUND")
return 1
}
const job = stripInvisible(parseJobDetail(html, id))
if (opts.format === "plain") {
const lines = [
job.title,
`${job.company || "—"} · ${job.location || "—"}`,
"",
job.seniority ? `Seniority: ${job.seniority}` : "",
job.employmentType ? `Employment: ${job.employmentType}` : "",
job.jobFunction ? `Function: ${job.jobFunction}` : "",
job.industries ? `Industries: ${job.industries}` : "",
`Status: ${job.isActive ? "ACTIVE" : "CLOSED / EXPIRED"}`,
"",
job.description || "(no description)",
"",
`URL: ${job.url}`,
].filter((l) => l !== "")
process.stdout.write(lines.join("\n") + "\n")
} else {
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "DETAIL_FAILED")
return 1
}
}