careers-llm-extract

This commit is contained in:
ldy
2026-06-17 22:56:04 -04:00
parent 113a4ced36
commit 41037fb2f5
7 changed files with 1350 additions and 28 deletions

View File

@@ -1,12 +1,13 @@
"""find_careers_page(): orchestrate the Stage 2 tier cascade. """find_careers_page(): orchestrate the Stage 2 tier cascade.
Cascade order (return early on first success): Cascade order (return early on first success):
1. ATS detection → ats.detect_and_fetch() confidence 0.95 1. ATS detection → ats.detect_and_fetch() confidence 0.95
2. URL patterns → heuristics.probe_url_patterns() 0.80 1b. ATS slug-guess → ats.recover_via_slug_guess() confidence 0.90
3. Homepage scan → heuristics.scan_homepage_links() 0.60 2. URL patterns → heuristics.probe_url_patterns() confidence 0.80
4. Sitemap → heuristics.parse_sitemap() 0.50 3. Homepage scan → heuristics.scan_homepage_links() confidence 0.60
5. Cheap-LLM → classify_llm (stub, not implemented in this phase) 4. Sitemap → heuristics.parse_sitemap() confidence 0.50
6. Browser agent → agent_fallback (stub, not implemented in this phase) 5. Cheap-LLM → classify_llm.classify_careers_link() confidence 0.55
6. Browser agent → agent_fallback (not implemented in this phase)
Returns a CareersResult with the URL, confidence, method string, and — when Returns a CareersResult with the URL, confidence, method string, and — when
the ATS tier resolves — the first open-position URL for free (Stage-3 shortcut). the ATS tier resolves — the first open-position URL for free (Stage-3 shortcut).
@@ -24,6 +25,7 @@ from pydantic import BaseModel
from ..http import build_client, request_with_retries from ..http import build_client, request_with_retries
from . import ats as _ats from . import ats as _ats
from . import classify_llm as _classify_llm
from . import heuristics as _heuristics from . import heuristics as _heuristics
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,7 +41,7 @@ class CareersResult(BaseModel):
careers_url: str | None = None careers_url: str | None = None
confidence: float = 0.0 confidence: float = 0.0
# method values: "ats:{name}" | "url_pattern" | "homepage_scan" | "sitemap" | "none" # method values: "ats:{name}" | "url_pattern" | "homepage_scan" | "sitemap" | "llm_classify" | "none"
method: str = "none" method: str = "none"
ats_name: str | None = None ats_name: str | None = None
# Free Stage-3 shortcut: populated when ATS tier resolves (first open job URL). # Free Stage-3 shortcut: populated when ATS tier resolves (first open job URL).
@@ -145,8 +147,23 @@ def find_careers_page(
except Exception as exc: except Exception as exc:
logger.warning("cascade(%s): sitemap tier error: %s", website, exc) logger.warning("cascade(%s): sitemap tier error: %s", website, exc)
# All deterministic tiers missed. # ------------------------------------------------------------------
logger.info("cascade(%s): all deterministic tiers missed", website) # Tier 5 — Cheap-LLM careers-link classification
# ------------------------------------------------------------------
try:
if homepage_html:
anchors = _classify_llm.extract_anchors(homepage_html, website)
res = _classify_llm.classify_careers_link(anchors)
if res:
logger.info(
"cascade(%s): resolved via llm_classify careers_url=%s confidence=%.2f",
website, res.careers_url, res.confidence,
)
return _finalize(res.careers_url, "llm_classify", 0.55, website, client)
except Exception as exc:
logger.warning("cascade(%s): llm_classify tier error: %s", website, exc)
logger.info("cascade(%s): all tiers missed", website)
return CareersResult() return CareersResult()
finally: finally:

View File

@@ -1,13 +1,235 @@
"""Cheap-LLM link classification for careers page and job links (Stage 2, tier 5 / Stage 3, tier 4). """Cheap-LLM link classification for careers page and job links (Stage 2, tier 5 / Stage 3, tier 4).
Scaffold stub -- not implemented yet. Two typed classification functions backed by Pydantic AI:
1. classify_careers_link(anchors) -> CareerLinkResult | None
Given a list of (url, text) pairs from a page, pick the single careers/jobs page URL.
2. classify_job_link(anchors) -> JobLinkResult | None
Given a list of (url, text) pairs from a careers page, pick one specific open-position URL.
Both return None and the cascade falls through gracefully when:
- classifier_model or llm_api_key is a placeholder (LLM not configured),
- the anchor list is empty,
- the LLM call fails for any reason,
- the model abstains (index -1) or returns an out-of-range index.
pydantic_ai is imported lazily inside _build_link_agent so a missing package or
unconfigured provider never breaks module import (graceful-degradation rule #8).
extract_anchors() is also exported here so cascade.py and extract.py can share
the same anchor-parsing logic without a separate utility module.
""" """
# TODO (Stage 2 tier 5 / Stage 3 tier 4): implement per CLAUDE.md "Cheap-LLM classification". from __future__ import annotations
# Uses Pydantic AI (model-agnostic) with the `classifier_model` from config.
# Two typed tasks: import logging
# 1. classify_careers_link(anchors: list[Anchor]) -> CareerLinkResult from urllib.parse import urljoin
# Given extracted <a> tags from a page, pick the careers/jobs page URL.
# 2. classify_job_link(anchors: list[Anchor]) -> JobLinkResult from bs4 import BeautifulSoup
# Given extracted <a> tags from a careers page, pick one open-position URL. from pydantic import BaseModel, Field
# Both return a typed Pydantic result including the chosen URL and confidence.
# Graceful degradation: if llm_api_key is placeholder or call fails, return None. from ..config import get_settings
logger = logging.getLogger(__name__)
# Maximum number of anchors forwarded to the LLM (token-budget guard).
_MAX_ANCHORS = 60
# Maximum anchor text length, in characters, before truncation.
_MAX_ANCHOR_TEXT = 80
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
class Anchor(BaseModel):
"""One hyperlink extracted from a page — the LLM's input row."""
url: str
text: str = ""
class _LinkChoice(BaseModel):
"""LLM structured output: 0-based index into the candidate list + confidence."""
index: int = Field(
description=(
"0-based index of the best link in the candidate list. "
"Use -1 if no link qualifies."
)
)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class CareerLinkResult(BaseModel):
"""Public result of classify_careers_link()."""
careers_url: str
confidence: float = 0.0
class JobLinkResult(BaseModel):
"""Public result of classify_job_link()."""
job_url: str
confidence: float = 0.0
# ---------------------------------------------------------------------------
# System-level prompt instructions for each classification task
# ---------------------------------------------------------------------------
_CAREERS_INSTRUCTIONS = (
"You are a link classifier for a job-sourcing pipeline. "
"You receive a numbered list of hyperlinks extracted from a company website. "
"Identify the single link that leads to the company's main careers or jobs page "
"(the page that lists open positions). "
"Respond with the 0-based index of the best match and your confidence (0.01.0). "
"If none of the links leads to a careers or jobs page, respond with index -1."
)
_JOB_INSTRUCTIONS = (
"You are a link classifier for a job-sourcing pipeline. "
"You receive a numbered list of hyperlinks extracted from a careers/jobs page. "
"Identify the single link that leads directly to one specific open job-posting detail page "
"(a page for exactly one position — not a category page, a filter, or a job listing index). "
"Respond with the 0-based index of the best match and your confidence (0.01.0). "
"If none of the links qualifies as a single-job detail page, respond with index -1."
)
# ---------------------------------------------------------------------------
# Shared anchor extractor (used by cascade.py Tier 5 and extract.py Tiers 3-4)
# ---------------------------------------------------------------------------
def extract_anchors(html: str, base_url: str) -> list[Anchor]:
"""Extract deduplicated absolute hyperlinks from HTML.
Skips mailto:, tel:, # (fragment-only), and javascript: hrefs.
Converts relative hrefs to absolute using base_url.
Returns only http(s) links in first-seen order, URL-deduplicated.
Truncates anchor text to _MAX_ANCHOR_TEXT characters.
"""
if not html:
return []
soup = BeautifulSoup(html, "lxml")
seen_urls: set[str] = set()
result: list[Anchor] = []
for tag in soup.find_all("a", href=True):
href: str = tag["href"].strip()
if not href or href.startswith(("mailto:", "tel:", "#", "javascript:")):
continue
full_url = urljoin(base_url, href)
if not full_url.startswith(("http://", "https://")):
continue
if full_url in seen_urls:
continue
seen_urls.add(full_url)
text = tag.get_text(separator=" ", strip=True)[:_MAX_ANCHOR_TEXT]
result.append(Anchor(url=full_url, text=text))
return result
# ---------------------------------------------------------------------------
# LLM availability gate
# ---------------------------------------------------------------------------
def _llm_unavailable() -> bool:
"""Return True when the LLM is not configured (placeholder values in settings)."""
s = get_settings()
return (
s.classifier_model.startswith("PLACEHOLDER")
or s.llm_api_key.startswith("PLACEHOLDER")
)
# ---------------------------------------------------------------------------
# Agent builder — extracted as a named function to serve as the test seam.
# Monkeypatch this function in tests to avoid real LLM calls.
# ---------------------------------------------------------------------------
def _build_link_agent(instructions: str): # type: ignore[return]
"""Construct a Pydantic AI Agent for link classification.
pydantic_ai is imported lazily here so a missing package never crashes
module import. This function is the primary test seam — monkeypatch it
to inject a FakeAgent without touching the real Pydantic AI machinery.
"""
from pydantic_ai import Agent # lazy import
return Agent(
get_settings().classifier_model,
output_type=_LinkChoice,
instructions=instructions,
)
# ---------------------------------------------------------------------------
# Core shared classifier
# ---------------------------------------------------------------------------
def _render_candidates(anchors: list[Anchor]) -> str:
"""Format anchors as a numbered list for the LLM prompt."""
lines = [f"[{i}] {a.url} | {a.text}" for i, a in enumerate(anchors)]
return "\n".join(lines)
def _choose_link(
anchors: list[Anchor],
*,
instructions: str,
label: str,
) -> "_LinkChoice | None":
"""Run the LLM classifier on anchors; return a _LinkChoice or None.
Returns None when the LLM is unconfigured, the list is empty, the model
abstains (index -1), the index is out of range, or any exception occurs.
"""
if not anchors or _llm_unavailable():
return None
candidates = anchors[:_MAX_ANCHORS]
try:
agent = _build_link_agent(instructions)
prompt = _render_candidates(candidates)
out = agent.run_sync(prompt).output
if isinstance(out, _LinkChoice) and 0 <= out.index < len(candidates):
return out
return None # abstain (index -1) or out-of-range
except Exception as exc:
logger.warning("classify_llm(%s): tier error: %s", label, exc)
return None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def classify_careers_link(anchors: list[Anchor]) -> CareerLinkResult | None:
"""Pick the careers/jobs page URL from a list of extracted anchors (Stage 2, tier 5).
Returns a CareerLinkResult on success, or None on any no-op / failure.
Never raises.
"""
c = _choose_link(anchors, instructions=_CAREERS_INSTRUCTIONS, label="careers")
if c is None:
return None
candidates = anchors[:_MAX_ANCHORS]
return CareerLinkResult(careers_url=candidates[c.index].url, confidence=c.confidence)
def classify_job_link(anchors: list[Anchor]) -> JobLinkResult | None:
"""Pick one specific open-position URL from a careers-page anchor list (Stage 3, tier 4).
Returns a JobLinkResult on success, or None on any no-op / failure.
Never raises.
"""
c = _choose_link(anchors, instructions=_JOB_INSTRUCTIONS, label="job")
if c is None:
return None
candidates = anchors[:_MAX_ANCHORS]
return JobLinkResult(job_url=candidates[c.index].url, confidence=c.confidence)

View File

@@ -1,12 +1,214 @@
"""Extract one open position URL from a careers page (Stage 3). """Extract one open position URL from a careers page (Stage 3).
Scaffold stub -- not implemented yet. Cascade order (return early on first hit):
1. ATS JSON (URL) — detect ATS board coordinates in the careers URL itself;
call the board's public JSON API for the first posting URL.
1b. ATS JSON (HTML) — fetch the careers page; detect ATS board in its HTML;
call the board API. Handles inline JS embeds.
2. JSON-LD — scan <script type="application/ld+json"> for a
JobPosting node with a `url` field.
3. Job-like anchors — first <a> whose href matches /job|/position|/opening|/vacanc.
4. Cheap-LLM — classify_job_link() from classify_llm; no-ops gracefully
when the LLM is not configured.
5. Miss — return (None, "none").
Returns (url: str | None, method: str). Never raises — any tier failure logs
and falls through. method is one of:
"ats:{name}" | "json_ld" | "anchor" | "llm_classify" | "none"
""" """
# TODO (Stage 3): implement per CLAUDE.md "Stage 3 — Extract one open position (return on first hit)". from __future__ import annotations
# Cascade order (return early on first hit):
# 1. ATS JSON — if ATS is already known from Stage 2, return first posting URL directly. import json
# 2. JobPosting JSON-LD — parse application/ld+json for a `url` field. import logging
# 3. Job-like anchors — first <a> matching /job, /position, /opening, /vacancy in href. import re
# 4. Cheap-LLM classification — Pydantic AI typed output (classifier_model). from urllib.parse import urlsplit
# 5. Browser-agent fallback — handled inside the fused Stage-2 agent call in agent_fallback.py.
# Returns (url: str | None, method: str) so callers know which tier resolved it. import httpx
from bs4 import BeautifulSoup
from .careers import ats as _ats
from .careers.classify_llm import classify_job_link, extract_anchors
from .http import build_client, request_with_retries
logger = logging.getLogger(__name__)
# Matches href paths that indicate a single-job detail page.
_JOB_HREF_RE = re.compile(r"/(job|position|opening|vacanc)", re.IGNORECASE)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _get_html(url: str, client: httpx.Client) -> str | None:
"""Fetch page HTML; return text on success or None on any error/non-2xx."""
try:
resp = request_with_retries(client, "GET", url, max_retries=1)
if resp.status_code < 400:
return resp.text
logger.info("extract._get_html(%s): HTTP %s", url, resp.status_code)
return None
except Exception as exc:
logger.info("extract._get_html(%s): fetch error: %s", url, exc)
return None
def _jsonld_job_url(html: str) -> str | None:
"""Scan every <script type="application/ld+json"> block for a JobPosting url."""
soup = BeautifulSoup(html, "lxml")
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.get_text(strip=True))
except (json.JSONDecodeError, ValueError):
continue
url = _find_job_posting_url(data)
if url:
return url
return None
def _find_job_posting_url(data: object) -> str | None:
"""Walk a JSON-LD structure (dict, list, @graph) for a JobPosting url field."""
if isinstance(data, list):
for item in data:
result = _find_job_posting_url(item)
if result:
return result
return None
if not isinstance(data, dict):
return None
# Expand @graph (JSON-LD named graph — a list of typed nodes)
graph = data.get("@graph")
if graph:
result = _find_job_posting_url(graph)
if result:
return result
# Check this node's @type
type_val = data.get("@type", "")
if isinstance(type_val, list):
is_job = "JobPosting" in type_val
else:
is_job = type_val == "JobPosting"
if is_job:
url = data.get("url")
if url and isinstance(url, str):
return url
return None
def _fetch_ats_position(board: "_ats.ATSBoard", client: httpx.Client) -> str | None:
"""Call the ATS public JSON API for *board* and return the first job URL or None."""
fetch_fn = _ats._FETCH_DISPATCH.get(board.ats_name)
if fetch_fn is None:
return None
fetch = fetch_fn(board, client) # type: ignore[operator]
return fetch.first_url
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def extract_open_position(
careers_url: str,
*,
client: httpx.Client | None = None,
) -> tuple[str | None, str]:
"""Return (open_position_url, method) for a careers page. Never raises.
Tries ATS JSON → JSON-LD → job-like anchor → LLM classifier in order,
returning on the first hit. method names which tier resolved it.
"""
_managed = client is None
if _managed:
client = build_client()
try:
# ------------------------------------------------------------------
# Tier 1 — ATS JSON from the careers URL string (no page fetch needed)
# ------------------------------------------------------------------
board = _ats.detect_ats_in_url(careers_url)
if board is not None:
try:
pos_url = _fetch_ats_position(board, client)
if pos_url:
logger.info(
"extract(%s): ats:%s from_url pos=%s",
careers_url, board.ats_name, pos_url,
)
return pos_url, f"ats:{board.ats_name}"
except Exception as exc:
logger.warning("extract(%s): ats url-tier error: %s", careers_url, exc)
# ------------------------------------------------------------------
# Fetch page HTML — shared by all remaining tiers
# ------------------------------------------------------------------
html = _get_html(careers_url, client)
if html is None:
logger.info("extract(%s): page fetch failed; skipping remaining tiers", careers_url)
return None, "none"
# ------------------------------------------------------------------
# Tier 1b — ATS JSON from page HTML (handles JS-embedded board pages)
# ------------------------------------------------------------------
html_board = _ats.detect_ats_in_html(html)
if html_board is not None:
try:
pos_url2 = _fetch_ats_position(html_board, client)
if pos_url2:
logger.info(
"extract(%s): ats:%s from_html pos=%s",
careers_url, html_board.ats_name, pos_url2,
)
return pos_url2, f"ats:{html_board.ats_name}"
except Exception as exc:
logger.warning("extract(%s): ats html-tier error: %s", careers_url, exc)
# ------------------------------------------------------------------
# Tier 2 — JobPosting JSON-LD
# ------------------------------------------------------------------
try:
jld_url = _jsonld_job_url(html)
if jld_url:
logger.info("extract(%s): json_ld pos=%s", careers_url, jld_url)
return jld_url, "json_ld"
except Exception as exc:
logger.warning("extract(%s): json_ld tier error: %s", careers_url, exc)
# ------------------------------------------------------------------
# Tier 3 & 4 — Job-like anchors, then LLM (share one extraction pass)
# ------------------------------------------------------------------
try:
anchors = extract_anchors(html, careers_url)
except Exception as exc:
logger.warning("extract(%s): anchor extraction error: %s", careers_url, exc)
anchors = []
# Tier 3 — first anchor whose URL *path* matches the job-URL pattern.
# We search only the path component so hostnames like jobs.lever.co
# don't trigger a false match (e.g. `//jobs` contains `/job`).
try:
for anchor in anchors:
if _JOB_HREF_RE.search(urlsplit(anchor.url).path):
logger.info("extract(%s): anchor pos=%s", careers_url, anchor.url)
return anchor.url, "anchor"
except Exception as exc:
logger.warning("extract(%s): anchor tier error: %s", careers_url, exc)
# Tier 4 — cheap-LLM job-link classifier (no-ops when LLM unconfigured)
try:
llm_result = classify_job_link(anchors)
if llm_result is not None:
logger.info("extract(%s): llm_classify pos=%s", careers_url, llm_result.job_url)
return llm_result.job_url, "llm_classify"
except Exception as exc:
logger.warning("extract(%s): llm_classify tier error: %s", careers_url, exc)
logger.info("extract(%s): all tiers missed", careers_url)
return None, "none"
finally:
if _managed:
client.close()

207
scripts/e2e_smoke.py Normal file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python
"""End-to-end smoke test: Stage 1→3 on a 10-job batch.
Usage: .venv/bin/python scripts/e2e_smoke.py
"""
from __future__ import annotations
import logging
import sys
import time
from dataclasses import dataclass, field
# Make sure we run from the repo root so imports resolve correctly.
sys.path.insert(0, ".")
logging.basicConfig(
level=logging.WARNING, # keep jobspy/httpx chatter suppressed
format="%(name)s: %(message)s",
)
# Enable our own modules at INFO so we see cascade method names.
for _mod in ("jobsource.resolve", "jobsource.careers.cascade",
"jobsource.careers.ats", "jobsource.careers.heuristics",
"jobsource.careers.classify_llm", "jobsource.extract"):
logging.getLogger(_mod).setLevel(logging.INFO)
from jobsource.careers.cascade import find_careers_page
from jobsource.config import get_settings
from jobsource.extract import extract_open_position
from jobsource.http import build_client
from jobsource.models import RawJob
from jobsource.resolve import resolve_website
from jobsource.sources.jobspy_source import JobSpySource
# ---------------------------------------------------------------------------
# Per-record result
# ---------------------------------------------------------------------------
@dataclass
class Result:
job_id: str
company: str
website: str | None = None
careers_url: str | None = None
careers_method: str | None = None
position_url: str | None = None
position_method: str | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# Main runner
# ---------------------------------------------------------------------------
BATCH_SIZE = 10
SEARCH_TERM = "software engineer"
LOCATION = "United States"
HOURS_OLD = 72
def run() -> None:
settings = get_settings()
print(f"\n{'='*64}")
print(f" E2E smoke batch={BATCH_SIZE} search={SEARCH_TERM!r} hours_old={HOURS_OLD}")
print(f"{'='*64}\n")
# ------------------------------------------------------------------
# Stage 1 — Ingest
# ------------------------------------------------------------------
print("Stage 1: ingesting…")
source = JobSpySource()
t0 = time.perf_counter()
raw_jobs: list[RawJob] = source.fetch_recent_jobs(
search_terms=[SEARCH_TERM],
location=LOCATION,
hours_old=HOURS_OLD,
results_wanted=BATCH_SIZE + 10, # fetch a few extra to cover dupes
)
elapsed = time.perf_counter() - t0
raw_jobs = raw_jobs[:BATCH_SIZE]
print(f" Ingested {len(raw_jobs)} unique jobs in {elapsed:.1f}s\n")
if not raw_jobs:
print("No jobs returned — aborting.")
return
results: list[Result] = []
with build_client() as client:
for raw in raw_jobs:
res = Result(job_id=raw.job_id, company=raw.company)
print(f"[{raw.job_id}] {raw.company}")
# -------------------------------------------------------
# Stage 1b — Resolve website
# -------------------------------------------------------
try:
res.website = resolve_website(
raw.company, raw.website, client=client
)
except Exception as exc:
res.error = f"resolve: {exc}"
print(f" resolve ERROR: {exc}")
results.append(res)
continue
if not res.website:
print(f" website: UNRESOLVED")
results.append(res)
continue
print(f" website: {res.website}")
# -------------------------------------------------------
# Stage 2 — Find careers page
# -------------------------------------------------------
try:
cr = find_careers_page(
res.website,
company_name=raw.company,
client=client,
)
res.careers_url = cr.careers_url
res.careers_method = cr.method
except Exception as exc:
res.error = f"cascade: {exc}"
print(f" careers ERROR: {exc}")
results.append(res)
continue
if not res.careers_url:
print(f" careers: MISSED (method={cr.method})")
results.append(res)
continue
print(f" careers: {res.careers_url} [{cr.method} conf={cr.confidence:.2f}]")
# -------------------------------------------------------
# Stage 3 — Extract one open position
# -------------------------------------------------------
try:
pos_url, pos_method = extract_open_position(
res.careers_url, client=client
)
res.position_url = pos_url
res.position_method = pos_method
except Exception as exc:
res.error = f"extract: {exc}"
print(f" position ERROR: {exc}")
results.append(res)
continue
if res.position_url:
print(f" position: {res.position_url} [{pos_method}]")
else:
print(f" position: MISSED (method={pos_method})")
results.append(res)
print()
# ------------------------------------------------------------------
# Summary
# ------------------------------------------------------------------
n_total = len(results)
n_website = sum(1 for r in results if r.website)
n_careers = sum(1 for r in results if r.careers_url)
n_position = sum(1 for r in results if r.position_url)
n_errors = sum(1 for r in results if r.error)
print(f"\n{'='*64}")
print(f" SUMMARY ({n_total} jobs processed)")
print(f"{'='*64}")
print(f" Stage 1 ingested {n_total:>3}")
print(f" Stage 1b website resolved {n_website:>3} / {n_total} ({n_website/n_total*100:.0f}%)")
print(f" Stage 2 careers found {n_careers:>3} / {n_website} ({n_careers/max(n_website,1)*100:.0f}% of resolved)")
print(f" Stage 3 position found {n_position:>3} / {n_careers} ({n_position/max(n_careers,1)*100:.0f}% of careers)")
print(f" Errors {n_errors:>3}")
# careers method breakdown
if n_careers:
method_counts: dict[str, int] = {}
for r in results:
if r.careers_method and r.careers_url:
method_counts[r.careers_method] = method_counts.get(r.careers_method, 0) + 1
print(f"\n Careers method breakdown:")
for m, c in sorted(method_counts.items(), key=lambda x: -x[1]):
print(f" {m:<30} {c}")
# position method breakdown
if n_position:
pos_method_counts: dict[str, int] = {}
for r in results:
if r.position_method and r.position_url:
pos_method_counts[r.position_method] = pos_method_counts.get(r.position_method, 0) + 1
print(f"\n Position method breakdown:")
for m, c in sorted(pos_method_counts.items(), key=lambda x: -x[1]):
print(f" {m:<30} {c}")
# CSV-ready output
print(f"\n{'='*64}")
print(" company_name, career_page_url, open_position_url")
print(f"{'='*64}")
for r in results:
print(f" {r.company!r}, {r.careers_url or ''!r}, {r.position_url or ''!r}")
print()
if __name__ == "__main__":
run()

View File

@@ -550,3 +550,112 @@ class TestSlugGuessTier:
result = find_careers_page("https://www.acme.com", client=FakeClient()) result = find_careers_page("https://www.acme.com", client=FakeClient())
assert result.method == "url_pattern" assert result.method == "url_pattern"
assert result.careers_url == "https://acme.com/careers" assert result.careers_url == "https://acme.com/careers"
# ---------------------------------------------------------------------------
# Tier 5 — Cheap-LLM careers-link classification
# ---------------------------------------------------------------------------
class TestLLMClassifyTier:
"""Tier 5 fires after sitemap, no-ops when LLM returns None, and is skipped on earlier hits."""
def _patch_all_deterministic_miss(self, monkeypatch, *, homepage_html: str = "<html/>") -> None:
"""Patch tiers 14 to all miss; leaves Tier 5 for the caller to control."""
monkeypatch.setattr(
"jobsource.careers.cascade._safe_get_html",
lambda website, client: homepage_html,
)
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_and_fetch", lambda *a, **kw: None)
monkeypatch.setattr("jobsource.careers.cascade._ats.recover_via_slug_guess", lambda *a, **kw: None)
monkeypatch.setattr("jobsource.careers.cascade._heuristics.probe_url_patterns", lambda *a, **kw: None)
monkeypatch.setattr("jobsource.careers.cascade._heuristics.scan_homepage_links", lambda *a, **kw: None)
monkeypatch.setattr("jobsource.careers.cascade._heuristics.parse_sitemap", lambda *a, **kw: None)
# Prevent _finalize from making network calls
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_ats_in_url", lambda url: None)
def test_llm_classify_hit_returns_055_confidence(self, monkeypatch):
from jobsource.careers.classify_llm import Anchor, CareerLinkResult
self._patch_all_deterministic_miss(
monkeypatch,
homepage_html='<html><body><a href="/careers">Careers</a></body></html>',
)
monkeypatch.setattr(
"jobsource.careers.cascade._classify_llm.extract_anchors",
lambda html, base_url: [Anchor(url="https://acme.com/careers", text="Careers")],
)
monkeypatch.setattr(
"jobsource.careers.cascade._classify_llm.classify_careers_link",
lambda anchors: CareerLinkResult(careers_url="https://acme.com/careers", confidence=0.85),
)
result = find_careers_page("https://www.acme.com", client=FakeClient())
assert result.method == "llm_classify"
assert result.confidence == 0.55 # fixed tier-level confidence from cascade
assert result.careers_url == "https://acme.com/careers"
def test_llm_classify_none_falls_through_to_method_none(self, monkeypatch):
from jobsource.careers.classify_llm import Anchor
self._patch_all_deterministic_miss(monkeypatch)
monkeypatch.setattr(
"jobsource.careers.cascade._classify_llm.extract_anchors",
lambda html, base_url: [Anchor(url="https://acme.com/about", text="About")],
)
monkeypatch.setattr(
"jobsource.careers.cascade._classify_llm.classify_careers_link",
lambda anchors: None,
)
result = find_careers_page("https://www.acme.com", client=FakeClient())
assert result.method == "none"
assert result.careers_url is None
def test_tier5_skipped_when_homepage_html_is_none(self, monkeypatch):
"""When the homepage fetch returns None, Tier 5 is silently bypassed."""
llm_called: list[bool] = []
self._patch_all_deterministic_miss(monkeypatch)
monkeypatch.setattr(
"jobsource.careers.cascade._safe_get_html",
lambda website, client: None,
)
monkeypatch.setattr(
"jobsource.careers.cascade._classify_llm.classify_careers_link",
lambda anchors: llm_called.append(True) or None,
)
find_careers_page("https://www.acme.com", client=FakeClient())
assert llm_called == []
def test_tier5_not_called_when_sitemap_hits(self, monkeypatch):
"""When sitemap resolves, the cascade returns before reaching Tier 5."""
llm_called: list[bool] = []
monkeypatch.setattr(
"jobsource.careers.cascade._safe_get_html",
lambda website, client: "<html/>",
)
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_and_fetch", lambda *a, **kw: None)
monkeypatch.setattr("jobsource.careers.cascade._ats.recover_via_slug_guess", lambda *a, **kw: None)
monkeypatch.setattr("jobsource.careers.cascade._heuristics.probe_url_patterns", lambda *a, **kw: None)
monkeypatch.setattr("jobsource.careers.cascade._heuristics.scan_homepage_links", lambda *a, **kw: None)
monkeypatch.setattr(
"jobsource.careers.cascade._heuristics.parse_sitemap",
lambda *a, **kw: "https://acme.com/careers",
)
monkeypatch.setattr("jobsource.careers.cascade._ats.detect_ats_in_url", lambda url: None)
monkeypatch.setattr(
"jobsource.careers.cascade._classify_llm.classify_careers_link",
lambda anchors: llm_called.append(True) or None,
)
result = find_careers_page("https://www.acme.com", client=FakeClient())
assert result.method == "sitemap"
assert llm_called == []
def test_tier5_error_falls_through_gracefully(self, monkeypatch):
"""A Tier 5 exception should not abort the cascade."""
self._patch_all_deterministic_miss(monkeypatch)
monkeypatch.setattr(
"jobsource.careers.cascade._classify_llm.extract_anchors",
lambda html, base_url: (_ for _ in ()).throw(RuntimeError("classify boom")),
)
result = find_careers_page("https://www.acme.com", client=FakeClient())
assert result.method == "none"
assert result.careers_url is None

256
tests/test_classify_llm.py Normal file
View File

@@ -0,0 +1,256 @@
"""Tests for careers/classify_llm.py — all network-free via monkeypatching."""
from __future__ import annotations
import pytest
from jobsource.careers.classify_llm import (
Anchor,
CareerLinkResult,
JobLinkResult,
_LinkChoice,
classify_careers_link,
classify_job_link,
extract_anchors,
)
# ---------------------------------------------------------------------------
# Fake helpers
# ---------------------------------------------------------------------------
class _FakeResult:
"""Minimal stand-in for a Pydantic AI run result."""
def __init__(self, output: _LinkChoice) -> None:
self.output = output
class _FakeAgent:
"""Stand-in for a Pydantic AI Agent; returns a fixed _LinkChoice."""
def __init__(self, choice: _LinkChoice) -> None:
self._choice = choice
def run_sync(self, prompt: str) -> _FakeResult:
return _FakeResult(self._choice)
class _RaisingAgent:
"""Stand-in that raises on run_sync, simulating an LLM failure."""
def run_sync(self, prompt: str) -> _FakeResult:
raise RuntimeError("LLM unavailable")
def _real_settings():
"""Fake settings object with non-placeholder values."""
return type("S", (), {
"classifier_model": "openai:gpt-4o-mini",
"llm_api_key": "sk-real-key",
})()
# ---------------------------------------------------------------------------
# extract_anchors
# ---------------------------------------------------------------------------
_SAMPLE_HTML = """
<html><body>
<a href="/about">About Us</a>
<a href="/careers">Careers</a>
<a href="https://external.com/jobs">External Jobs</a>
<a href="mailto:info@acme.com">Email</a>
<a href="#section">Anchor</a>
<a href="javascript:void(0)">JS link</a>
<a href="/careers">Duplicate careers link</a>
</body></html>
"""
class TestExtractAnchors:
def test_resolves_relative_to_absolute(self):
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
urls = [a.url for a in anchors]
assert "https://acme.com/about" in urls
assert "https://acme.com/careers" in urls
def test_preserves_already_absolute_urls(self):
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
urls = [a.url for a in anchors]
assert "https://external.com/jobs" in urls
def test_skips_mailto(self):
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
assert all("mailto:" not in a.url for a in anchors)
def test_skips_hash_fragments(self):
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
# A pure # anchor resolves to base_url itself (https://acme.com) —
# it starts with http so it passes the scheme check but we skip hrefs
# that start with "#". Verify the JavaScript link is also gone.
assert all("javascript:" not in a.url for a in anchors)
def test_deduplicates_by_url(self):
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
urls = [a.url for a in anchors]
assert urls.count("https://acme.com/careers") == 1
def test_captures_anchor_text(self):
anchors = extract_anchors(_SAMPLE_HTML, "https://acme.com")
career_anchor = next(a for a in anchors if a.url == "https://acme.com/careers")
assert career_anchor.text == "Careers"
def test_empty_html_returns_empty_list(self):
assert extract_anchors("", "https://acme.com") == []
def test_html_with_no_links_returns_empty_list(self):
assert extract_anchors("<html><body><p>No links here.</p></body></html>", "https://acme.com") == []
def test_text_truncated_at_max_length(self):
long_text = "x" * 200
html = f'<html><body><a href="/careers">{long_text}</a></body></html>'
anchors = extract_anchors(html, "https://acme.com")
assert len(anchors) == 1
assert len(anchors[0].text) <= 80
# ---------------------------------------------------------------------------
# Graceful degradation: placeholder settings → always None
# ---------------------------------------------------------------------------
class TestPlaceholderGate:
"""When classifier_model or llm_api_key is a placeholder, classifiers no-op."""
def test_classify_careers_link_returns_none_with_placeholder(self):
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
assert classify_careers_link(anchors) is None
def test_classify_job_link_returns_none_with_placeholder(self):
anchors = [Anchor(url="https://acme.com/jobs/123", text="Engineer")]
assert classify_job_link(anchors) is None
def test_classify_careers_link_returns_none_on_empty_list(self, monkeypatch):
monkeypatch.setattr(
"jobsource.careers.classify_llm.get_settings",
_real_settings,
)
assert classify_careers_link([]) is None
def test_classify_job_link_returns_none_on_empty_list(self, monkeypatch):
monkeypatch.setattr(
"jobsource.careers.classify_llm.get_settings",
_real_settings,
)
assert classify_job_link([]) is None
# ---------------------------------------------------------------------------
# Success paths via _build_link_agent monkeypatch
# ---------------------------------------------------------------------------
class TestClassifySuccess:
def _patch_agent(self, monkeypatch, choice: _LinkChoice) -> None:
monkeypatch.setattr(
"jobsource.careers.classify_llm.get_settings",
_real_settings,
)
monkeypatch.setattr(
"jobsource.careers.classify_llm._build_link_agent",
lambda instructions: _FakeAgent(choice),
)
def test_careers_pick_returns_mapped_url(self, monkeypatch):
anchors = [
Anchor(url="https://acme.com/about", text="About"),
Anchor(url="https://acme.com/careers", text="Careers"),
]
self._patch_agent(monkeypatch, _LinkChoice(index=1, confidence=0.95))
result = classify_careers_link(anchors)
assert result is not None
assert isinstance(result, CareerLinkResult)
assert result.careers_url == "https://acme.com/careers"
assert result.confidence == 0.95
def test_job_pick_returns_mapped_url(self, monkeypatch):
anchors = [
Anchor(url="https://acme.com/jobs/", text="All Jobs"),
Anchor(url="https://acme.com/jobs/123", text="Software Engineer"),
]
self._patch_agent(monkeypatch, _LinkChoice(index=1, confidence=0.85))
result = classify_job_link(anchors)
assert result is not None
assert isinstance(result, JobLinkResult)
assert result.job_url == "https://acme.com/jobs/123"
assert result.confidence == 0.85
def test_careers_first_index_zero(self, monkeypatch):
anchors = [Anchor(url="https://acme.com/jobs", text="Jobs")]
self._patch_agent(monkeypatch, _LinkChoice(index=0, confidence=0.70))
result = classify_careers_link(anchors)
assert result is not None
assert result.careers_url == "https://acme.com/jobs"
# ---------------------------------------------------------------------------
# Abstain and failure paths
# ---------------------------------------------------------------------------
class TestClassifyFailurePaths:
def _patch_real_settings(self, monkeypatch) -> None:
monkeypatch.setattr(
"jobsource.careers.classify_llm.get_settings",
_real_settings,
)
def test_abstain_index_minus_one_returns_none(self, monkeypatch):
self._patch_real_settings(monkeypatch)
monkeypatch.setattr(
"jobsource.careers.classify_llm._build_link_agent",
lambda instructions: _FakeAgent(_LinkChoice(index=-1, confidence=0.0)),
)
anchors = [Anchor(url="https://acme.com/about", text="About")]
assert classify_careers_link(anchors) is None
def test_out_of_range_index_returns_none(self, monkeypatch):
self._patch_real_settings(monkeypatch)
monkeypatch.setattr(
"jobsource.careers.classify_llm._build_link_agent",
lambda instructions: _FakeAgent(_LinkChoice(index=99, confidence=0.9)),
)
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
assert classify_careers_link(anchors) is None
def test_run_sync_exception_returns_none(self, monkeypatch):
self._patch_real_settings(monkeypatch)
monkeypatch.setattr(
"jobsource.careers.classify_llm._build_link_agent",
lambda instructions: _RaisingAgent(),
)
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
assert classify_careers_link(anchors) is None
def test_build_agent_exception_returns_none(self, monkeypatch):
self._patch_real_settings(monkeypatch)
def boom(instructions: str):
raise RuntimeError("import error")
monkeypatch.setattr(
"jobsource.careers.classify_llm._build_link_agent",
boom,
)
anchors = [Anchor(url="https://acme.com/careers", text="Careers")]
assert classify_careers_link(anchors) is None
def test_job_classify_run_sync_exception_returns_none(self, monkeypatch):
self._patch_real_settings(monkeypatch)
monkeypatch.setattr(
"jobsource.careers.classify_llm._build_link_agent",
lambda instructions: _RaisingAgent(),
)
anchors = [Anchor(url="https://acme.com/jobs/123", text="Engineer")]
assert classify_job_link(anchors) is None

309
tests/test_extract.py Normal file
View File

@@ -0,0 +1,309 @@
"""Tests for extract.py (Stage 3) — all network-free via monkeypatching."""
from __future__ import annotations
import pytest
from jobsource.careers.ats import ATSBoard, ATSFetch
from jobsource.careers.classify_llm import Anchor, JobLinkResult
from jobsource.extract import _find_job_posting_url, _jsonld_job_url, extract_open_position
# ---------------------------------------------------------------------------
# Fake helpers
# ---------------------------------------------------------------------------
class FakeResponse:
def __init__(self, status_code: int, text: str = "", url: str = "https://example.com") -> None:
self.status_code = status_code
self.text = text
self.url = url
class FakeClient:
pass
# ---------------------------------------------------------------------------
# _find_job_posting_url (pure function)
# ---------------------------------------------------------------------------
class TestFindJobPostingUrl:
def test_flat_dict_job_posting(self):
data = {"@type": "JobPosting", "url": "https://acme.com/jobs/1"}
assert _find_job_posting_url(data) == "https://acme.com/jobs/1"
def test_list_of_nodes(self):
data = [
{"@type": "Organization", "name": "Acme"},
{"@type": "JobPosting", "url": "https://acme.com/jobs/2"},
]
assert _find_job_posting_url(data) == "https://acme.com/jobs/2"
def test_graph_expansion(self):
data = {
"@context": "https://schema.org",
"@graph": [
{"@type": "WebPage"},
{"@type": "JobPosting", "url": "https://acme.com/jobs/3"},
],
}
assert _find_job_posting_url(data) == "https://acme.com/jobs/3"
def test_type_as_list(self):
data = {"@type": ["Thing", "JobPosting"], "url": "https://acme.com/jobs/4"}
assert _find_job_posting_url(data) == "https://acme.com/jobs/4"
def test_no_job_posting_returns_none(self):
data = {"@type": "Organization", "name": "Acme"}
assert _find_job_posting_url(data) is None
def test_job_posting_without_url_returns_none(self):
data = {"@type": "JobPosting"}
assert _find_job_posting_url(data) is None
def test_scalar_input_returns_none(self):
assert _find_job_posting_url("not a dict") is None
assert _find_job_posting_url(42) is None
# ---------------------------------------------------------------------------
# _jsonld_job_url (parses HTML)
# ---------------------------------------------------------------------------
class TestJsonldJobUrl:
def test_finds_url_in_json_ld_block(self):
html = """
<html><head>
<script type="application/ld+json">
{"@type": "JobPosting", "url": "https://acme.com/jobs/99"}
</script>
</head></html>
"""
assert _jsonld_job_url(html) == "https://acme.com/jobs/99"
def test_invalid_json_block_skipped(self):
html = """
<html><head>
<script type="application/ld+json">NOT JSON{{</script>
<script type="application/ld+json">{"@type": "JobPosting", "url": "https://ok.com/job/1"}</script>
</head></html>
"""
assert _jsonld_job_url(html) == "https://ok.com/job/1"
def test_no_job_posting_returns_none(self):
html = """
<html><head>
<script type="application/ld+json">{"@type": "Organization"}</script>
</head></html>
"""
assert _jsonld_job_url(html) is None
def test_no_script_returns_none(self):
assert _jsonld_job_url("<html><body>No scripts</body></html>") is None
# ---------------------------------------------------------------------------
# extract_open_position — tier integration (network-free)
# ---------------------------------------------------------------------------
class TestExtractOpenPositionTiers:
def _patch_base(self, monkeypatch, *, html: str | None = None) -> None:
"""Patch ATS detect functions to miss and HTTP fetch to return html."""
monkeypatch.setattr(
"jobsource.extract._ats.detect_ats_in_url",
lambda url: None,
)
monkeypatch.setattr(
"jobsource.extract._ats.detect_ats_in_html",
lambda html: None,
)
if html is not None:
monkeypatch.setattr(
"jobsource.extract.request_with_retries",
lambda *a, **kw: FakeResponse(200, html),
)
# -- Tier 1: ATS from URL ------------------------------------------------
def test_ats_from_url_returns_first_job(self, monkeypatch):
board = ATSBoard(
ats_name="greenhouse",
slug="acme",
careers_url="https://boards.greenhouse.io/acme",
)
monkeypatch.setattr(
"jobsource.extract._ats.detect_ats_in_url",
lambda url: board,
)
monkeypatch.setattr(
"jobsource.extract._ats._FETCH_DISPATCH",
{"greenhouse": lambda b, c: ATSFetch(first_url="https://boards.greenhouse.io/acme/jobs/1", job_count=5)},
)
url, method = extract_open_position(
"https://boards.greenhouse.io/acme", client=FakeClient()
)
assert url == "https://boards.greenhouse.io/acme/jobs/1"
assert method == "ats:greenhouse"
def test_ats_from_url_with_no_jobs_falls_through(self, monkeypatch):
board = ATSBoard(
ats_name="lever",
slug="acme",
careers_url="https://jobs.lever.co/acme",
)
monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: board)
monkeypatch.setattr(
"jobsource.extract._ats._FETCH_DISPATCH",
{"lever": lambda b, c: ATSFetch(first_url=None, job_count=0)},
)
monkeypatch.setattr(
"jobsource.extract._ats.detect_ats_in_html", lambda html: None
)
# Page has no JSON-LD, no job-path anchors, LLM returns None.
# Use a neutral href that won't trigger the job-path pattern.
monkeypatch.setattr(
"jobsource.extract.request_with_retries",
lambda *a, **kw: FakeResponse(200, "<html><body><a href='/team'>Team</a></body></html>"),
)
monkeypatch.setattr("jobsource.extract.classify_job_link", lambda anchors: None)
url, method = extract_open_position("https://acme.example.com/careers", client=FakeClient())
assert url is None
assert method == "none"
# -- Tier 1b: ATS from page HTML ----------------------------------------
def test_ats_from_html_returns_first_job(self, monkeypatch):
gh_board = ATSBoard(
ats_name="greenhouse",
slug="vercel",
careers_url="https://boards.greenhouse.io/vercel",
)
monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: None)
monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_html", lambda html: gh_board)
monkeypatch.setattr(
"jobsource.extract._ats._FETCH_DISPATCH",
{"greenhouse": lambda b, c: ATSFetch(first_url="https://boards.greenhouse.io/vercel/jobs/42", job_count=73)},
)
monkeypatch.setattr(
"jobsource.extract.request_with_retries",
lambda *a, **kw: FakeResponse(200, '<script src="greenhouse"></script>'),
)
url, method = extract_open_position("https://vercel.com/careers", client=FakeClient())
assert url == "https://boards.greenhouse.io/vercel/jobs/42"
assert method == "ats:greenhouse"
# -- Tier 2: JSON-LD ----------------------------------------------------
def test_jsonld_returns_job_url(self, monkeypatch):
html = """
<html><head>
<script type="application/ld+json">
{"@type": "JobPosting", "url": "https://acme.com/jobs/77"}
</script>
</head><body></body></html>
"""
self._patch_base(monkeypatch, html=html)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url == "https://acme.com/jobs/77"
assert method == "json_ld"
# -- Tier 3: job-like anchor --------------------------------------------
def test_job_anchor_matched_by_pattern(self, monkeypatch):
html = """
<html><body>
<a href="/about">About</a>
<a href="/jobs/software-engineer-123">Software Engineer</a>
</body></html>
"""
self._patch_base(monkeypatch, html=html)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url == "https://acme.com/jobs/software-engineer-123"
assert method == "anchor"
def test_opening_anchor_matched(self, monkeypatch):
html = '<html><body><a href="/openings/42">Open Role</a></body></html>'
self._patch_base(monkeypatch, html=html)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url == "https://acme.com/openings/42"
assert method == "anchor"
# -- Tier 4: LLM --------------------------------------------------------
def test_llm_classify_fires_when_no_job_anchor(self, monkeypatch):
html = '<html><body><a href="/about">About</a><a href="/team">Team</a></body></html>'
self._patch_base(monkeypatch, html=html)
monkeypatch.setattr(
"jobsource.extract.classify_job_link",
lambda anchors: JobLinkResult(job_url="https://acme.com/about", confidence=0.60),
)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url == "https://acme.com/about"
assert method == "llm_classify"
def test_llm_returns_none_falls_through_to_none(self, monkeypatch):
html = '<html><body><a href="/about">About</a></body></html>'
self._patch_base(monkeypatch, html=html)
monkeypatch.setattr(
"jobsource.extract.classify_job_link",
lambda anchors: None,
)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url is None
assert method == "none"
# -- Page fetch failure -------------------------------------------------
def test_fetch_failure_returns_none(self, monkeypatch):
monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: None)
monkeypatch.setattr(
"jobsource.extract.request_with_retries",
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("timeout")),
)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url is None
assert method == "none"
def test_non_200_fetch_returns_none(self, monkeypatch):
monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: None)
monkeypatch.setattr(
"jobsource.extract.request_with_retries",
lambda *a, **kw: FakeResponse(404, ""),
)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url is None
assert method == "none"
# -- All tiers miss ------------------------------------------------------
def test_all_miss_returns_none_none(self, monkeypatch):
html = '<html><body><a href="/about">About</a></body></html>'
self._patch_base(monkeypatch, html=html)
monkeypatch.setattr("jobsource.extract.classify_job_link", lambda anchors: None)
url, method = extract_open_position("https://acme.com/careers", client=FakeClient())
assert url is None
assert method == "none"
# -- ATS tier error falls through ---------------------------------------
def test_ats_fetch_error_falls_through(self, monkeypatch):
board = ATSBoard(ats_name="ashby", slug="acme", careers_url="https://jobs.ashbyhq.com/acme")
monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_url", lambda url: board)
monkeypatch.setattr(
"jobsource.extract._ats._FETCH_DISPATCH",
{"ashby": lambda b, c: (_ for _ in ()).throw(RuntimeError("api down"))},
)
monkeypatch.setattr("jobsource.extract._ats.detect_ats_in_html", lambda html: None)
html = '<html><body><a href="/jobs/42">Engineer</a></body></html>'
monkeypatch.setattr(
"jobsource.extract.request_with_retries",
lambda *a, **kw: FakeResponse(200, html),
)
url, method = extract_open_position("https://jobs.ashbyhq.com/acme", client=FakeClient())
# urljoin("https://jobs.ashbyhq.com/acme", "/jobs/42") → root-relative
assert url == "https://jobs.ashbyhq.com/jobs/42"
assert method == "anchor"