fix: make Apec adapter real
This commit is contained in:
parent
52b21b65c5
commit
2da39613c7
@ -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()
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user