fix: harden Apec readiness and snapshot writes

This commit is contained in:
Antoine 2026-06-02 20:55:17 +02:00
parent 47f912ce8c
commit c28f804e23
6 changed files with 176 additions and 29 deletions

View File

@ -133,12 +133,16 @@ class ApecAdapter:
with _open_public_page() as page:
page.goto(url, wait_until="domcontentloaded")
page.wait_for_selector(".details-post:has-text('Descriptif du poste')", timeout=15_000)
page.wait_for_function(
"""
() => {
const company = document.querySelector('.card-ents .ents-name, .card-ents-quote');
return !!company && (company.textContent || '').trim().length > 0;
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 offerList = document.querySelector('.details-offer-list');
const descriptionReady = Array.from(document.querySelectorAll('.details-post h4')).some((heading) => {
return (heading.textContent || '').trim() === 'Descriptif du poste';
});
return !!title && !!offerList && !!company && (company.textContent || '').trim().length > 0 && descriptionReady;
}
""",
polling=1000,

View File

@ -116,18 +116,19 @@ def _extract_contract_type(details_offer_list) -> str | None:
def _extract_company(soup: BeautifulSoup, details_offer_list) -> str | None:
company = soup.select_one(".card-ents .ents-name")
if company is not None:
return _clean_text(company.get_text(" ", strip=True))
company = soup.select_one(".card-ents-quote")
if company is not None:
return _clean_text(company.get_text(" ", strip=True))
for selector in (".card-ents .ents-name", ".card-ents-quote"):
company = soup.select_one(selector)
if company is not None:
text = _clean_text(company.get_text(" ", strip=True))
if text is not None:
return text
if details_offer_list is not None:
company = details_offer_list.select_one("li:first-of-type")
if company is not None:
return _clean_text(company.get_text(" ", strip=True))
text = _clean_text(company.get_text(" ", strip=True))
if text is not None:
return text
return None

View File

@ -135,8 +135,12 @@ def fetch_apec(
listing_errors.append(ListingError(url=result.url, stage="fetch_html", message=str(exc)))
continue
snapshot_path = paths["snapshots"] / f"{_snapshot_stem(result.url, result.source_job_id)}.html"
snapshot_path.write_text(html, encoding="utf-8")
try:
snapshot_path = paths["snapshots"] / f"{_snapshot_stem(result.url, result.source_job_id)}.html"
snapshot_path.write_text(html, encoding="utf-8")
except Exception as exc: # pragma: no cover - defensive boundary
listing_errors.append(ListingError(url=result.url, stage="snapshot_write", message=str(exc)))
continue
try:
listing = normalize_apec_listing(

View File

@ -45,7 +45,6 @@ class _FakeDetailPage:
self.result_pages = result_pages
self.rendered_html = rendered_html
self.shell_html = "<html>shell</html>"
self.waited_selectors: list[str] = []
self.waited_functions: list[tuple[str, int | None]] = []
self.goto_urls: list[str] = []
self.current_query = ""
@ -76,17 +75,6 @@ class _FakeDetailPage:
return None
def wait_for_selector(self, selector: str, timeout: int | None = None) -> None:
self.waited_selectors.append(selector)
if selector in {
".card-ents .ents-name",
".card-offer .ref-offre",
".details-offer-list",
".details-post",
".details-post:has-text('Descriptif du poste')",
}:
return None
if selector == _RESULT_LINK_SELECTOR and self.current_results():
return None
@ -139,11 +127,9 @@ def test_fetch_listing_html_waits_for_rendered_offer_content(monkeypatch) -> Non
assert html == "<html>rendered offer</html>"
assert len(page.waited_functions) == 1
assert ".card-ents .ents-name" in page.waited_functions[0][0]
assert ".details-offer-list" in page.waited_functions[0][0]
assert "Descriptif du poste" in page.waited_functions[0][0]
assert page.waited_functions[0][1] == 1000
assert page.waited_selectors == [
".details-post:has-text('Descriptif du poste')",
]
def test_fetch_listing_html_rejects_non_apec_hosts() -> None:

View File

@ -68,3 +68,59 @@ def test_normalize_apec_listing_extracts_minimal_shape() -> None:
assert listing.description_text == "Build pipelines"
assert listing.published_at == "2026-04-20"
assert listing.fetched_at == "2026-06-01T10:00:00Z"
def test_normalize_apec_listing_uses_details_offer_list_company_fallback() -> None:
html = """
<html>
<body>
<main class="container-details-offer">
<h1>Data Engineer F/H</h1>
<div class="card-offer">
<div class="ref-offre">Ref. Apec : 178554452W</div>
<ul class="details-offer-list mb-20">
<li>Fallback Company</li>
<li>1 <span> CDI </span></li>
<li>Puteaux - 92</li>
</ul>
<p>Publiée le 20/04/2026 Actualisée le 02/06/2026</p>
</div>
<div class="details-post">
<h4>Salaire</h4>
<span>A partir de 70 k brut annuel</span>
</div>
<div class="details-post">
<h4>Prise de poste</h4>
<span>Dès que possible</span>
</div>
<div class="details-post">
<h4>Expérience</h4>
<span>Minimum 7 ans</span>
</div>
<div class="details-post">
<h4>Descriptif du poste</h4>
<p>Build pipelines</p>
<div class="nested-late-sections">
<h4>Profil recherché</h4>
<p>Python / SQL</p>
<h4>Compétences attendues</h4>
<p>Ignored</p>
<h4>Entreprise</h4>
<p>Ignored</p>
<div class="recruiter">Ignored recruiter info</div>
</div>
</div>
</main>
</body>
</html>
"""
listing = normalize_apec_listing(
url="https://example.test/job/123",
html=html,
fetched_at="2026-06-01T10:00:00Z",
source_job_id=None,
)
assert listing.company == "Fallback Company"
assert listing.description_text == "Build pipelines"

View File

@ -1,4 +1,5 @@
from datetime import datetime, timezone
from pathlib import Path
from textwrap import dedent
import yaml
@ -262,6 +263,101 @@ def test_fetch_apec_records_partial_failures_without_losing_successful_listings(
}
def test_fetch_apec_records_snapshot_write_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 = _apec_detail_html()
first_result = ApecSearchResult(url="https://example.test/job/123", source_job_id="job-123")
second_result = ApecSearchResult(url="https://example.test/job/456", source_job_id="job-456")
class SnapshotWriteFailingAdapter:
instances: list["SnapshotWriteFailingAdapter"] = []
def __init__(self, max_listings: int = 50) -> None:
self.max_listings = max_listings
self.search_calls: list[list[str]] = []
self.fetch_calls: list[str] = []
SnapshotWriteFailingAdapter.instances.append(self)
def search(self, queries: list[str]) -> list[ApecSearchResult]:
self.search_calls.append(list(queries))
return [first_result, second_result]
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_snapshot_failure(
self: Path,
data: str,
encoding: str | None = None,
errors: str | None = None,
newline: str | None = None,
) -> int:
if self.parent.name == "snapshots" and self.name == "job-123.html":
raise OSError("disk full")
return original_write_text(self, data, encoding=encoding, errors=errors, newline=newline)
monkeypatch.setattr("job_research.cli.ApecAdapter", SnapshotWriteFailingAdapter)
monkeypatch.setattr("job_research.cli._utc_now", _fixed_now)
monkeypatch.setattr(Path, "write_text", write_text_with_snapshot_failure)
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
run_dir = data_root / "apec" / "runs" / "2026-06-01T10-00-00Z"
snapshot_files = sorted((run_dir / "snapshots").glob("*.html"))
assert [snapshot.name for snapshot in snapshot_files] == ["job-456.html"]
listings_payload = yaml.safe_load((run_dir / "listings.yaml").read_text(encoding="utf-8"))
assert listings_payload == [
{
"source": "apec",
"source_job_id": "job-456",
"url": "https://example.test/job/456",
"title": "Role From YAML",
"company": "Example Corp",
"location": "Paris - 75",
"contract_type": "CDI",
"description_text": "Build pipelines",
"published_at": "2026-04-20",
"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["failed_count"] == 1
assert run_meta_payload["listing_errors"] == [
{
"url": first_result.url,
"stage": "snapshot_write",
"message": "disk full",
}
]
def test_fetch_apec_fails_when_no_queries_are_derived(tmp_path, monkeypatch) -> None:
data_root = tmp_path / "data"
data_root.mkdir()