Files
ai-job-search/tests/test_scrape_contract.py
T
8c81edc330 fix(scrape): make seen_jobs.json keys a pure function of the posting (#441)
* fix(scraper): make seen_jobs.json keys a pure function of the posting, and clean up the drift

The dedup key was prose only ("<url_or_company_title_key>"), so different
/scrape runs slugified company+title differently and the state file
accumulated two failures: keys carrying "&", "/", "," and ":" that break
the archive-folder path /apply and /outcome derive from company+role
(documents/README.md's subfolder rule exists because of exactly this),
and the same posting stored twice under two different truncations of a
long title (two Deloitte entries, one job, one URL).

tools/job_key.py makes the key a pure, deterministic function of
company+title+url: a strict allowlist slug, length-capped with a hash of
the full slug so truncation never collides across runs, and a fallback
to the portal's numeric job id when a non-Latin title slugifies to
nothing (a real prior entry, "securion_", would have collided with
every future non-Latin posting from that company).

--audit finds both failure classes in an existing seen_jobs.json without
guessing at a fix: malformed keys (real damage), a legacy three-part
company_title_location shape (harmless but not what the current rule
produces, so it silently re-duplicates on the next scrape), and
duplicate URLs. Ran it against this workspace's file and re-keyed the 15
entries it found - 7 malformed, 8 legacy-shape - verified byte-for-byte
against the pre-cleanup copy that no entry's data changed, only its key.

tests/test_job_key.py (16 tests) covers the slugify rules, the
truncation-hash behavior, both non-Latin fallback paths, and the audit
CLI's exit codes.

* fix(scrape): call the key helper from Step 4 instead of slugifying ad hoc

The helper added in the previous commit is only load-bearing if the spec
calls it. Step 4 described the key as prose ("<url_or_company_title_key>"),
which is what let each run slugify its own way. Step 4 now names the
command, and the schema shows the key's provenance.

Renumbers the trailing list item; no other behaviour in the step changes.

* docs(changelog): record the job-key rule under Unreleased

* fix(scrape): preserve dedup continuity across key rule

* changelog: note that existing seen_jobs.json entries need no migration (#441)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi

---------

Co-authored-by: nox <nox@Mac.home>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:25:15 +02:00

157 lines
7.1 KiB
Python

"""Tests for the /scrape Step 2 search-output contract across portal CLIs.
Mirrors the pattern of test_html_report_command.py: derive the contract from
the spec itself and compare it against the real portal CLIs, so a drift on
either side fails with a clean diff.
Why this test exists: .claude/skills/job-scraper/SKILL.md Step 2 promises
"Search output already includes title, company, location, date, and URL" for
every portal CLI, and Step 4.75's degraded scan flags "company null or empty
on every result" as a half-working parser. A CLI that quietly stops emitting
those fields flags the portal as degraded on every /scrape run while CI stays
green, breaks the seen_jobs.json dedupe (url_or_company_title_key), and leaves
/rank without a posting URL. That failure class landed for real: jobnet-search
emitted only the raw API schema and jobdanmark-search emitted companyName with
no company/location/date keys until both were normalized.
{helpers.ts, commands/search.ts} are the two files where every registered
CLI's search output currently lives (HTML-parsing portals normalize in
helpers.ts, API portals in commands/search.ts). detail.ts is deliberately
excluded: the contract is about the search output /scrape consumes.
"""
import re
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRAPER_SKILL = REPO_ROOT / ".claude" / "skills" / "job-scraper" / "SKILL.md"
PORTAL_CLIS = sorted((REPO_ROOT / ".agents" / "skills").glob("*-search"))
# Derived, never copied: a hardcoded field list drifts in lockstep with
# nothing - if Step 2's prose drops or adds a field, the known-good portals
# and this pin would keep agreeing forever while the contract changed.
_CONTRACT_SENTENCE = re.compile(r"Search output already includes ([a-zA-Z0-9\s,]+)\.", re.MULTILINE)
def derive_contract_fields() -> frozenset[str]:
text = SCRAPER_SKILL.read_text(encoding="utf-8")
match = _CONTRACT_SENTENCE.search(text)
if match is None:
raise AssertionError("Step 2 contract sentence not found in job-scraper/SKILL.md")
fields_text = re.sub(r"\s+and\s+", ",", match.group(1))
fields = {f.strip().lower() for f in fields_text.split(",") if f.strip()}
return frozenset(fields)
def search_output_source(search_ts: Path) -> str:
helpers_ts = search_ts.parent.parent / "helpers.ts"
files = [search_ts, helpers_ts] if helpers_ts.exists() else [search_ts]
return "\n".join(f.read_text(encoding="utf-8") for f in files)
class ScrapeSearchOutputContractTests(unittest.TestCase):
"""Every portal CLI's search output must carry the Step 2 contract fields."""
def test_step2_contract_sentence_is_found_in_the_scraper_skill(self):
"""Guards the anchor the field list is derived from."""
fields = derive_contract_fields()
self.assertGreaterEqual(fields, {"title", "company", "location", "date", "url"})
def test_every_portal_cli_emits_the_step2_contract_fields(self):
contract = derive_contract_fields()
failures: list[str] = []
for portal in PORTAL_CLIS:
search_ts = portal / "cli" / "src" / "commands" / "search.ts"
if not search_ts.exists():
failures.append(f"{portal.name}: no cli/src/commands/search.ts")
continue
source = search_output_source(search_ts)
emitted = set(re.findall(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*):", source, re.MULTILINE))
missing = sorted(contract - emitted)
if missing:
failures.append(f"{portal.name}: missing {missing} in search output")
self.assertEqual([], failures, "; ".join(failures) or "no portal CLIs checked")
# Step 4's storage schema, derived the same way as the Step 2 contract above:
# the field list lives in the spec, never duplicated here, so a schema change
# fails this test instead of silently agreeing with a stale copy.
_STEP4_SCHEMA_BLOCK = re.compile(r"Add ALL fetched jobs.*?```json(.*?)```", re.DOTALL)
def derive_stored_fields() -> frozenset[str]:
text = SCRAPER_SKILL.read_text(encoding="utf-8")
match = _STEP4_SCHEMA_BLOCK.search(text)
if match is None:
raise AssertionError("Step 4 seen_jobs.json schema block not found in job-scraper/SKILL.md")
return frozenset(re.findall(r'"([a-z_]+)":', match.group(1)))
class SeenJobsPostingDateTests(unittest.TestCase):
"""The posting date Step 2 guarantees must survive into Step 4's storage.
Step 2's contract promises a `date` on every portal CLI's search output and
the test above keeps every CLI honest about emitting it. Step 1b then uses
that date to scope the run to the last 14 days - and Step 4's schema drops
it. `first_seen` records when this scraper first saw an entry, not when the
employer posted it, so once the run ends nothing can tell a posting
published yesterday from one published two years ago: the Step 1b window is
unauditable and /rank has no freshness signal to weigh.
That failure landed for real: a freehire-search posting dated 2024-05-13 was
scraped and ranked Strong Fit at position 1 of 133, its own scoring note
observing the listing "may be long stale" with nothing able to act on it.
"""
def test_step4_schema_persists_a_posting_date(self):
stored = derive_stored_fields()
self.assertIn(
"posted_date",
stored,
"Step 4's seen_jobs.json schema stores no posting-date field, so a "
"posting's age is unrecoverable after the run that scraped it",
)
def test_the_step2_date_field_survives_into_storage(self):
contract = derive_contract_fields()
self.assertIn("date", contract, "Step 2 no longer guarantees a posting date")
stored = derive_stored_fields()
self.assertIn(
"posted_date",
stored,
"Step 2 guarantees a posting `date` and CI enforces every CLI emits it, "
"but Step 4 discards it at write time",
)
def test_posted_date_semantics_are_documented(self):
"""A stored field the spec never explains gets backfilled by guessing."""
text = SCRAPER_SKILL.read_text(encoding="utf-8")
self.assertIn("`posted_date`", text, "posted_date is in the schema but never documented")
self.assertRegex(
text,
r"never infer a posting date",
"posted_date must carry the same never-backfill rule as `deadline`",
)
class SeenJobsDedupContinuityTests(unittest.TestCase):
"""The new key rule must not replay jobs stored under legacy keys."""
def test_existing_urls_are_seen_regardless_of_key(self):
text = SCRAPER_SKILL.read_text(encoding="utf-8")
self.assertRegex(
text,
r"URL matches any existing `seen_jobs\.json` entry, regardless of\s+that entry's key",
"legacy seen_jobs entries must be matched by URL during the key-rule transition",
)
def test_step4_presentation_mentions_url_deduplication(self):
text = SCRAPER_SKILL.read_text(encoding="utf-8")
self.assertRegex(text, r"matched by URL or\s+company\+title")
if __name__ == "__main__":
unittest.main()