fix(jobbank): parse JobPosting entries nested in JSON-LD @graph (#190)

Extracts the JSON-LD JobPosting lookup into a recursive parseJobPostingJsonLd helper that handles top-level objects, arrays, and @graph wrappers (including nested combinations), keeps skipping malformed scripts, and covers all four cases with network-free Bun tests.

By @luochen211. Closes #189.
This commit is contained in:
落尘
2026-07-19 19:38:32 +02:00
committed by GitHub
parent 61d17d1bda
commit 3a184bc115
3 changed files with 77 additions and 27 deletions
@@ -1,7 +1,6 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { fetchWithUA, writeError, BASE_URL } from "../helpers.js"
import { parse as parseHtml } from "node-html-parser"
import { fetchWithUA, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
export const detail = defineCommand({
name: "detail",
@@ -39,31 +38,7 @@ export const detail = defineCommand({
if (signal.aborted) return
const root = parseHtml(html)
// Find all <script type="application/ld+json"> tags
const scripts = root.querySelectorAll('script[type="application/ld+json"]')
let jobPosting: Record<string, unknown> | null = null
for (const script of scripts) {
try {
const json = JSON.parse(script.text)
if (json["@type"] === "JobPosting") {
jobPosting = json
break
}
// Could be an array
if (Array.isArray(json)) {
const found = json.find((item) => item["@type"] === "JobPosting")
if (found) {
jobPosting = found
break
}
}
} catch {
// not valid JSON — skip
}
}
const jobPosting = parseJobPostingJsonLd(html)
if (!jobPosting) {
writeError("No JSON-LD found on job page", "PARSE_ERROR")
@@ -1,3 +1,5 @@
import { parse as parseHtml } from "node-html-parser"
export const BASE_URL = "https://jobbank.dk"
export const USER_AGENT =
@@ -158,3 +160,36 @@ export function extractJobIdFromUrl(url: string): string {
const match = url.match(/\/job\/(\d+)\//)
return match ? match[1] : ""
}
function findJobPosting(value: unknown): Record<string, unknown> | null {
if (Array.isArray(value)) {
for (const item of value) {
const jobPosting = findJobPosting(item)
if (jobPosting) return jobPosting
}
return null
}
if (!value || typeof value !== "object") return null
const record = value as Record<string, unknown>
if (record["@type"] === "JobPosting") return record
return findJobPosting(record["@graph"])
}
export function parseJobPostingJsonLd(html: string): Record<string, unknown> | null {
const root = parseHtml(html)
const scripts = root.querySelectorAll('script[type="application/ld+json"]')
for (const script of scripts) {
try {
const jobPosting = findJobPosting(JSON.parse(script.text) as unknown)
if (jobPosting) return jobPosting
} catch {
// Invalid JSON-LD should not prevent later scripts from being checked.
}
}
return null
}