mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-08 16:07:40 +00:00
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>
85 lines
2.9 KiB
TypeScript
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
|
|
}
|
|
}
|