Files
ai-job-search/.agents/skills/jobnet-search/cli/src/helpers.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

163 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const BASE_URL = "https://jobnet.dk/bff"
export const USER_AGENT = "Mozilla/5.0 (compatible; jobnet-cli/1.0)"
export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
let url = `${BASE_URL}${path}`
if (params && Object.keys(params).length > 0) {
const qs = new URLSearchParams(params)
url += `?${qs.toString()}`
}
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: {
"User-Agent": USER_AGENT,
"x-csrf": "1",
},
signal: AbortSignal.timeout(15000),
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
// Add jitter to spread out retries: base delay + random 0-500ms
const jitter = Math.floor(Math.random() * 500)
await new Promise((resolve) => setTimeout(resolve, delay + jitter))
delay = Math.min(delay * 2, 5000)
continue
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<T>
}
throw new Error("API request failed after max retries")
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
/**
* Characters a reader of the posting never sees but a model reading this
* output does: C0/C1 controls other than tab, newline and carriage return, the
* soft hyphen, bidi marks/embeddings/overrides/isolates, zero-width characters
* and invisible operators, the BOM, variation selectors, and Unicode tag
* characters (U+E0000-E007F), which can spell out a whole hidden instruction.
* Listing text is third-party data, and these arrive raw in a portal's JSON or
* markup as easily as through a numeric entity.
*/
const INVISIBLE_CHARS =
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F­؜᠎​-‏‪-‮⁠-︀-️\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/gu
/** Strip INVISIBLE_CHARS from every string in a result, recursively: the CLI's output boundary. */
export function stripInvisible<T>(value: T): T {
if (typeof value === "string") return value.replace(INVISIBLE_CHARS, "") as T
if (Array.isArray(value)) return value.map(stripInvisible) as T
if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stripInvisible(v)])) as T
}
return value
}
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
/** Whether an opening tag's attributes keep it from rendering: `hidden`, or an inline style that hides it. */
function isHiddenTag(attrs: string): boolean {
for (const m of attrs.matchAll(/([^\s"'=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g)) {
const name = m[1].toLowerCase()
const value = m[2] ?? m[3] ?? m[4] ?? ""
if (name === "hidden") return true
if (name === "style" && /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden|mso-hide\s*:\s*all)\b/i.test(value)) {
return true
}
}
return false
}
/**
* Drop markup a browser never renders - comments (including Word's
* conditional ones), <script>/<style>/<template> blocks, and elements marked
* hidden - before tags are stripped. A plain tag strip keeps their text, which
* hands the model words the posting's reader never saw. CSS-only tricks
* (off-screen, zero font size) need a renderer and are not caught here.
*/
export function visibleHtml(html: string): string {
const src = html
.replace(/<!--[\s\S]*?(?:-->|$)/g, "")
.replace(/<(script|style|template)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, "")
// One pass, collecting what is kept: slicing the string per hidden element
// would make a page full of them quadratic.
const open = /<([a-zA-Z][a-zA-Z0-9-]*)(\s[^>]*)?>/g
const kept: string[] = []
let from = 0
let m: RegExpExecArray | null
while ((m = open.exec(src)) !== null) {
if (!m[2] || !isHiddenTag(m[2])) continue
const tag = m[1].toLowerCase()
let end = open.lastIndex
if (!VOID_TAGS.has(tag) && !m[0].endsWith("/>")) {
// Skip to the matching close tag, counting nested same-name elements. An
// unclosed hidden element runs to the end: failing closed drops visible
// text, failing open would leak the hidden text.
const same = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi")
same.lastIndex = end
end = src.length
let depth = 1
let s: RegExpExecArray | null
while ((s = same.exec(src)) !== null) {
if (s[1]) depth--
else if (!s[0].endsWith("/>")) depth++
if (depth === 0) {
end = same.lastIndex
break
}
}
}
kept.push(src.slice(from, m.index))
from = end
open.lastIndex = end
}
kept.push(src.slice(from))
return kept.join("")
}
export function stripHtml(html: string): string {
return visibleHtml(html)
.replace(/<[^>]*>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim()
}
export function normalizeJobId(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed
let url: URL
try {
const hostLike = /^[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(trimmed)
url = new URL(hostLike ? `https://${trimmed}` : trimmed)
} catch {
return null
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null
if (url.hostname !== "jobnet.dk" && !url.hostname.endsWith(".jobnet.dk")) return null
// Otherwise an unrelated site's matching path silently selects a Jobnet ad.
// Keep legacy routes, but never interpret a query or fragment as a job path.
const match = url.pathname.match(/(?:\/find-job\/|\/JobAdDetails\/|\/Details\/)(?:detaljer\/)?([a-zA-Z0-9_-]+)(?:\/|$)/i)
if (match) return match[1]
return null
}