fix: harden Apec crawl edge cases

This commit is contained in:
Antoine 2026-06-03 20:41:23 +02:00
parent 31c0488f60
commit 352dfcd6ce
6 changed files with 240 additions and 15 deletions

View File

@ -149,7 +149,14 @@ class ApecAdapter:
"""
() => {
const title = document.querySelector('.container-details-offer h1, h1');
const company = document.querySelector('.card-ents .ents-name, .card-ents-quote, .details-offer-list li:first-of-type');
const companySelectors = [
'.card-ents .ents-name',
'.card-ents-quote',
'.details-offer-list li:first-of-type',
];
const company = companySelectors
.map((selector) => document.querySelector(selector))
.find((element) => (element?.textContent || '').trim().length > 0);
const offerList = document.querySelector('.details-offer-list');
const descriptionReady = Array.from(document.querySelectorAll('.details-post h4')).some((heading) => {
return (heading.textContent || '').trim() === 'Descriptif du poste';

View File

@ -1,12 +1,24 @@
from job_research.models import CandidateProfileOutput
def _normalize_term(raw_term: str) -> str:
return " ".join(raw_term.split())
def _normalize_constraint(raw_term: str) -> str:
term = _normalize_term(raw_term)
if term.lower().endswith(" only"):
return term[:-5].rstrip()
return term
def derive_apec_queries(profile: CandidateProfileOutput) -> list[str]:
queries: list[str] = []
seen: set[str] = set()
def add_query(raw_query: str) -> None:
query = " ".join(raw_query.split())
query = _normalize_term(raw_query)
if not query or query in seen or len(queries) == 5:
return
@ -21,18 +33,30 @@ def derive_apec_queries(profile: CandidateProfileOutput) -> list[str]:
unique_roles.append(query)
for target_role in unique_roles[:2]:
add_query(target_role)
support_terms = [_normalize_term(term) for term in profile.strengths]
support_terms.extend(_normalize_term(term) for term in profile.skills_to_emphasize)
support_terms.extend(_normalize_constraint(term) for term in profile.constraints)
if unique_roles:
primary_role = unique_roles[0]
for term in profile.strengths + profile.skills_to_emphasize:
add_query(f"{primary_role} {term}")
if len(queries) == 5:
break
support_terms = [term for term in support_terms if term]
if support_terms:
for target_role in unique_roles[:2]:
add_query(target_role)
if unique_roles:
primary_role = unique_roles[0]
for term in support_terms:
add_query(f"{primary_role} {term}")
if len(queries) == 5:
break
else:
for term in support_terms:
add_query(term)
if len(queries) == 5:
break
else:
for term in profile.strengths + profile.skills_to_emphasize:
add_query(term)
for target_role in unique_roles[:5]:
add_query(target_role)
if len(queries) == 5:
break

View File

@ -192,7 +192,7 @@ def fetch_apec(
normalized_listings.append(listing)
if successful_fetch_count == 0:
if search_results and successful_fetch_count == 0:
typer.echo("No listings could be fetched or normalized from Apec", err=True)
raise typer.Exit(code=1)
@ -211,8 +211,12 @@ def fetch_apec(
listing_errors=listing_errors,
)
_write_yaml(paths["listings"], [listing.model_dump(mode="json") for listing in deduplicated_listings])
_write_yaml(paths["run_meta"], run_meta.model_dump(mode="json"))
try:
_write_yaml(paths["listings"], [listing.model_dump(mode="json") for listing in deduplicated_listings])
_write_yaml(paths["run_meta"], run_meta.model_dump(mode="json"))
except OSError as exc: # pragma: no cover - defensive boundary
typer.echo(f"Unable to write Apec run artifacts: {exc}", err=True)
raise typer.Exit(code=1)
typer.echo(
f"query={len(derived_queries)} fetched={fetched_count} normalized={len(normalized_listings)} "

View File

@ -139,6 +139,21 @@ def test_fetch_listing_html_waits_for_rendered_offer_content(monkeypatch) -> Non
assert page.waited_functions[0][1] == 1000
def test_fetch_listing_html_uses_explicit_company_fallback_chain(monkeypatch) -> None:
page = _FakeDetailPage({}, rendered_html="<html>rendered offer</html>")
monkeypatch.setattr(adapter_module, "_open_public_page", lambda: _fake_open_public_page(page))
ApecAdapter().fetch_listing_html(
"https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/111"
)
wait_script = page.waited_functions[0][0]
assert "companySelectors" in wait_script
assert ".card-ents .ents-name" in wait_script
assert ".card-ents-quote" in wait_script
assert ".details-offer-list li:first-of-type" in wait_script
def test_fetch_listing_html_rejects_non_apec_hosts() -> None:
adapter = ApecAdapter()

View File

@ -26,3 +26,45 @@ def test_derive_apec_queries_preserves_order_dedupes_and_caps_at_five() -> None:
"Data Engineer SQL",
"Data Engineer BigQuery",
]
def test_derive_apec_queries_uses_up_to_five_target_roles_when_no_support_terms_exist() -> None:
profile = CandidateProfileOutput(
target_roles=[
"Data Engineer",
"Analytics Engineer",
"BI Engineer",
"Junior Data Platform Engineer",
"ML Engineer",
"Backend Engineer",
]
)
queries = derive_apec_queries(profile)
assert queries == [
"Data Engineer",
"Analytics Engineer",
"BI Engineer",
"Junior Data Platform Engineer",
"ML Engineer",
]
def test_derive_apec_queries_includes_constraints_in_support_term_queries() -> None:
profile = CandidateProfileOutput(
target_roles=["Data Engineer"],
strengths=["Python"],
skills_to_emphasize=["BigQuery"],
constraints=["CDI only", "France only"],
)
queries = derive_apec_queries(profile)
assert queries == [
"Data Engineer",
"Data Engineer Python",
"Data Engineer BigQuery",
"Data Engineer CDI",
"Data Engineer France",
]

View File

@ -2,6 +2,7 @@ from datetime import datetime, timezone
from pathlib import Path
from textwrap import dedent
import pytest
import yaml
from typer.testing import CliRunner
@ -209,6 +210,138 @@ def test_fetch_apec_fails_cleanly_when_adapter_search_raises(
assert FailingSearchAdapter.instances[0].search_calls == [["Role From YAML"]]
def test_fetch_apec_succeeds_with_empty_search_results(tmp_path, monkeypatch) -> None:
data_root = tmp_path / "data"
data_root.mkdir()
(data_root / "candidate-profile.yaml").write_text(
dedent(
"""
target_roles:
- Role From YAML
"""
).strip(),
encoding="utf-8",
)
class EmptyResultAdapter:
instances: list["EmptyResultAdapter"] = []
def __init__(self, max_listings: int = 50) -> None:
self.max_listings = max_listings
self.search_calls: list[list[str]] = []
self.fetch_calls: list[str] = []
EmptyResultAdapter.instances.append(self)
def search(self, queries: list[str]) -> list[ApecSearchResult]:
self.search_calls.append(list(queries))
return []
def fetch_listing_html(self, url: str) -> str:
self.fetch_calls.append(url)
raise AssertionError("fetch should not be called when search returns no results")
monkeypatch.setattr("job_research.cli.ApecAdapter", EmptyResultAdapter)
monkeypatch.setattr("job_research.cli._utc_now", _fixed_now)
result = CliRunner().invoke(app, ["fetch-apec", "--data-root", str(data_root)])
assert result.exit_code == 0
assert "fetched=0" in result.stdout
assert "normalized=0" in result.stdout
assert "deduplicated=0" in result.stdout
assert "failed=0" in result.stdout
adapter = EmptyResultAdapter.instances[0]
assert adapter.search_calls == [["Role From YAML"]]
assert adapter.fetch_calls == []
run_dir = data_root / "apec" / "runs" / "2026-06-01T10-00-00Z"
assert (run_dir / "snapshots").exists()
assert list((run_dir / "snapshots").iterdir()) == []
listings_payload = yaml.safe_load((run_dir / "listings.yaml").read_text(encoding="utf-8"))
assert listings_payload == []
run_meta_payload = yaml.safe_load((run_dir / "run-meta.yaml").read_text(encoding="utf-8"))
assert run_meta_payload == {
"run_id": "2026-06-01T10-00-00Z",
"run_started_at": "2026-06-01T10:00:00Z",
"derived_queries": ["Role From YAML"],
"snapshots": [],
"fetched_count": 0,
"normalized_count": 0,
"deduplicated_count": 0,
"failed_count": 0,
"listing_errors": [],
}
@pytest.mark.parametrize("failing_filename", ["listings.yaml", "run-meta.yaml"])
def test_fetch_apec_reports_final_artifact_write_failures_cleanly(
tmp_path,
monkeypatch,
failing_filename: str,
) -> None:
data_root = tmp_path / "data"
data_root.mkdir()
(data_root / "candidate-profile.yaml").write_text(
dedent(
"""
target_roles:
- Role From YAML
"""
).strip(),
encoding="utf-8",
)
html = _apec_detail_html()
class FinalWriteFailingAdapter:
instances: list["FinalWriteFailingAdapter"] = []
def __init__(self, max_listings: int = 50) -> None:
self.max_listings = max_listings
self.search_calls: list[list[str]] = []
self.fetch_calls: list[str] = []
FinalWriteFailingAdapter.instances.append(self)
def search(self, queries: list[str]) -> list[ApecSearchResult]:
self.search_calls.append(list(queries))
return [ApecSearchResult(url="https://example.test/job/123", source_job_id="job-123")]
def fetch_listing_html(self, url: str) -> str:
self.fetch_calls.append(url)
return html
original_write_text = Path.write_text
def write_text_with_final_failure(
self: Path,
data: str,
encoding: str | None = None,
errors: str | None = None,
newline: str | None = None,
) -> int:
if self.name == failing_filename:
raise OSError("disk full")
return original_write_text(self, data, encoding=encoding, errors=errors, newline=newline)
monkeypatch.setattr("job_research.cli.ApecAdapter", FinalWriteFailingAdapter)
monkeypatch.setattr("job_research.cli._utc_now", _fixed_now)
monkeypatch.setattr(Path, "write_text", write_text_with_final_failure)
result = CliRunner().invoke(app, ["fetch-apec", "--data-root", str(data_root)])
assert result.exit_code == 1
assert "Unable to write Apec run artifacts" in result.stderr
assert "Traceback" not in result.stderr
adapter = FinalWriteFailingAdapter.instances[0]
assert adapter.search_calls == [["Role From YAML"]]
assert adapter.fetch_calls == ["https://example.test/job/123"]
def test_fetch_apec_reads_profile_and_writes_run_artifacts(tmp_path, monkeypatch) -> None:
data_root = tmp_path / "data"
data_root.mkdir()