From 86b5cf2d7baf9eb73df40e6db156f4e6d6aa6bfd Mon Sep 17 00:00:00 2001 From: Antoine Date: Wed, 3 Jun 2026 19:37:35 +0200 Subject: [PATCH] fix: apply Apec source filters and profile errors --- src/job_research/apec/adapter.py | 18 +++++++++++--- src/job_research/cli.py | 19 +++++++++++++-- tests/apec/test_adapter.py | 7 ++++++ tests/test_apec_cli.py | 40 ++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/job_research/apec/adapter.py b/src/job_research/apec/adapter.py index 5c9c0da..602cce6 100644 --- a/src/job_research/apec/adapter.py +++ b/src/job_research/apec/adapter.py @@ -10,6 +10,8 @@ from playwright.sync_api import sync_playwright _SEARCH_URL = "https://www.apec.fr/candidat/recherche-emploi.html/emploi" +_FRANCE_LOCATION_ID = "799" +_CDI_CONTRACT_ID = "101888" _RESULT_LINK_SELECTOR = "a[href*='/candidat/recherche-emploi.html/emploi/detail-offre/']" _DETAIL_JOB_ID_PATTERN = re.compile(r"/detail-offre/([^/?#]+)") _APEC_HOSTS = {"apec.fr", "www.apec.fr"} @@ -49,6 +51,18 @@ def _search_results_url(base_url: str, page_number: int) -> str: return urlunsplit(parsed_url._replace(query=urlencode(filtered_params, doseq=True))) +def _search_url(query: str, page_number: int = 0) -> str: + # Apec exposes stable public filter ids for France and CDI in the search URL. + # Using the URL directly is less brittle than driving the collapsed filter UI. + params = [ + ("motsCles", query), + ("lieux", _FRANCE_LOCATION_ID), + ("typesContrat", _CDI_CONTRACT_ID), + ("page", str(page_number)), + ] + return f"{_SEARCH_URL}?{urlencode(params)}" + + def _accept_cookies_if_present(page) -> None: try: page.get_by_role("button", name="Accepter tous les cookies").click(timeout=2_000) @@ -82,10 +96,8 @@ class ApecAdapter: if len(results) >= self.max_listings: break - page.goto(_SEARCH_URL, wait_until="domcontentloaded") + page.goto(_search_url(query), wait_until="domcontentloaded") _accept_cookies_if_present(page) - page.locator('input[name="keywords"]').fill(query) - page.get_by_role("button", name="Rechercher").click() page.wait_for_load_state("domcontentloaded") try: diff --git a/src/job_research/cli.py b/src/job_research/cli.py index 4c4ad02..0700f0e 100644 --- a/src/job_research/cli.py +++ b/src/job_research/cli.py @@ -6,6 +6,7 @@ from urllib.parse import unquote, urlparse import typer import yaml +from pydantic import ValidationError from job_research.apec.adapter import ApecAdapter from job_research.apec.dedupe import dedupe_apec_listings @@ -42,6 +43,15 @@ def _write_yaml(path: Path, payload: Any) -> None: path.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), encoding="utf-8") +def _load_candidate_profile(profile_path: Path) -> CandidateProfileOutput: + try: + return CandidateProfileOutput.model_validate(load_yaml(profile_path)) + except FileNotFoundError as exc: + raise ValueError(f"candidate-profile.yaml not found at {profile_path}") from exc + except (yaml.YAMLError, ValidationError, ValueError) as exc: + raise ValueError(f"invalid candidate-profile.yaml at {profile_path}: {exc}") from exc + + @app.callback() def main_command() -> None: pass @@ -102,11 +112,16 @@ def fetch_apec( readable=True, help="Directory containing candidate-profile.yaml and Apec run artifacts.", ), -) -> None: + ) -> None: """Fetch, normalize, dedupe, and persist Apec listings.""" profile_path = data_root / "candidate-profile.yaml" - profile = CandidateProfileOutput.model_validate(load_yaml(profile_path)) + try: + profile = _load_candidate_profile(profile_path) + except ValueError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) + derived_queries = derive_apec_queries(profile) if not derived_queries: diff --git a/tests/apec/test_adapter.py b/tests/apec/test_adapter.py index 7dd7017..f7fe873 100644 --- a/tests/apec/test_adapter.py +++ b/tests/apec/test_adapter.py @@ -114,6 +114,13 @@ def test_search_continues_past_duplicate_only_pages(monkeypatch) -> None: assert [result.url for result in results] == [first_result, second_result] assert [result.source_job_id for result in results] == ["111", "222"] + assert "motsCles=alpha" in page.goto_urls[0] + assert "lieux=799" in page.goto_urls[0] + assert "typesContrat=101888" in page.goto_urls[0] + assert any( + "motsCles=beta" in url and "lieux=799" in url and "typesContrat=101888" in url and "page=1" in url + for url in page.goto_urls + ) assert any("page=1" in url for url in page.goto_urls) diff --git a/tests/test_apec_cli.py b/tests/test_apec_cli.py index 40455ad..f1a75b1 100644 --- a/tests/test_apec_cli.py +++ b/tests/test_apec_cli.py @@ -79,6 +79,46 @@ def _apec_detail_html( ).strip() +def test_fetch_apec_fails_cleanly_when_candidate_profile_is_missing(tmp_path, monkeypatch) -> None: + data_root = tmp_path / "data" + data_root.mkdir() + + class SpyApecAdapter: + def __init__(self, max_listings: int = 50) -> None: + raise AssertionError("adapter should not be created when the profile is missing") + + monkeypatch.setattr("job_research.cli.ApecAdapter", SpyApecAdapter) + + result = CliRunner().invoke(app, ["fetch-apec", "--data-root", str(data_root)]) + + assert result.exit_code == 1 + assert "candidate-profile.yaml" in result.stderr + assert "Traceback" not in result.stderr + assert not (data_root / "apec").exists() + + +def test_fetch_apec_fails_cleanly_when_candidate_profile_yaml_is_malformed( + tmp_path, + monkeypatch, +) -> None: + data_root = tmp_path / "data" + data_root.mkdir() + (data_root / "candidate-profile.yaml").write_text("target_roles: [\n", encoding="utf-8") + + class SpyApecAdapter: + def __init__(self, max_listings: int = 50) -> None: + raise AssertionError("adapter should not be created when the profile is invalid") + + monkeypatch.setattr("job_research.cli.ApecAdapter", SpyApecAdapter) + + result = CliRunner().invoke(app, ["fetch-apec", "--data-root", str(data_root)]) + + assert result.exit_code == 1 + assert "candidate-profile.yaml" in result.stderr + assert "Traceback" not in result.stderr + assert not (data_root / "apec").exists() + + def test_fetch_apec_reads_profile_and_writes_run_artifacts(tmp_path, monkeypatch) -> None: data_root = tmp_path / "data" data_root.mkdir()