mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-11 17:37:59 +00:00
* feat(scrape,rank): record and surface a posting's employment type Nullable employment_type (full_time|part_time|contract|freelance) in the seen_jobs.json entry, read from posting text at Step 3; /rank shows it on the shortlist line and flags a mismatch with a stated profile preference (never a veto). No pay normalisation, no new profile field. * fix(scrape,rank): address review on employment type - scope the never-a-veto test to the Employment type paragraph instead of matching the phrase anywhere in rank.md - count portal `detail` output as a source next to the posting text - bracket the optional ` · <employment type>` so a null type leaves no dangling separator - drop the CHANGELOG.md hunk: the file is frozen since #800 and release notes come from the PR title Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv8AZjCnuGt98qsM9zuCFZ --------- Co-authored-by: Claude <noreply@anthropic.com>
256 lines
12 KiB
Python
256 lines
12 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`",
|
|
)
|
|
|
|
|
|
RANK_COMMAND = REPO_ROOT / ".claude" / "commands" / "rank.md"
|
|
EMPLOYMENT_TYPES = ("full_time", "part_time", "contract", "freelance")
|
|
|
|
|
|
class SeenJobsEmploymentTypeTests(unittest.TestCase):
|
|
"""The engagement type a posting states must be stored and surfaced, never guessed.
|
|
|
|
Part-time, contract and freelance postings reach the shortlist
|
|
indistinguishable from permanent roles: Step 3 reads the posting but its
|
|
only outputs are a fit band and highlights, so a "6-month contract" is only
|
|
visible if the reader happens to see the title. Persisting it as a nullable
|
|
field lets /rank show it and flag a mismatch with the user's stated
|
|
preference, without any new profile field.
|
|
"""
|
|
|
|
def test_step4_schema_persists_employment_type(self):
|
|
self.assertIn(
|
|
"employment_type",
|
|
derive_stored_fields(),
|
|
"Step 4's seen_jobs.json schema stores no employment type",
|
|
)
|
|
|
|
def test_schema_lists_every_allowed_value_and_null(self):
|
|
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
|
line = next(l for l in text.splitlines() if l.strip().startswith('"employment_type"'))
|
|
for value in EMPLOYMENT_TYPES:
|
|
self.assertIn(f'"{value}"', line)
|
|
self.assertIn("null", line)
|
|
|
|
def test_employment_type_semantics_are_documented(self):
|
|
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
|
self.assertRegex(
|
|
text,
|
|
r"never infer an employment type",
|
|
"employment_type must carry the same never-backfill rule as `posted_date`",
|
|
)
|
|
|
|
@staticmethod
|
|
def _rank_employment_paragraph() -> str:
|
|
text = RANK_COMMAND.read_text(encoding="utf-8")
|
|
start = text.find("**Employment type:**")
|
|
if start == -1:
|
|
return ""
|
|
end = text.find("\n\n", start)
|
|
return text[start:] if end == -1 else text[start:end]
|
|
|
|
def test_rank_shows_it_as_a_flag_never_a_veto(self):
|
|
# Scoped to the paragraph: "never a veto" is said about age and the
|
|
# posting guard elsewhere in the file, so a file-wide search would
|
|
# keep passing if this paragraph lost the promise.
|
|
paragraph = self._rank_employment_paragraph()
|
|
self.assertIn("employment_type", paragraph, "/rank never reads the stored employment type")
|
|
self.assertRegex(paragraph, r"never a veto", "the mismatch flag must not veto")
|
|
|
|
def test_rank_marks_the_type_and_its_separator_as_optional(self):
|
|
# A null type must not leave a dangling "·" after the score.
|
|
text = RANK_COMMAND.read_text(encoding="utf-8")
|
|
self.assertIn("(78)**[ \u00b7 <employment type>]", text)
|
|
self.assertRegex(self._rank_employment_paragraph(), r"separator included")
|
|
|
|
def test_portal_detail_output_counts_as_a_source(self):
|
|
# Step 3 reads the posting text, but a portal's `detail` output is what
|
|
# Step 2 fetched; the rule must not restrict the source to one of them.
|
|
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
|
start = text.index("**Employment type:**")
|
|
self.assertIn("`detail` output", text[start:text.index("\n\n", start)])
|
|
|
|
|
|
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 `(?:job_scraper/)?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")
|
|
|
|
|
|
# Step 1b sends one plain term per CLI call and takes quoting from the portal's
|
|
# own SKILL.md. The rule is data each portal carries, so the test derives the
|
|
# portal list from the skills tree and names no portal.
|
|
_QUERY_SYNTAX_LINE = re.compile(r"^Query syntax:\s*\S", re.MULTILINE)
|
|
|
|
|
|
class PortalQuerySyntaxTests(unittest.TestCase):
|
|
"""Every installed portal SKILL.md must state its query syntax."""
|
|
|
|
def test_step1b_points_at_the_query_syntax_line(self):
|
|
text = SCRAPER_SKILL.read_text(encoding="utf-8")
|
|
step1b = re.search(r"#### 1b\..*?(?=#### 1c\.)", text, re.DOTALL)
|
|
self.assertIsNotNone(step1b, "Step 1b section not found")
|
|
self.assertIn("`Query syntax:`", step1b.group(0), "Step 1b no longer defers quoting to the portal's Query syntax line")
|
|
self.assertIn("inside the argument the CLI receives", step1b.group(0))
|
|
self.assertIn("deduplicate the pool by URL before Step 2", step1b.group(0))
|
|
|
|
def test_every_installed_portal_skill_has_a_query_syntax_line(self):
|
|
self.assertTrue(PORTAL_CLIS, "no installed portal skills found")
|
|
missing = [
|
|
portal.name
|
|
for portal in PORTAL_CLIS
|
|
if not _QUERY_SYNTAX_LINE.search((portal / "SKILL.md").read_text(encoding="utf-8"))
|
|
]
|
|
self.assertEqual(
|
|
[],
|
|
missing,
|
|
"portal SKILL.md files without a `Query syntax:` line: " + ", ".join(missing),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|