diff --git a/.agents/skills/linkedin-search/cli/src/commands/detail.ts b/.agents/skills/linkedin-search/cli/src/commands/detail.ts index ed710b8..cb561c4 100644 --- a/.agents/skills/linkedin-search/cli/src/commands/detail.ts +++ b/.agents/skills/linkedin-search/cli/src/commands/detail.ts @@ -39,6 +39,7 @@ export async function runDetail(opts: DetailOpts): Promise { 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)", "", diff --git a/.agents/skills/linkedin-search/cli/src/helpers.ts b/.agents/skills/linkedin-search/cli/src/helpers.ts index 8b58e1f..2191549 100644 --- a/.agents/skills/linkedin-search/cli/src/helpers.ts +++ b/.agents/skills/linkedin-search/cli/src/helpers.ts @@ -63,6 +63,7 @@ export interface JobDetail extends JobCard { employmentType: string | null jobFunction: string | null industries: string | null + isActive: boolean } /** @@ -227,6 +228,21 @@ export function parseJobDetail(html: string, id: string): JobDetail { criteria[clean(cm[1]).toLowerCase()] = clean(cm[2]) } + // Closed-state detection, scoped to the top card. A closed posting renders + //
+ //
No longer accepting applications
+ //
+ // there; that class and its visible text are the only markers real closed + // pages carry (verified against live guest pages, 2026-08-09). The search + // stops where the description markup begins: recruiter boilerplate quotes + // these phrases, and a false CLOSED talks a user out of a live job. + // Absence of the banner is absence of evidence, not proof the posting is + // open - markup drift or a consent-walled response also renders no banner - + // so isActive: true means only "no closed banner found". + const descStart = html.search(/class="(?:show-more-less-html__markup|description__text)/i) + const topcard = descStart === -1 ? html : html.slice(0, descStart) + const isActive = !/closed-job__flavor|no longer accepting applications/i.test(topcard) + return { id, title: title ? clean(title) : "(untitled)", @@ -240,6 +256,7 @@ export function parseJobDetail(html: string, id: string): JobDetail { employmentType: criteria["employment type"] ?? null, jobFunction: criteria["job function"] ?? null, industries: criteria["industries"] ?? null, + isActive, } } diff --git a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts index 28edb70..19905e5 100644 --- a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts @@ -86,6 +86,51 @@ describe("decodeHtmlEntities (via parseJobCards)", () => { }); }); +describe("parseJobDetail active-status detection", () => { + // Captured from a real closed guest posting (2026-08-09): the banner LinkedIn + // actually renders inside the top card. Its class and its visible text are the + // only closed markers that occur in the wild. + const closedBanner = ` +
+ +
No longer accepting applications
+
`; + + const page = (topcardExtra: string, description: string) => ` +

Data Engineer

+ Berlin + ${topcardExtra} +
${description}
`; + + test("a closed posting's top-card banner yields isActive: false", () => { + const job = parseJobDetail(page(closedBanner, "We build things."), "1"); + expect(job.isActive).toBe(false); + }); + + test("an open posting yields isActive: true", () => { + const job = parseJobDetail(page("", "We are hiring!"), "2"); + expect(job.isActive).toBe(true); + }); + + test("recruiter boilerplate in the description does not flag a live posting", () => { + // The review's false-positive case: the closed phrase appears in the + // *description text* of a job that is very much open. + const job = parseJobDetail( + page("", "Apply soon - once filled, this posting is no longer accepting applications."), + "3", + ); + expect(job.isActive).toBe(true); + }); + + test("a closed-job class named in the description does not flag a live posting", () => { + const job = parseJobDetail( + page("", "Our design system documents a closed-job__flavor CSS class."), + "4", + ); + expect(job.isActive).toBe(true); + }); +}); + describe("parseJobDetail dropped fields", () => { test("emits no applyUrl field", () => { // The extraction regex assumed class-before-href and never matched diff --git a/.claude/skills/job-scraper/SKILL.md b/.claude/skills/job-scraper/SKILL.md index a5165b4..9036894 100644 --- a/.claude/skills/job-scraper/SKILL.md +++ b/.claude/skills/job-scraper/SKILL.md @@ -94,6 +94,16 @@ and URL. For jobs worth a deeper look, fetch full detail with that portal's `det command (see its SKILL.md — do not guess flags) to extract **key requirements**, **application deadline**, and a brief description snippet. +**Closed-at-source detection:** `linkedin-search detail` also returns `isActive`. +`false` means the posting page itself renders LinkedIn's "No longer accepting +applications" banner — the job died between being indexed and being fetched (expired +LinkedIn URLs redirect to *similar live jobs*, so a search hit can be a ghost). Mark +such a job, never silently drop it: write its entry to `seen_jobs.json` in Step 4 with +`"status": "expired"` and leave it out of the Step 5 presentation — an absent entry +looks identical to a job never seen, and the recorded status is what makes a later +ghost report self-triaging. `isActive: true` is only the absence of that banner, not +proof the posting is open; deadlines and dead URLs remain `/rank`'s job. + **From WebSearch results:** Use `WebFetch` on the posting URL and extract the same fields manually. If it returns HTTP 403, retry with browser headers via curl per `.claude/skills/job-application-assistant/09-web-research.md` before giving up — most diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff9e03..522b691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,21 @@ per-file diff commands. ### Added +- **`linkedin-search detail` reports closed postings** (#280, adopted with the original + author's commit preserved) - a new `isActive` field: `false` when the posting page + renders LinkedIn's own "No longer accepting applications" top-card banner. Detection + is scoped to the top card and pinned by fixture tests in both directions, including + the false-positive case the review required (recruiter boilerplate quoting the closed + phrase in a *description* must not flag a live job - on the unscoped first version it + did, and the new tests fail there). Only the two markers real closed pages carry are + matched (`closed-job__flavor` and the banner text, verified against live guest + pages); three speculative phrases from the first version were dropped as + false-positive-only risk. `/scrape` Step 2 now consumes the signal: a closed-at-source + job is recorded in `seen_jobs.json` as `"status": "expired"` - marked, never silently + dropped, per the `/rank` pattern - which is the fix for the ghost-LinkedIn-jobs class + in #331 (an expired LinkedIn URL redirects to a *similar live job*, so a stored hit + can die unnoticed between scrape and click). `isActive: true` is documented as + absence of the banner, not proof the posting is open. - **pypdf ATS text-layer fallback** - `/apply` Step 5d and `tools/verify_pdf.py` extract the CV PDF text layer with **pypdf** first (BSD, `pip install pypdf`) so Windows machines without Poppler still get a mechanical parseability check. Poppler `pdftotext -layout -enc UTF-8` remains the fallback; if both are missing the check still degrades to a visual keyword review. No extra cache or installer. `05-cv-templates.md` `framework_version` 1.4.2 → 1.4.3. - **CI now tests the full documented Python range** (#370) - the Python tool tests job runs a 3.10-3.14 version matrix instead of pinning 3.12, so both the documented 3.10