mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
fix(portals): depth-track div extraction so nested job descriptions aren't truncated (#204)
The jobindex and linkedin detail parsers matched description containers with a non-greedy regex that stops at the first inner </div>, so any posting whose description contains nested divs was silently truncated (jobindex dropped later sections; linkedin dropped everything after the first block). Replaces the regex with a depth-tracked extractDivContent scanner that walks div open/close markers to the matching close. Verified: truncation bug reproduced against real markup fixtures, depth arithmetic correct (no off-by-one/infinite-loop), 28 tests pass network-free, no regression on non-nested divs. Malformed-HTML over-grabs rather than truncates - the safer failure, cleaned by downstream stripTags/decode. By @oscarbol09.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { htmlFetch, writeError } from "../helpers.js"
|
||||
import { htmlFetch, writeError, extractDivContent } from "../helpers.js"
|
||||
|
||||
const BASE_URL = "https://www.jobindex.dk"
|
||||
|
||||
@@ -180,9 +180,9 @@ function parseDetailPage(html: string, url: string, id: string): DetailResult {
|
||||
let description: string | null = null
|
||||
|
||||
// Try job-text class first
|
||||
const jobTextMatch = html.match(/class="job-text"[^>]*>([\s\S]*?)<\/div>\s*(?:<div|<\/div>)/i)
|
||||
if (jobTextMatch) {
|
||||
description = decodeHtmlEntities(stripTags(jobTextMatch[1])).replace(/\s+/g, " ").trim() || null
|
||||
const jobTextHtml = extractDivContent(html, "job-text")
|
||||
if (jobTextHtml) {
|
||||
description = decodeHtmlEntities(stripTags(jobTextHtml)).replace(/\s+/g, " ").trim() || null
|
||||
}
|
||||
|
||||
// Fallback: try og:description meta tag for a brief description
|
||||
|
||||
@@ -307,6 +307,33 @@ export function parseJobCards(html: string): JobCard[] {
|
||||
return results
|
||||
}
|
||||
|
||||
export function extractDivContent(html: string, className: string): string | null {
|
||||
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const openRe = new RegExp(`<div[^>]*class="[^"]*${escaped}[^"]*"[^>]*>`, 'i')
|
||||
const open = openRe.exec(html)
|
||||
if (!open) return null
|
||||
|
||||
let i = open.index + open[0].length
|
||||
let depth = 1
|
||||
|
||||
while (depth > 0 && i < html.length) {
|
||||
const nextOpen = html.indexOf('<div', i)
|
||||
const nextClose = html.indexOf('</div>', i)
|
||||
|
||||
if (nextClose === -1) return null
|
||||
|
||||
if (nextOpen !== -1 && nextOpen < nextClose) {
|
||||
depth++
|
||||
i = nextOpen + 4
|
||||
} else {
|
||||
depth--
|
||||
i = nextClose + 6
|
||||
}
|
||||
}
|
||||
|
||||
return html.slice(open.index + open[0].length, i - 6)
|
||||
}
|
||||
|
||||
export function parseHitCount(html: string): number {
|
||||
const match = html.match(/af <strong>([\d.]+)<\/strong>/)
|
||||
if (!match) return 0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { parseJobCards } from "../src/helpers";
|
||||
import { parseJobCards, extractDivContent } from "../src/helpers";
|
||||
|
||||
// Minimal jobad-wrapper markup: parseJobCards splits on `jobad-wrapper-<id>`
|
||||
// and reads the title from the <h4><a href>…</a></h4> and the company from the
|
||||
@@ -44,3 +44,55 @@ describe("decodeHtmlEntities (via parseJobCards)", () => {
|
||||
expect(c.company).toBe("Nørrebro ApS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDivContent", () => {
|
||||
test("extracts content from simple div", () => {
|
||||
const html = '<div class="job-text">Simple text</div>';
|
||||
expect(extractDivContent(html, "job-text")).toBe("Simple text");
|
||||
});
|
||||
|
||||
test("extracts content with nested divs — the regression case", () => {
|
||||
const html = `<div class="job-text">
|
||||
<div class="highlight">First section</div>
|
||||
<div class="details">Second section with important info</div>
|
||||
</div>`;
|
||||
expect(extractDivContent(html, "job-text")).toBe(
|
||||
'\n <div class="highlight">First section</div>\n <div class="details">Second section with important info</div>\n ',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when class not found", () => {
|
||||
expect(extractDivContent("<div>no class</div>", "nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
test("works with extra attributes on the div", () => {
|
||||
const html = '<div id="desc" class="job-text" data-x="1">Content</div>';
|
||||
expect(extractDivContent(html, "job-text")).toBe("Content");
|
||||
});
|
||||
|
||||
test("handles deeply nested divs (3 levels)", () => {
|
||||
const html = `<div class="job-text">
|
||||
<div>
|
||||
<div>Deep content</div>
|
||||
</div>
|
||||
</div>`;
|
||||
expect(extractDivContent(html, "job-text")).toBe(
|
||||
'\n <div>\n <div>Deep content</div>\n </div>\n ',
|
||||
);
|
||||
});
|
||||
|
||||
test("handles empty content", () => {
|
||||
const html = '<div class="job-text"></div>';
|
||||
expect(extractDivContent(html, "job-text")).toBe("");
|
||||
});
|
||||
|
||||
test("handles br and other non-div tags", () => {
|
||||
const html = '<div class="job-text">Line1<br>Line2<br>Line3</div>';
|
||||
expect(extractDivContent(html, "job-text")).toBe("Line1<br>Line2<br>Line3");
|
||||
});
|
||||
|
||||
test("escapes special regex characters in class name", () => {
|
||||
const html = '<div class="job-text (special)">Content</div>';
|
||||
expect(extractDivContent(html, "job-text (special)")).toBe("Content");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,37 @@ export interface JobDetail extends JobCard {
|
||||
applyUrl: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the inner HTML of a <div> identified by a CSS class name, correctly
|
||||
* handling nested <div> elements by tracking tag depth.
|
||||
*/
|
||||
export function extractDivContent(html: string, className: string): string | null {
|
||||
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const openRe = new RegExp(`<div[^>]*class="[^"]*${escaped}[^"]*"[^>]*>`, 'i')
|
||||
const open = openRe.exec(html)
|
||||
if (!open) return null
|
||||
|
||||
let i = open.index + open[0].length
|
||||
let depth = 1
|
||||
|
||||
while (depth > 0 && i < html.length) {
|
||||
const nextOpen = html.indexOf('<div', i)
|
||||
const nextClose = html.indexOf('</div>', i)
|
||||
|
||||
if (nextClose === -1) return null
|
||||
|
||||
if (nextOpen !== -1 && nextOpen < nextClose) {
|
||||
depth++
|
||||
i = nextOpen + 4
|
||||
} else {
|
||||
depth--
|
||||
i = nextClose + 6
|
||||
}
|
||||
}
|
||||
|
||||
return html.slice(open.index + open[0].length, i - 6)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Unicode code point to a string. Uses `fromCodePoint` (not
|
||||
* `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600)
|
||||
@@ -180,11 +211,11 @@ export function parseJobDetail(html: string, id: string): JobDetail {
|
||||
|
||||
// Rich description block. Keep paragraph/line breaks as newlines.
|
||||
let description: string | null = null
|
||||
const desc = html.match(
|
||||
/class="(?:show-more-less-html__markup|description__text[^"]*)"[^>]*>([\s\S]*?)<\/div>/i,
|
||||
)
|
||||
if (desc) {
|
||||
const withBreaks = desc[1]
|
||||
const descHtml =
|
||||
extractDivContent(html, "show-more-less-html__markup") ??
|
||||
extractDivContent(html, "description__text")
|
||||
if (descHtml) {
|
||||
const withBreaks = descHtml
|
||||
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n")
|
||||
description = decodeHtmlEntities(stripTags(withBreaks)).replace(/\n{3,}/g, "\n\n").trim() || null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { parseJobCards, parseJobDetail } from "../src/helpers";
|
||||
import { parseJobCards, parseJobDetail, extractDivContent } from "../src/helpers";
|
||||
|
||||
// Minimal search-card markup: parseJobCards splits on the job-posting URN and
|
||||
// needs an id, a base-search-card__title, and a full-link. Everything else is
|
||||
@@ -53,3 +53,61 @@ describe("decodeHtmlEntities (via parseJobDetail)", () => {
|
||||
expect(job.title).toBe("Señor Engineer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDivContent", () => {
|
||||
test("extracts content from simple div", () => {
|
||||
const html = '<div class="description__text">Simple text</div>';
|
||||
expect(extractDivContent(html, "description__text")).toBe("Simple text");
|
||||
});
|
||||
|
||||
test("extracts content with nested divs — the regression case", () => {
|
||||
const html = `<div class="description__text">
|
||||
<div>Requirements:</div>
|
||||
<ul><li>Skill A</li></ul>
|
||||
<div>About Us:</div>
|
||||
<p>We are...</p>
|
||||
</div>`;
|
||||
expect(extractDivContent(html, "description__text")).toBe(
|
||||
'\n <div>Requirements:</div>\n <ul><li>Skill A</li></ul>\n <div>About Us:</div>\n <p>We are...</p>\n ',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when class not found", () => {
|
||||
expect(extractDivContent("<div>no class</div>", "nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
test("works with show-more-less-html__markup class", () => {
|
||||
const html = '<div class="show-more-less-html__markup">LinkedIn content</div>';
|
||||
expect(extractDivContent(html, "show-more-less-html__markup")).toBe("LinkedIn content");
|
||||
});
|
||||
|
||||
test("handles deeply nested divs (3 levels)", () => {
|
||||
const html = `<div class="description__text">
|
||||
<div>
|
||||
<div>Deep content</div>
|
||||
</div>
|
||||
</div>`;
|
||||
expect(extractDivContent(html, "description__text")).toBe(
|
||||
'\n <div>\n <div>Deep content</div>\n </div>\n ',
|
||||
);
|
||||
});
|
||||
|
||||
test("handles empty content", () => {
|
||||
const html = '<div class="description__text"></div>';
|
||||
expect(extractDivContent(html, "description__text")).toBe("");
|
||||
});
|
||||
|
||||
test("parseJobDetail uses extractDivContent and preserves full description", () => {
|
||||
const html = `<div class="description__text">
|
||||
<div>Requirements:</div>
|
||||
<ul><li>5 years Python</li></ul>
|
||||
<div>About Us:</div>
|
||||
<p>We are hiring!</p>
|
||||
</div>`;
|
||||
const job = parseJobDetail(html, "999");
|
||||
expect(job.description).toContain("Requirements:");
|
||||
expect(job.description).toContain("5 years Python");
|
||||
expect(job.description).toContain("About Us:");
|
||||
expect(job.description).toContain("We are hiring!");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user