fix: apply Apec source filters and profile errors
This commit is contained in:
parent
450134c808
commit
86b5cf2d7b
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user