diff --git a/src/job_research/apec/adapter.py b/src/job_research/apec/adapter.py index 602cce6..b1efd10 100644 --- a/src/job_research/apec/adapter.py +++ b/src/job_research/apec/adapter.py @@ -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'; diff --git a/src/job_research/apec/query_derivation.py b/src/job_research/apec/query_derivation.py index 6dc9c56..921ec7e 100644 --- a/src/job_research/apec/query_derivation.py +++ b/src/job_research/apec/query_derivation.py @@ -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 diff --git a/src/job_research/cli.py b/src/job_research/cli.py index 166d3e4..cf868ad 100644 --- a/src/job_research/cli.py +++ b/src/job_research/cli.py @@ -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)} " diff --git a/tests/apec/test_adapter.py b/tests/apec/test_adapter.py index f7fe873..7864575 100644 --- a/tests/apec/test_adapter.py +++ b/tests/apec/test_adapter.py @@ -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="rendered offer") + 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() diff --git a/tests/apec/test_query_derivation.py b/tests/apec/test_query_derivation.py index 312a930..2b814b0 100644 --- a/tests/apec/test_query_derivation.py +++ b/tests/apec/test_query_derivation.py @@ -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", + ] diff --git a/tests/test_apec_cli.py b/tests/test_apec_cli.py index e3d1823..bb6889a 100644 --- a/tests/test_apec_cli.py +++ b/tests/test_apec_cli.py @@ -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()