fix: harden Apec crawl edge cases
This commit is contained in:
parent
31c0488f60
commit
352dfcd6ce
@ -149,7 +149,14 @@ class ApecAdapter:
|
||||
"""
|
||||
() => {
|
||||
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 companySelectors = [
|
||||
'.card-ents .ents-name',
|
||||
'.card-ents-quote',
|
||||
'.details-offer-list li:first-of-type',
|
||||
];
|
||||
const company = companySelectors
|
||||
.map((selector) => document.querySelector(selector))
|
||||
.find((element) => (element?.textContent || '').trim().length > 0);
|
||||
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';
|
||||
|
||||
@ -1,12 +1,24 @@
|
||||
from job_research.models import CandidateProfileOutput
|
||||
|
||||
|
||||
def _normalize_term(raw_term: str) -> str:
|
||||
return " ".join(raw_term.split())
|
||||
|
||||
|
||||
def _normalize_constraint(raw_term: str) -> str:
|
||||
term = _normalize_term(raw_term)
|
||||
if term.lower().endswith(" only"):
|
||||
return term[:-5].rstrip()
|
||||
|
||||
return term
|
||||
|
||||
|
||||
def derive_apec_queries(profile: CandidateProfileOutput) -> list[str]:
|
||||
queries: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add_query(raw_query: str) -> None:
|
||||
query = " ".join(raw_query.split())
|
||||
query = _normalize_term(raw_query)
|
||||
if not query or query in seen or len(queries) == 5:
|
||||
return
|
||||
|
||||
@ -21,18 +33,30 @@ def derive_apec_queries(profile: CandidateProfileOutput) -> list[str]:
|
||||
|
||||
unique_roles.append(query)
|
||||
|
||||
for target_role in unique_roles[:2]:
|
||||
add_query(target_role)
|
||||
support_terms = [_normalize_term(term) for term in profile.strengths]
|
||||
support_terms.extend(_normalize_term(term) for term in profile.skills_to_emphasize)
|
||||
support_terms.extend(_normalize_constraint(term) for term in profile.constraints)
|
||||
|
||||
if unique_roles:
|
||||
primary_role = unique_roles[0]
|
||||
for term in profile.strengths + profile.skills_to_emphasize:
|
||||
add_query(f"{primary_role} {term}")
|
||||
if len(queries) == 5:
|
||||
break
|
||||
support_terms = [term for term in support_terms if term]
|
||||
|
||||
if support_terms:
|
||||
for target_role in unique_roles[:2]:
|
||||
add_query(target_role)
|
||||
|
||||
if unique_roles:
|
||||
primary_role = unique_roles[0]
|
||||
for term in support_terms:
|
||||
add_query(f"{primary_role} {term}")
|
||||
if len(queries) == 5:
|
||||
break
|
||||
else:
|
||||
for term in support_terms:
|
||||
add_query(term)
|
||||
if len(queries) == 5:
|
||||
break
|
||||
else:
|
||||
for term in profile.strengths + profile.skills_to_emphasize:
|
||||
add_query(term)
|
||||
for target_role in unique_roles[:5]:
|
||||
add_query(target_role)
|
||||
if len(queries) == 5:
|
||||
break
|
||||
|
||||
|
||||
@ -192,7 +192,7 @@ def fetch_apec(
|
||||
|
||||
normalized_listings.append(listing)
|
||||
|
||||
if successful_fetch_count == 0:
|
||||
if search_results and successful_fetch_count == 0:
|
||||
typer.echo("No listings could be fetched or normalized from Apec", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
@ -211,8 +211,12 @@ def fetch_apec(
|
||||
listing_errors=listing_errors,
|
||||
)
|
||||
|
||||
_write_yaml(paths["listings"], [listing.model_dump(mode="json") for listing in deduplicated_listings])
|
||||
_write_yaml(paths["run_meta"], run_meta.model_dump(mode="json"))
|
||||
try:
|
||||
_write_yaml(paths["listings"], [listing.model_dump(mode="json") for listing in deduplicated_listings])
|
||||
_write_yaml(paths["run_meta"], run_meta.model_dump(mode="json"))
|
||||
except OSError as exc: # pragma: no cover - defensive boundary
|
||||
typer.echo(f"Unable to write Apec run artifacts: {exc}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
typer.echo(
|
||||
f"query={len(derived_queries)} fetched={fetched_count} normalized={len(normalized_listings)} "
|
||||
|
||||
@ -139,6 +139,21 @@ def test_fetch_listing_html_waits_for_rendered_offer_content(monkeypatch) -> Non
|
||||
assert page.waited_functions[0][1] == 1000
|
||||
|
||||
|
||||
def test_fetch_listing_html_uses_explicit_company_fallback_chain(monkeypatch) -> None:
|
||||
page = _FakeDetailPage({}, rendered_html="<html>rendered offer</html>")
|
||||
monkeypatch.setattr(adapter_module, "_open_public_page", lambda: _fake_open_public_page(page))
|
||||
|
||||
ApecAdapter().fetch_listing_html(
|
||||
"https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/111"
|
||||
)
|
||||
|
||||
wait_script = page.waited_functions[0][0]
|
||||
assert "companySelectors" in wait_script
|
||||
assert ".card-ents .ents-name" in wait_script
|
||||
assert ".card-ents-quote" in wait_script
|
||||
assert ".details-offer-list li:first-of-type" in wait_script
|
||||
|
||||
|
||||
def test_fetch_listing_html_rejects_non_apec_hosts() -> None:
|
||||
adapter = ApecAdapter()
|
||||
|
||||
|
||||
@ -26,3 +26,45 @@ def test_derive_apec_queries_preserves_order_dedupes_and_caps_at_five() -> None:
|
||||
"Data Engineer SQL",
|
||||
"Data Engineer BigQuery",
|
||||
]
|
||||
|
||||
|
||||
def test_derive_apec_queries_uses_up_to_five_target_roles_when_no_support_terms_exist() -> None:
|
||||
profile = CandidateProfileOutput(
|
||||
target_roles=[
|
||||
"Data Engineer",
|
||||
"Analytics Engineer",
|
||||
"BI Engineer",
|
||||
"Junior Data Platform Engineer",
|
||||
"ML Engineer",
|
||||
"Backend Engineer",
|
||||
]
|
||||
)
|
||||
|
||||
queries = derive_apec_queries(profile)
|
||||
|
||||
assert queries == [
|
||||
"Data Engineer",
|
||||
"Analytics Engineer",
|
||||
"BI Engineer",
|
||||
"Junior Data Platform Engineer",
|
||||
"ML Engineer",
|
||||
]
|
||||
|
||||
|
||||
def test_derive_apec_queries_includes_constraints_in_support_term_queries() -> None:
|
||||
profile = CandidateProfileOutput(
|
||||
target_roles=["Data Engineer"],
|
||||
strengths=["Python"],
|
||||
skills_to_emphasize=["BigQuery"],
|
||||
constraints=["CDI only", "France only"],
|
||||
)
|
||||
|
||||
queries = derive_apec_queries(profile)
|
||||
|
||||
assert queries == [
|
||||
"Data Engineer",
|
||||
"Data Engineer Python",
|
||||
"Data Engineer BigQuery",
|
||||
"Data Engineer CDI",
|
||||
"Data Engineer France",
|
||||
]
|
||||
|
||||
@ -2,6 +2,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@ -209,6 +210,138 @@ def test_fetch_apec_fails_cleanly_when_adapter_search_raises(
|
||||
assert FailingSearchAdapter.instances[0].search_calls == [["Role From YAML"]]
|
||||
|
||||
|
||||
def test_fetch_apec_succeeds_with_empty_search_results(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",
|
||||
)
|
||||
|
||||
class EmptyResultAdapter:
|
||||
instances: list["EmptyResultAdapter"] = []
|
||||
|
||||
def __init__(self, max_listings: int = 50) -> None:
|
||||
self.max_listings = max_listings
|
||||
self.search_calls: list[list[str]] = []
|
||||
self.fetch_calls: list[str] = []
|
||||
EmptyResultAdapter.instances.append(self)
|
||||
|
||||
def search(self, queries: list[str]) -> list[ApecSearchResult]:
|
||||
self.search_calls.append(list(queries))
|
||||
return []
|
||||
|
||||
def fetch_listing_html(self, url: str) -> str:
|
||||
self.fetch_calls.append(url)
|
||||
raise AssertionError("fetch should not be called when search returns no results")
|
||||
|
||||
monkeypatch.setattr("job_research.cli.ApecAdapter", EmptyResultAdapter)
|
||||
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=0" in result.stdout
|
||||
assert "normalized=0" in result.stdout
|
||||
assert "deduplicated=0" in result.stdout
|
||||
assert "failed=0" in result.stdout
|
||||
|
||||
adapter = EmptyResultAdapter.instances[0]
|
||||
assert adapter.search_calls == [["Role From YAML"]]
|
||||
assert adapter.fetch_calls == []
|
||||
|
||||
run_dir = data_root / "apec" / "runs" / "2026-06-01T10-00-00Z"
|
||||
assert (run_dir / "snapshots").exists()
|
||||
assert list((run_dir / "snapshots").iterdir()) == []
|
||||
|
||||
listings_payload = yaml.safe_load((run_dir / "listings.yaml").read_text(encoding="utf-8"))
|
||||
assert listings_payload == []
|
||||
|
||||
run_meta_payload = yaml.safe_load((run_dir / "run-meta.yaml").read_text(encoding="utf-8"))
|
||||
assert run_meta_payload == {
|
||||
"run_id": "2026-06-01T10-00-00Z",
|
||||
"run_started_at": "2026-06-01T10:00:00Z",
|
||||
"derived_queries": ["Role From YAML"],
|
||||
"snapshots": [],
|
||||
"fetched_count": 0,
|
||||
"normalized_count": 0,
|
||||
"deduplicated_count": 0,
|
||||
"failed_count": 0,
|
||||
"listing_errors": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failing_filename", ["listings.yaml", "run-meta.yaml"])
|
||||
def test_fetch_apec_reports_final_artifact_write_failures_cleanly(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
failing_filename: str,
|
||||
) -> 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()
|
||||
|
||||
class FinalWriteFailingAdapter:
|
||||
instances: list["FinalWriteFailingAdapter"] = []
|
||||
|
||||
def __init__(self, max_listings: int = 50) -> None:
|
||||
self.max_listings = max_listings
|
||||
self.search_calls: list[list[str]] = []
|
||||
self.fetch_calls: list[str] = []
|
||||
FinalWriteFailingAdapter.instances.append(self)
|
||||
|
||||
def search(self, queries: list[str]) -> list[ApecSearchResult]:
|
||||
self.search_calls.append(list(queries))
|
||||
return [ApecSearchResult(url="https://example.test/job/123", source_job_id="job-123")]
|
||||
|
||||
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_final_failure(
|
||||
self: Path,
|
||||
data: str,
|
||||
encoding: str | None = None,
|
||||
errors: str | None = None,
|
||||
newline: str | None = None,
|
||||
) -> int:
|
||||
if self.name == failing_filename:
|
||||
raise OSError("disk full")
|
||||
|
||||
return original_write_text(self, data, encoding=encoding, errors=errors, newline=newline)
|
||||
|
||||
monkeypatch.setattr("job_research.cli.ApecAdapter", FinalWriteFailingAdapter)
|
||||
monkeypatch.setattr("job_research.cli._utc_now", _fixed_now)
|
||||
monkeypatch.setattr(Path, "write_text", write_text_with_final_failure)
|
||||
|
||||
result = CliRunner().invoke(app, ["fetch-apec", "--data-root", str(data_root)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Unable to write Apec run artifacts" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
adapter = FinalWriteFailingAdapter.instances[0]
|
||||
assert adapter.search_calls == [["Role From YAML"]]
|
||||
assert adapter.fetch_calls == ["https://example.test/job/123"]
|
||||
|
||||
|
||||
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