Add checkpoint logic and tests

This commit is contained in:
qananasikq
2026-04-15 11:10:32 +03:00
parent d9111be2d2
commit acbb045b6e
8 changed files with 432 additions and 175 deletions

View File

@@ -1,66 +0,0 @@
IAAI_HEADLESS=true
IAAI_LOG_LEVEL=INFO
# IAAI_LOG_FILE=iaai_scraper.log
# Network capture settings.
IAAI_CAPTURE_SAME_ORIGIN_ONLY=true
IAAI_MAX_CAPTURED_REQUESTS=40
IAAI_MAX_CAPTURED_JSON_RESPONSES=30
# Sequential listing-first mode.
IAAI_CARS_LISTING_URL=https://www.iaai.com/Vehiclelisting/Cars
IAAI_MAX_PAGES_PER_RUN=10
IAAI_MAX_VEHICLES_PER_RUN=50000
IAAI_PAGE_LINK_LIMIT=500
IAAI_INCLUDE_PAGINATION=true
IAAI_COLLECT_CURRENT_PAGE_ONLY=false
# Паузы (отключены для максимальной скорости; включи на VPS если попадаешь под блокировки).
IAAI_HUMAN_PACE_ENABLED=false
IAAI_AFTER_LISTING_OPEN_MIN_S=0.2
IAAI_AFTER_LISTING_OPEN_MAX_S=0.5
IAAI_AFTER_FILTER_ACTION_MIN_S=0.2
IAAI_AFTER_FILTER_ACTION_MAX_S=0.5
IAAI_BEFORE_VEHICLE_OPEN_MIN_S=0.02
IAAI_BEFORE_VEHICLE_OPEN_MAX_S=0.08
IAAI_AFTER_VEHICLE_OPEN_MIN_S=0.02
IAAI_AFTER_VEHICLE_OPEN_MAX_S=0.08
IAAI_BETWEEN_VEHICLES_MIN_S=0.02
IAAI_BETWEEN_VEHICLES_MAX_S=0.08
IAAI_AFTER_PAGE_CHANGE_MIN_S=0.2
IAAI_AFTER_PAGE_CHANGE_MAX_S=0.5
# Sync settings
IAAI_SYNC_ONLY_NEW=true
IAAI_TOKENS_FILE=/data/tokens.json
IAAI_RUNTIME_CONFIG_FILE=/app/runtime_config.json
# Retry / backoff
IAAI_RETRY_DELAY_SECONDS=2.5
IAAI_RETRY_BACKOFF_MULTIPLIER=2.0
IAAI_RETRY_JITTER_SECONDS=0.25
# Proxy (HTTP/HTTPS preferred for Playwright)
# Chromium does not support SOCKS5 proxy authentication directly.
# Use residential or mobile USA proxy.
# IAAI_PROXY_SERVER=http://proxy.example.com:8080
# IAAI_PROXY_USERNAME=
# IAAI_PROXY_PASSWORD=
# Database (PostgreSQL).
IAAI_DATABASE_URL=postgresql+psycopg2://iaai:iaai@postgres:5432/iaai_scraper
IAAI_DATABASE_ECHO=false
IAAI_DATABASE_POOL_SIZE=10
IAAI_DATABASE_MAX_OVERFLOW=20
# ─── Redis (Celery broker) ─────────────────────────────────
IAAI_REDIS_URL=redis://redis:6379/0
# ─── Celery ────────────────────────────────────────────────
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_TASK_SOFT_TIME_LIMIT=600
CELERY_TASK_TIME_LIMIT=900
CELERY_WORKER_CONCURRENCY=1
CELERY_BEAT_SYNC_INTERVAL_MINUTES=60
CELERY_BEAT_SYNC_LIMIT=26

5
.gitignore vendored
View File

@@ -2,8 +2,9 @@ __pycache__/
.pytest_cache/ .pytest_cache/
.coverage .coverage
coverage.xml coverage.xml
.env .env*
.env.* !.env
.env.example
.venv/ .venv/
venv/ venv/
*.pyc *.pyc

View File

@@ -77,6 +77,62 @@ class ListingCollector:
self.settings = settings self.settings = settings
self.pacer = pacer self.pacer = pacer
@staticmethod
def _get_current_page_number(page: Page) -> int | None:
try:
value = page.evaluate(
"""
() => {
const controls = Array.from(document.querySelectorAll('a,button,[role="button"],span,div'));
const current = controls.find((el) => {
const text = (el.textContent || '').trim();
const cls = (el.getAttribute('class') || '').toLowerCase();
const ariaCurrent = (el.getAttribute('aria-current') || '').toLowerCase();
return /^\d+$/.test(text) && (ariaCurrent === 'page' || cls.includes('active') || cls.includes('current') || cls.includes('selected'));
});
if (current) {
return parseInt((current.textContent || '').trim(), 10);
}
const match = (document.body?.innerText || '').match(/\b(\d+)\s+of\s+\d+\+?/i);
return match ? parseInt(match[1], 10) : null;
}
"""
)
return int(value) if value is not None else None
except Exception:
return None
@staticmethod
def _wait_for_navigation_result(page: Page, old_first_href: str, expected_page_number: int | None) -> bool:
if old_first_href:
try:
page.wait_for_function(
f"""() => {{
const a = document.querySelector(\"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']\");
return a && a.getAttribute('href') !== '{old_first_href}';
}}""",
timeout=12000,
)
return True
except Exception:
pass
if expected_page_number is not None:
current_page = ListingCollector._get_current_page_number(page)
if current_page == expected_page_number:
return True
try:
page.wait_for_selector(VEHICLE_LINK_SELECTOR, timeout=3000)
except Exception:
pass
current_page = ListingCollector._get_current_page_number(page)
if expected_page_number is not None and current_page == expected_page_number:
return True
return not old_first_href
def open_cars_listing(self, page: Page) -> None: def open_cars_listing(self, page: Page) -> None:
logger.info("Opening cars listing page: %s", self.settings.listing.cars_url) logger.info("Opening cars listing page: %s", self.settings.listing.cars_url)
url = self.settings.listing.cars_url url = self.settings.listing.cars_url
@@ -248,7 +304,7 @@ class ListingCollector:
return found return found
def go_to_next_page(self, page: Page) -> bool: def go_to_next_page(self, page: Page, expected_page_number: int | None = None) -> bool:
# Запоминаем первую ссылку текущей страницы для определения смены контента. # Запоминаем первую ссылку текущей страницы для определения смены контента.
old_first_href = "" old_first_href = ""
try: try:
@@ -276,30 +332,9 @@ class ListingCollector:
except Exception: except Exception:
continue continue
# Ждём смены контента (AJAX пагинация): первая VehicleDetail-ссылка должна измениться. if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
if old_first_href: self.pacer.after_page_change()
try: return True
page.wait_for_function(
f"""() => {{
const a = document.querySelector("a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']");
return a && a.getAttribute('href') !== '{old_first_href}';
}}""",
timeout=8000,
)
except Exception:
pass
else:
try:
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass
try:
page.wait_for_selector(VEHICLE_LINK_SELECTOR, timeout=3000)
except Exception:
pass
self.pacer.after_page_change()
return True
# Fallback для IAAI: пагинация часто рендерится как набор номеров страниц # Fallback для IAAI: пагинация часто рендерится как набор номеров страниц
# + стрелка с иконкой, без явного текста Next. # + стрелка с иконкой, без явного текста Next.
@@ -357,24 +392,9 @@ class ListingCollector:
""" """
)) ))
if clicked: if clicked:
if old_first_href: if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
try: self.pacer.after_page_change()
page.wait_for_function( return True
f"""() => {{
const a = document.querySelector(\"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']\");
return a && a.getAttribute('href') !== '{old_first_href}';
}}""",
timeout=12000,
)
except Exception:
pass
else:
try:
page.wait_for_load_state("domcontentloaded", timeout=12000)
except Exception:
pass
self.pacer.after_page_change()
return True
except Exception as exc: except Exception as exc:
logger.debug("Numeric/icon pagination fallback failed: %s", exc) logger.debug("Numeric/icon pagination fallback failed: %s", exc)
@@ -403,19 +423,9 @@ class ListingCollector:
""" """
)) ))
if clicked: if clicked:
if old_first_href: if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
try: self.pacer.after_page_change()
page.wait_for_function( return True
f"""() => {{
const a = document.querySelector(\"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']\");
return a && a.getAttribute('href') !== '{old_first_href}';
}}""",
timeout=8000,
)
except Exception:
pass
self.pacer.after_page_change()
return True
except Exception as exc: except Exception as exc:
logger.debug("JS next-page fallback failed: %s", exc) logger.debug("JS next-page fallback failed: %s", exc)

View File

@@ -9,7 +9,7 @@ import html as html_module
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -29,7 +29,7 @@ from .core.utils import save_to_json
from .parsing.mapper import CarMapper from .parsing.mapper import CarMapper
from .parsing.parser import VehicleParser from .parsing.parser import VehicleParser
from .storage.db import PersistenceService from .storage.db import PersistenceService
from .browser.listing import ListingCollector from .browser.listing import ListingCollector, ListingPageResult
from .storage.schemas import CarRecord from .storage.schemas import CarRecord
logger = logging.getLogger("iaai_scraper.scraper") logger = logging.getLogger("iaai_scraper.scraper")
@@ -258,6 +258,70 @@ class IAAIScraper:
new_urls = [url for url in vehicle_urls if url not in known_urls] new_urls = [url for url in vehicle_urls if url not in known_urls]
return new_urls, skipped return new_urls, skipped
def _extract_page_urls(
self,
page_result: ListingPageResult,
all_raw_urls: list[str],
seen_urls: set[str],
all_listing_origin_urls: set[str] | None = None,
) -> list[str]:
page_urls: list[str] = []
for item in page_result.vehicle_links:
normalized = self._normalize_vehicle_url(item.href)
all_raw_urls.append(item.href)
if normalized and all_listing_origin_urls is not None:
all_listing_origin_urls.add(normalized)
if normalized and normalized not in seen_urls:
seen_urls.add(normalized)
page_urls.append(normalized)
return page_urls
def _recover_empty_listing_page(
self,
page: Page,
*,
page_number: int,
all_raw_urls: list[str],
seen_urls: set[str],
all_listing_origin_urls: set[str] | None = None,
) -> tuple[ListingPageResult, list[str]]:
if page_number == 1:
logger.warning("Page 1 returned 0 links — retrying listing open...")
time.sleep(3)
self.listing_collector.open_cars_listing(page)
page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
return page_result, self._extract_page_urls(page_result, all_raw_urls, seen_urls, all_listing_origin_urls)
logger.warning(
"Page %d returned 0 links — retrying current page before stopping pagination",
page_number,
)
try:
page.reload(wait_until="domcontentloaded", timeout=30_000)
except Exception as exc:
logger.debug("Page %d reload failed during empty-page recovery: %s", page_number, exc)
page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
return page_result, self._extract_page_urls(page_result, all_raw_urls, seen_urls, all_listing_origin_urls)
def _reopen_listing_and_resume(
self,
*,
target_page_number: int,
make: str | None,
model: str | None,
) -> Page:
page = self._get_page_with_warmup()
self.listing_collector.open_cars_listing(page)
self.listing_collector.apply_filters(page, make=make, model=model)
for expected_page in range(2, target_page_number + 1):
if not self.listing_collector.go_to_next_page(page, expected_page_number=expected_page):
page.close()
raise RuntimeError(f"Failed to resume listing at page {target_page_number}")
logger.warning("Listing resumed at page %d after recovery", target_page_number)
return page
def _collect_listing_iterative( def _collect_listing_iterative(
self, self,
*, *,
@@ -301,28 +365,25 @@ class IAAIScraper:
}) })
# Собираем URL со страницы. # Собираем URL со страницы.
page_urls: list[str] = [] page_urls = self._extract_page_urls(page_result, all_raw_urls, seen)
for item in page_result.vehicle_links:
normalized = self._normalize_vehicle_url(item.href)
all_raw_urls.append(item.href)
if normalized not in seen:
seen.add(normalized)
page_urls.append(normalized)
if not page_urls: if not page_urls:
# Страница 1 пустая — скорее всего transient network issue. page_result, page_urls = self._recover_empty_listing_page(
# Пробуем перезагрузить листинг ещё раз. page,
if page_number == 1: page_number=page_number,
logger.warning("Page 1 returned 0 links — retrying listing open...") all_raw_urls=all_raw_urls,
time.sleep(3) seen_urls=seen,
self.listing_collector.open_cars_listing(page) )
if not page_urls and page_number > 1:
logger.warning("Page %d still empty after retry — reopening listing and resuming", page_number)
page.close()
page = self._reopen_listing_and_resume(
target_page_number=page_number,
make=make,
model=model,
)
page_result = self.listing_collector.collect_current_page(page, page_number=page_number) page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
for item in page_result.vehicle_links: page_urls = self._extract_page_urls(page_result, all_raw_urls, seen)
normalized = self._normalize_vehicle_url(item.href)
all_raw_urls.append(item.href)
if normalized not in seen:
seen.add(normalized)
page_urls.append(normalized)
if not page_urls: if not page_urls:
logger.info("Page %d: 0 new links, stopping pagination", page_number) logger.info("Page %d: 0 new links, stopping pagination", page_number)
break break
@@ -349,9 +410,14 @@ class IAAIScraper:
if not page_result.next_page_detected: if not page_result.next_page_detected:
logger.info("No next page detected, stopping") logger.info("No next page detected, stopping")
break break
if not self.listing_collector.go_to_next_page(page): if not self.listing_collector.go_to_next_page(page, expected_page_number=page_number + 1):
logger.info("Failed to navigate to next page, stopping") logger.warning("Failed to navigate to page %d — reopening listing and resuming", page_number + 1)
break page.close()
page = self._reopen_listing_and_resume(
target_page_number=page_number + 1,
make=make,
model=model,
)
finally: finally:
page.close() page.close()
@@ -404,6 +470,8 @@ class IAAIScraper:
limit: int | None, limit: int | None,
effective_only_new: bool, effective_only_new: bool,
started_at: float, started_at: float,
start_page: int = 1,
progress_callback: Callable[[int], None] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
batch_size = self.settings.celery.batch_size batch_size = self.settings.celery.batch_size
pending_urls: list[str] = [] pending_urls: list[str] = []
@@ -540,42 +608,57 @@ class IAAIScraper:
failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(batch_exc)}) failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(batch_exc)})
return True return True
page = self._get_page_with_warmup() page = self._get_page_with_warmup() if start_page <= 1 else None
try: try:
self.listing_collector.open_cars_listing(page) if start_page <= 1:
applied_filters = self.listing_collector.apply_filters(page, make=make, model=model) assert page is not None
self.listing_collector.open_cars_listing(page)
applied_filters = self.listing_collector.apply_filters(page, make=make, model=model)
else:
page = self._reopen_listing_and_resume(
target_page_number=start_page,
make=make,
model=model,
)
applied_filters = {"make": make, "model": model}
for page_number in range(1, max(1, self.settings.listing.max_pages_per_run) + 1): for page_number in range(start_page, max(start_page, self.settings.listing.max_pages_per_run) + 1):
page_result = self.listing_collector.collect_current_page(page, page_number=page_number) page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
pages_info.append({ pages_info.append({
"page_number": page_result.page_number, "page_number": page_result.page_number,
"links_found": len(page_result.vehicle_links), "links_found": len(page_result.vehicle_links),
}) })
page_urls: list[str] = [] page_urls = self._extract_page_urls(
for item in page_result.vehicle_links: page_result,
normalized = self._normalize_vehicle_url(item.href) all_raw_urls,
all_raw_urls.append(item.href) seen_urls,
if normalized: all_listing_origin_urls,
all_listing_origin_urls.add(normalized) )
if normalized and normalized not in seen_urls:
seen_urls.add(normalized)
page_urls.append(normalized)
if not page_urls: if not page_urls:
if page_number == 1: page_result, page_urls = self._recover_empty_listing_page(
logger.warning("Page 1 returned 0 links — retrying listing open...") page,
time.sleep(3) page_number=page_number,
self.listing_collector.open_cars_listing(page) all_raw_urls=all_raw_urls,
seen_urls=seen_urls,
all_listing_origin_urls=all_listing_origin_urls,
)
if not page_urls and page_number > 1:
logger.warning("Page %d still empty after retry — reopening listing and resuming", page_number)
page.close()
page = self._reopen_listing_and_resume(
target_page_number=page_number,
make=make,
model=model,
)
page_result = self.listing_collector.collect_current_page(page, page_number=page_number) page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
for item in page_result.vehicle_links: page_urls = self._extract_page_urls(
normalized = self._normalize_vehicle_url(item.href) page_result,
all_raw_urls.append(item.href) all_raw_urls,
if normalized: seen_urls,
all_listing_origin_urls.add(normalized) all_listing_origin_urls,
if normalized and normalized not in seen_urls: )
seen_urls.add(normalized)
page_urls.append(normalized)
if not page_urls: if not page_urls:
logger.info("Page %d: 0 new links, stopping pagination", page_number) logger.info("Page %d: 0 new links, stopping pagination", page_number)
break break
@@ -643,6 +726,12 @@ class IAAIScraper:
break break
pending_urls = pending_urls[batch_size:] pending_urls = pending_urls[batch_size:]
if progress_callback is not None:
try:
progress_callback(page_number)
except Exception:
logger.warning("Failed to persist progress callback for page %d", page_number, exc_info=True)
if limit is not None and limit > 0 and total >= limit: if limit is not None and limit > 0 and total >= limit:
break break
if ( if (
@@ -651,8 +740,14 @@ class IAAIScraper:
or not page_result.next_page_detected or not page_result.next_page_detected
): ):
break break
if not self.listing_collector.go_to_next_page(page): if not self.listing_collector.go_to_next_page(page, expected_page_number=page_number + 1):
break logger.warning("Failed to navigate to page %d — reopening listing and resuming", page_number + 1)
page.close()
page = self._reopen_listing_and_resume(
target_page_number=page_number + 1,
make=make,
model=model,
)
if pending_urls: if pending_urls:
_process_pending_batch(pending_urls, cars_upserted + cars_failed) _process_pending_batch(pending_urls, cars_upserted + cars_failed)
@@ -1443,6 +1538,8 @@ class IAAIScraper:
lane: str = "iaai_cars", lane: str = "iaai_cars",
limit: int | None = None, limit: int | None = None,
only_new: bool | None = None, only_new: bool | None = None,
start_page: int = 1,
progress_callback: Callable[[int], None] | None = None,
): ):
# Листинг + sync всех найденных машин. # Листинг + sync всех найденных машин.
# Применяем runtime_config как дефолты (CLI/API аргументы имеют приоритет). # Применяем runtime_config как дефолты (CLI/API аргументы имеют приоритет).
@@ -1477,6 +1574,8 @@ class IAAIScraper:
limit=limit, limit=limit,
effective_only_new=effective_only_new, effective_only_new=effective_only_new,
started_at=started_at, started_at=started_at,
start_page=max(1, int(start_page)),
progress_callback=progress_callback,
) )
listing = stream_result["listing"] listing = stream_result["listing"]
total = stream_result["total"] total = stream_result["total"]

View File

@@ -1,6 +1,7 @@
# Задачи Celery для синхронизации автомобилей и листинга IAAI. # Задачи Celery для синхронизации автомобилей и листинга IAAI.
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import json
import logging import logging
from threading import Event, Thread from threading import Event, Thread
import time import time
@@ -18,6 +19,7 @@ logger = logging.getLogger("iaai_scraper.worker.tasks")
SYNC_LISTING_LOCK_KEY = "iaai:locks:sync_listing" SYNC_LISTING_LOCK_KEY = "iaai:locks:sync_listing"
SYNC_FULL_SCAN_DONE_KEY = "iaai:state:sync_full_scan_done" SYNC_FULL_SCAN_DONE_KEY = "iaai:state:sync_full_scan_done"
SYNC_LISTING_CHECKPOINT_KEY = "iaai:state:sync_listing_checkpoint"
SYNC_LISTING_TASK_NAME = "iaai_scraper.worker.tasks.sync_listing_task" SYNC_LISTING_TASK_NAME = "iaai_scraper.worker.tasks.sync_listing_task"
@@ -228,6 +230,55 @@ def _set_full_scan_done(redis_client: Redis, done: bool) -> None:
logger.warning("Failed to persist full scan state", exc_info=True) logger.warning("Failed to persist full scan state", exc_info=True)
def _load_sync_checkpoint(redis_client: Redis) -> dict[str, object] | None:
try:
raw = redis_client.get(SYNC_LISTING_CHECKPOINT_KEY)
except Exception:
logger.warning("Failed to read sync listing checkpoint", exc_info=True)
return None
if not raw:
return None
if isinstance(raw, str) and not raw.strip():
return None
try:
data = json.loads(str(raw))
except Exception:
logger.warning("Failed to decode sync listing checkpoint", exc_info=True)
return None
return data if isinstance(data, dict) else None
def _save_sync_checkpoint(
redis_client: Redis,
*,
task_id: str,
page_number: int,
make: str | None,
model: str | None,
lane: str,
) -> None:
payload = {
"status": "in_progress",
"task_id": task_id,
"last_successful_page": int(page_number),
"make": make,
"model": model,
"lane": lane,
"updated_at": int(time.time()),
}
try:
redis_client.set(SYNC_LISTING_CHECKPOINT_KEY, json.dumps(payload))
except Exception:
logger.warning("Failed to save sync listing checkpoint", exc_info=True)
def _clear_sync_checkpoint(redis_client: Redis) -> None:
try:
redis_client.delete(SYNC_LISTING_CHECKPOINT_KEY)
except Exception:
logger.warning("Failed to clear sync listing checkpoint", exc_info=True)
def _start_lock_heartbeat( def _start_lock_heartbeat(
redis_client: Redis, redis_client: Redis,
key: str, key: str,
@@ -350,6 +401,28 @@ def sync_listing_task(
force_bootstrap_full_scan = not full_scan_done_before_run force_bootstrap_full_scan = not full_scan_done_before_run
effective_limit = None if force_bootstrap_full_scan else limit effective_limit = None if force_bootstrap_full_scan else limit
effective_only_new = False if force_bootstrap_full_scan else only_new effective_only_new = False if force_bootstrap_full_scan else only_new
checkpoint = _load_sync_checkpoint(redis_client)
resume_from_page = 1
if checkpoint and str(checkpoint.get("status") or "") == "in_progress":
checkpoint_page = int(checkpoint.get("last_successful_page") or 0)
checkpoint_make = checkpoint.get("make")
checkpoint_model = checkpoint.get("model")
checkpoint_lane = checkpoint.get("lane")
same_scope = (
checkpoint_make == make
and checkpoint_model == model
and checkpoint_lane == lane
)
if checkpoint_page > 0 and same_scope:
resume_from_page = checkpoint_page + 1
logger.warning(
"Resuming sync_listing from page %d using checkpoint",
resume_from_page,
)
elif checkpoint_page > 0:
logger.info("Ignoring stale checkpoint due to different sync parameters")
_clear_sync_checkpoint(redis_client)
if force_bootstrap_full_scan: if force_bootstrap_full_scan:
logger.info( logger.info(
@@ -377,6 +450,15 @@ def sync_listing_task(
lane=lane, lane=lane,
limit=effective_limit, limit=effective_limit,
only_new=effective_only_new, only_new=effective_only_new,
start_page=resume_from_page,
progress_callback=lambda page_number: _save_sync_checkpoint(
redis_client,
task_id=task_id,
page_number=page_number,
make=make,
model=model,
lane=lane,
),
) )
result = _run_browser_job(_job) result = _run_browser_job(_job)
@@ -385,11 +467,14 @@ def sync_listing_task(
bootstrap_completed = bool(result.get("full_scan_completed")) bootstrap_completed = bool(result.get("full_scan_completed"))
if bootstrap_completed: if bootstrap_completed:
_set_full_scan_done(redis_client, True) _set_full_scan_done(redis_client, True)
_clear_sync_checkpoint(redis_client)
logger.info("Bootstrap full scan completed; hourly schedule continues") logger.info("Bootstrap full scan completed; hourly schedule continues")
else: else:
_set_full_scan_done(redis_client, False) _set_full_scan_done(redis_client, False)
logger.info("Bootstrap full scan not complete yet; queuing immediate continuation") logger.info("Bootstrap full scan not complete yet; queuing immediate continuation")
_enqueue_bootstrap_followup("bootstrap_not_completed") _enqueue_bootstrap_followup("bootstrap_not_completed")
elif result.get("status") == "success":
_clear_sync_checkpoint(redis_client)
summary = { summary = {
"task_id": task_id, "task_id": task_id,

View File

@@ -11,6 +11,7 @@ class _FakePage:
def __init__(self, counts: dict[str, int]) -> None: def __init__(self, counts: dict[str, int]) -> None:
self._counts = counts self._counts = counts
self._evaluate_result = False self._evaluate_result = False
self._evaluate_values: list[object] = []
class _Locator: class _Locator:
def __init__(self, count_value: int) -> None: def __init__(self, count_value: int) -> None:
@@ -23,6 +24,8 @@ class _FakePage:
return _FakePage._Locator(self._counts.get(selector, 0)) return _FakePage._Locator(self._counts.get(selector, 0))
def evaluate(self, _script: str): def evaluate(self, _script: str):
if self._evaluate_values:
return self._evaluate_values.pop(0)
return self._evaluate_result return self._evaluate_result
@@ -40,6 +43,11 @@ class TestListingUnit(unittest.TestCase):
page._evaluate_result = True page._evaluate_result = True
self.assertTrue(ListingCollector._has_next_page(page)) self.assertTrue(ListingCollector._has_next_page(page))
def test_get_current_page_number_from_text_counter(self) -> None:
page = _FakePage({})
page._evaluate_values = [5]
self.assertEqual(ListingCollector._get_current_page_number(page), 5)
def test_extract_vehicle_links_from_html_finds_detail_urls(self) -> None: def test_extract_vehicle_links_from_html_finds_detail_urls(self) -> None:
collector = ListingCollector(Settings(), HumanPacer(Settings())) collector = ListingCollector(Settings(), HumanPacer(Settings()))
html = """ html = """

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from iaai_scraper.core.config import Settings from iaai_scraper.core.config import Settings
@@ -169,6 +170,50 @@ class TestScraperSync(unittest.TestCase):
self.assertTrue(IAAIScraper._is_protection_or_network_error(RuntimeError("captcha challenge"))) self.assertTrue(IAAIScraper._is_protection_or_network_error(RuntimeError("captcha challenge")))
self.assertFalse(IAAIScraper._is_protection_or_network_error(RuntimeError("plain validation error"))) self.assertFalse(IAAIScraper._is_protection_or_network_error(RuntimeError("plain validation error")))
def test_recover_empty_listing_page_reload_recovers_links(self) -> None:
scraper = self._make_scraper()
page = MagicMock()
page_result = SimpleNamespace(
vehicle_links=[SimpleNamespace(href="https://www.iaai.com/VehicleDetail/123~US")],
)
scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result)
all_raw_urls: list[str] = []
seen_urls: set[str] = set()
recovered_result, recovered_urls = scraper._recover_empty_listing_page(
page,
page_number=5,
all_raw_urls=all_raw_urls,
seen_urls=seen_urls,
)
page.reload.assert_called_once()
self.assertIs(recovered_result, page_result)
self.assertEqual(recovered_urls, ["https://www.iaai.com/VehicleDetail/123~US"])
self.assertEqual(all_raw_urls, ["https://www.iaai.com/VehicleDetail/123~US"])
def test_recover_empty_listing_page_reopens_listing_for_first_page(self) -> None:
scraper = self._make_scraper()
page = MagicMock()
page_result = SimpleNamespace(
vehicle_links=[SimpleNamespace(href="https://www.iaai.com/VehicleDetail/456~US")],
)
scraper.listing_collector.open_cars_listing = MagicMock()
scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result)
recovered_result, recovered_urls = scraper._recover_empty_listing_page(
page,
page_number=1,
all_raw_urls=[],
seen_urls=set(),
)
scraper.listing_collector.open_cars_listing.assert_called_once_with(page)
page.reload.assert_not_called()
self.assertIs(recovered_result, page_result)
self.assertEqual(recovered_urls, ["https://www.iaai.com/VehicleDetail/456~US"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -72,6 +73,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
persistence = MagicMock() persistence = MagicMock()
get_persistence.return_value = persistence get_persistence.return_value = persistence
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client get_redis.return_value = redis_client
stop_event = MagicMock() stop_event = MagicMock()
heartbeat_thread = MagicMock() heartbeat_thread = MagicMock()
@@ -140,6 +142,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
persistence = MagicMock() persistence = MagicMock()
get_persistence.return_value = persistence get_persistence.return_value = persistence
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client get_redis.return_value = redis_client
stop_event = MagicMock() stop_event = MagicMock()
heartbeat_thread = MagicMock() heartbeat_thread = MagicMock()
@@ -156,6 +159,78 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
self.assertEqual(acquire_lock.call_count, 2) self.assertEqual(acquire_lock.call_count, 2)
release_lock.assert_called_once() release_lock.assert_called_once()
def test_sync_listing_checkpoint_roundtrip(self) -> None:
redis_client = MagicMock()
storage: dict[str, str] = {}
redis_client.set.side_effect = lambda key, value: storage.__setitem__(key, value)
redis_client.get.side_effect = lambda key: storage.get(key)
tasks._save_sync_checkpoint(
redis_client,
task_id="task-1",
page_number=12,
make=None,
model=None,
lane="iaai_cars",
)
checkpoint = tasks._load_sync_checkpoint(redis_client)
self.assertIsNotNone(checkpoint)
self.assertEqual(checkpoint["last_successful_page"], 12)
self.assertEqual(checkpoint["status"], "in_progress")
def test_sync_listing_task_resumes_from_checkpoint_page(self) -> None:
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_clear_sync_checkpoint") as clear_checkpoint, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()):
persistence = MagicMock()
get_persistence.return_value = persistence
redis_client = MagicMock()
redis_client.get.side_effect = lambda key: json.dumps({
"status": "in_progress",
"last_successful_page": 9,
"make": None,
"model": None,
"lane": "iaai_cars",
}) if key == tasks.SYNC_LISTING_CHECKPOINT_KEY else None
get_redis.return_value = redis_client
stop_event = MagicMock()
heartbeat_thread = MagicMock()
start_heartbeat.return_value = (stop_event, heartbeat_thread)
sync_listing_mock = MagicMock(return_value={
"run_id": 11,
"status": "success",
"full_scan_completed": True,
"cars_upserted": 1,
"cars_failed": 0,
"images_upserted": 0,
"skipped_existing": 0,
"elapsed_seconds": 1.0,
"failures": [],
})
scraper_ctx = MagicMock()
scraper_ctx.__enter__.return_value.sync_listing = sync_listing_mock
scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-789")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
self.assertEqual(sync_listing_mock.call_args.kwargs["start_page"], 10)
clear_checkpoint.assert_called()
release_lock.assert_called_once()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()