fix: make Apec adapter real

This commit is contained in:
Antoine 2026-06-02 19:23:14 +02:00
parent 52b21b65c5
commit 2da39613c7
3 changed files with 237 additions and 4 deletions

View File

@ -1,6 +1,17 @@
from __future__ import annotations from __future__ import annotations
import re
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from urllib.parse import parse_qsl, urlencode, urlparse, urlsplit, urlunsplit
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
_SEARCH_URL = "https://www.apec.fr/candidat/recherche-emploi.html/emploi"
_RESULT_LINK_SELECTOR = "a[href*='/candidat/recherche-emploi.html/emploi/detail-offre/']"
_DETAIL_JOB_ID_PATTERN = re.compile(r"/detail-offre/([^/?#]+)")
@dataclass(slots=True) @dataclass(slots=True)
@ -9,12 +20,112 @@ class ApecSearchResult:
source_job_id: str | None = None source_job_id: str | None = None
@contextmanager
def _open_public_page():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
try:
page = browser.new_page()
page.set_default_timeout(15_000)
yield page
finally:
browser.close()
def _extract_source_job_id(url: str) -> str | None:
match = _DETAIL_JOB_ID_PATTERN.search(url)
if match is None:
return None
return match.group(1)
def _search_results_url(base_url: str, page_number: int) -> str:
parsed_url = urlsplit(base_url)
params = parse_qsl(parsed_url.query, keep_blank_values=True)
filtered_params = [(key, value) for key, value in params if key != "page"]
filtered_params.append(("page", str(page_number)))
return urlunsplit(parsed_url._replace(query=urlencode(filtered_params, doseq=True)))
def _accept_cookies_if_present(page) -> None:
try:
page.get_by_role("button", name="Accepter tous les cookies").click(timeout=2_000)
except PlaywrightTimeoutError:
return
class ApecAdapter: class ApecAdapter:
def __init__(self, max_listings: int = 50) -> None: def __init__(self, max_listings: int = 50) -> None:
self.max_listings = max_listings self.max_listings = max_listings
def search(self, queries: list[str]) -> list[ApecSearchResult]: def search(self, queries: list[str]) -> list[ApecSearchResult]:
return [] results: list[ApecSearchResult] = []
seen_keys: set[str] = set()
with _open_public_page() as page:
for query in queries:
if not query.strip():
continue
if len(results) >= self.max_listings:
break
page.goto(_SEARCH_URL, 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:
page.wait_for_selector(_RESULT_LINK_SELECTOR, timeout=5_000)
except PlaywrightTimeoutError:
continue
result_page_url = page.url
page_number = 0
while len(results) < self.max_listings:
if page_number > 0:
page.goto(_search_results_url(result_page_url, page_number), wait_until="domcontentloaded")
try:
page.wait_for_selector(_RESULT_LINK_SELECTOR, timeout=5_000)
except PlaywrightTimeoutError:
break
hrefs = page.locator(_RESULT_LINK_SELECTOR).evaluate_all(
"nodes => nodes.map(node => node.href)"
)
if not hrefs:
break
new_results_on_page = 0
for href in hrefs:
source_job_id = _extract_source_job_id(href)
dedupe_key = source_job_id or href
if dedupe_key in seen_keys:
continue
seen_keys.add(dedupe_key)
results.append(ApecSearchResult(url=href, source_job_id=source_job_id))
new_results_on_page += 1
if len(results) >= self.max_listings:
break
if new_results_on_page == 0:
break
page_number += 1
return results
def fetch_listing_html(self, url: str) -> str: def fetch_listing_html(self, url: str) -> str:
return "" parsed_url = urlparse(url)
if not parsed_url.netloc.endswith("apec.fr"):
raise ValueError("ApecAdapter only fetches public Apec URLs")
with _open_public_page() as page:
page.goto(url, wait_until="domcontentloaded")
return page.content()

View File

@ -1,6 +1,8 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import unquote, urlparse
import typer import typer
import yaml import yaml
@ -22,6 +24,19 @@ def _utc_now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _snapshot_stem(url: str, source_job_id: str | None) -> str:
if source_job_id:
return source_job_id
parsed_url = urlparse(url)
fallback = parsed_url.path.rstrip("/").rsplit("/", 1)[-1] or parsed_url.netloc or "listing"
if parsed_url.query:
fallback = f"{fallback}-{parsed_url.query}"
stem = re.sub(r"[^A-Za-z0-9]+", "-", unquote(fallback)).strip("-")
return stem or "listing"
def _write_yaml(path: Path, payload: Any) -> None: def _write_yaml(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), encoding="utf-8") path.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), encoding="utf-8")
@ -111,7 +126,7 @@ def fetch_apec(
listing_errors: list[ListingError] = [] listing_errors: list[ListingError] = []
fetched_count = 0 fetched_count = 0
for index, result in enumerate(search_results, start=1): for result in search_results:
fetched_count += 1 fetched_count += 1
try: try:
@ -120,7 +135,7 @@ def fetch_apec(
listing_errors.append(ListingError(url=result.url, stage="fetch_html", message=str(exc))) listing_errors.append(ListingError(url=result.url, stage="fetch_html", message=str(exc)))
continue continue
snapshot_path = paths["snapshots"] / f"{index:04d}.html" snapshot_path = paths["snapshots"] / f"{_snapshot_stem(result.url, result.source_job_id)}.html"
snapshot_path.write_text(html, encoding="utf-8") snapshot_path.write_text(html, encoding="utf-8")
try: try:

View File

@ -83,6 +83,7 @@ def test_fetch_apec_reads_profile_and_writes_run_artifacts(tmp_path, monkeypatch
run_dir = run_dirs[0] run_dir = run_dirs[0]
snapshot_files = list((run_dir / "snapshots").glob("*.html")) snapshot_files = list((run_dir / "snapshots").glob("*.html"))
assert len(snapshot_files) == 1 assert len(snapshot_files) == 1
assert snapshot_files[0].name == "job-123.html"
assert snapshot_files[0].read_text(encoding="utf-8") == html assert snapshot_files[0].read_text(encoding="utf-8") == html
listings_payload = yaml.safe_load((run_dir / "listings.yaml").read_text(encoding="utf-8")) listings_payload = yaml.safe_load((run_dir / "listings.yaml").read_text(encoding="utf-8"))
@ -113,6 +114,112 @@ def test_fetch_apec_reads_profile_and_writes_run_artifacts(tmp_path, monkeypatch
} }
def test_fetch_apec_records_partial_failures_without_losing_successful_listings(
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",
)
html = dedent(
"""
<html>
<body>
<h1>Role From YAML</h1>
<div class="company">Example Corp</div>
<div class="location">Paris</div>
<div class="contract">CDI</div>
<div class="description">Build pipelines</div>
</body>
</html>
"""
).strip()
good_result = ApecSearchResult(url="https://example.test/job/123", source_job_id="job-123")
bad_result = ApecSearchResult(url="https://example.test/job/456", source_job_id="job-456")
class MixedApecAdapter:
instances: list["MixedApecAdapter"] = []
def __init__(self, max_listings: int = 50) -> None:
self.max_listings = max_listings
self.search_calls: list[list[str]] = []
self.fetch_calls: list[str] = []
MixedApecAdapter.instances.append(self)
def search(self, queries: list[str]) -> list[ApecSearchResult]:
self.search_calls.append(list(queries))
return [good_result, bad_result]
def fetch_listing_html(self, url: str) -> str:
self.fetch_calls.append(url)
if url == good_result.url:
return html
raise RuntimeError(f"boom: {url}")
monkeypatch.setattr("job_research.cli.ApecAdapter", MixedApecAdapter)
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=2" in result.stdout
assert "normalized=1" in result.stdout
assert "deduplicated=1" in result.stdout
assert "failed=1" in result.stdout
adapter = MixedApecAdapter.instances[0]
assert adapter.search_calls == [["Role From YAML"]]
assert adapter.fetch_calls == [good_result.url, bad_result.url]
run_dir = data_root / "apec" / "runs" / "2026-06-01T10-00-00Z"
snapshot_files = list((run_dir / "snapshots").glob("*.html"))
assert [snapshot.name for snapshot in snapshot_files] == ["job-123.html"]
assert snapshot_files[0].read_text(encoding="utf-8") == html
listings_payload = yaml.safe_load((run_dir / "listings.yaml").read_text(encoding="utf-8"))
assert listings_payload == [
{
"source": "apec",
"source_job_id": "job-123",
"url": "https://example.test/job/123",
"title": "Role From YAML",
"company": "Example Corp",
"location": "Paris",
"contract_type": "CDI",
"description_text": "Build pipelines",
"published_at": None,
"fetched_at": "2026-06-01T10:00:00Z",
"warnings": [],
}
]
run_meta_payload = yaml.safe_load((run_dir / "run-meta.yaml").read_text(encoding="utf-8"))
assert run_meta_payload == {
"derived_queries": ["Role From YAML"],
"fetched_count": 2,
"normalized_count": 1,
"deduplicated_count": 1,
"failed_count": 1,
"listing_errors": [
{
"url": bad_result.url,
"stage": "fetch_html",
"message": f"boom: {bad_result.url}",
}
],
}
def test_fetch_apec_fails_when_no_queries_are_derived(tmp_path, monkeypatch) -> None: def test_fetch_apec_fails_when_no_queries_are_derived(tmp_path, monkeypatch) -> None:
data_root = tmp_path / "data" data_root = tmp_path / "data"
data_root.mkdir() data_root.mkdir()