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
import re
from contextlib import contextmanager
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)
@ -9,12 +20,112 @@ class ApecSearchResult:
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:
def __init__(self, max_listings: int = 50) -> None:
self.max_listings = max_listings
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:
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
import re
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
import typer
import yaml
@ -22,6 +24,19 @@ def _utc_now() -> datetime:
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:
path.parent.mkdir(parents=True, exist_ok=True)
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] = []
fetched_count = 0
for index, result in enumerate(search_results, start=1):
for result in search_results:
fetched_count += 1
try:
@ -120,7 +135,7 @@ def fetch_apec(
listing_errors.append(ListingError(url=result.url, stage="fetch_html", message=str(exc)))
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")
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]
snapshot_files = list((run_dir / "snapshots").glob("*.html"))
assert len(snapshot_files) == 1
assert snapshot_files[0].name == "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"))
@ -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:
data_root = tmp_path / "data"
data_root.mkdir()