mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
@@ -1,7 +1,6 @@
|
|||||||
import { defineCommand, option } from "@bunli/core"
|
import { defineCommand, option } from "@bunli/core"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { fetchWithUA, writeError, BASE_URL } from "../helpers.js"
|
import { fetchWithUA, parseJobPostingJsonLd, writeError, BASE_URL } from "../helpers.js"
|
||||||
import { parse as parseHtml } from "node-html-parser"
|
|
||||||
|
|
||||||
export const detail = defineCommand({
|
export const detail = defineCommand({
|
||||||
name: "detail",
|
name: "detail",
|
||||||
@@ -39,31 +38,7 @@ export const detail = defineCommand({
|
|||||||
|
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
|
|
||||||
const root = parseHtml(html)
|
const jobPosting = parseJobPostingJsonLd(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jobPosting) {
|
if (!jobPosting) {
|
||||||
writeError("No JSON-LD found on job page", "PARSE_ERROR")
|
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 BASE_URL = "https://jobbank.dk"
|
||||||
|
|
||||||
export const USER_AGENT =
|
export const USER_AGENT =
|
||||||
@@ -158,3 +160,36 @@ export function extractJobIdFromUrl(url: string): string {
|
|||||||
const match = url.match(/\/job\/(\d+)\//)
|
const match = url.match(/\/job\/(\d+)\//)
|
||||||
return match ? match[1] : ""
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { parseJobPostingJsonLd } from "../src/helpers"
|
||||||
|
|
||||||
|
const script = (value: string) => `<script type="application/ld+json">${value}</script>`
|
||||||
|
|
||||||
|
describe("parseJobPostingJsonLd", () => {
|
||||||
|
test("finds a JobPosting inside an @graph", () => {
|
||||||
|
const html = script(
|
||||||
|
JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{ "@type": "WebPage", name: "Jobs" },
|
||||||
|
{ "@type": "JobPosting", title: "Data Engineer" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(parseJobPostingJsonLd(html)).toEqual({
|
||||||
|
"@type": "JobPosting",
|
||||||
|
title: "Data Engineer",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves top-level object and array support", () => {
|
||||||
|
expect(parseJobPostingJsonLd(script('{"@type":"JobPosting","title":"One"}'))?.title).toBe("One")
|
||||||
|
expect(
|
||||||
|
parseJobPostingJsonLd(script('[{"@type":"WebPage"},{"@type":"JobPosting","title":"Two"}]'))?.title,
|
||||||
|
).toBe("Two")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips malformed scripts and checks later JSON-LD", () => {
|
||||||
|
const html = `${script("{not-json")}${script('{"@type":"JobPosting","title":"Valid"}')}`
|
||||||
|
|
||||||
|
expect(parseJobPostingJsonLd(html)?.title).toBe("Valid")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null when no JobPosting exists", () => {
|
||||||
|
expect(parseJobPostingJsonLd(script('{"@graph":[{"@type":"WebPage"}]}'))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user