improve scraper runtime

This commit is contained in:
qananasikq
2026-04-08 14:23:40 +03:00
parent b0f44efdb8
commit fd1d1d3c9f
4 changed files with 79 additions and 42 deletions

View File

@@ -11,5 +11,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
STOPSIGNAL SIGINT
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
CMD ["python", "main.py", "--help"] CMD ["python", "main.py", "run-daemon"]

View File

@@ -3,10 +3,13 @@ services:
build: . build: .
container_name: iaai-scraper container_name: iaai-scraper
env_file: env_file:
- .env - path: .env
required: false
volumes: volumes:
- ./:/app - ./:/app
- /app/.venv - /app/.venv
- /app/__pycache__ - /app/__pycache__
working_dir: /app working_dir: /app
command: python main.py --help restart: unless-stopped
stop_grace_period: 30s
command: python main.py run-daemon

View File

@@ -1,6 +1,7 @@
import argparse import argparse
from pathlib import Path from pathlib import Path
from .core.config import Settings
from .core.utils import save_to_json from .core.utils import save_to_json
from .scraper import IAAIScraper from .scraper import IAAIScraper
@@ -75,8 +76,6 @@ def main() -> None:
runtime_settings: Settings | None = None runtime_settings: Settings | None = None
if args.headless is not None or args.debug: if args.headless is not None or args.debug:
from .core.config import Settings
runtime_settings = Settings() runtime_settings = Settings()
if args.headless is not None: if args.headless is not None:
runtime_settings.headless = args.headless == "true" runtime_settings.headless = args.headless == "true"
@@ -85,7 +84,6 @@ def main() -> None:
if args.command == "run-daemon": if args.command == "run-daemon":
# daemon: бесконечный цикл # daemon: бесконечный цикл
from .core.config import Settings
runtime_settings = runtime_settings or Settings() runtime_settings = runtime_settings or Settings()
if args.interval is not None: if args.interval is not None:
runtime_settings.scheduler_interval_minutes = args.interval runtime_settings.scheduler_interval_minutes = args.interval

View File

@@ -1,4 +1,5 @@
import logging import logging
import signal
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -39,6 +40,7 @@ class IAAIScraper:
self.vehicle_parser = VehicleParser() self.vehicle_parser = VehicleParser()
self.car_mapper = CarMapper() self.car_mapper = CarMapper()
self.persistence = PersistenceService(self.settings) self.persistence = PersistenceService(self.settings)
self._shutdown_requested = False
# browser lifecycle # browser lifecycle
@@ -170,7 +172,7 @@ class IAAIScraper:
trace_id = self._new_trace_id("scrape") trace_id = self._new_trace_id("scrape")
started_at = time.perf_counter() started_at = time.perf_counter()
capture = NetworkCapture(self.settings) capture = NetworkCapture(self.settings)
capture.attach(page) capture.attach(page, origin_url=vehicle_url)
page.goto(vehicle_url, wait_until="domcontentloaded", timeout=60_000) page.goto(vehicle_url, wait_until="domcontentloaded", timeout=60_000)
try: try:
@@ -264,24 +266,26 @@ class IAAIScraper:
trace_id = self._new_trace_id("sync-listing") trace_id = self._new_trace_id("sync-listing")
started_at = time.perf_counter() started_at = time.perf_counter()
self.persistence.create_tables() self.persistence.create_tables()
listing = self.collect_listing(make=make, model=model)
vehicle_urls = list(listing.get("vehicle_urls", []))
if limit is not None:
vehicle_urls = vehicle_urls[:max(0, limit)]
run_id = self.persistence.start_sync_run(lane=lane) run_id = self.persistence.start_sync_run(lane=lane)
cars_upserted = 0 cars_upserted = 0
cars_failed = 0 cars_failed = 0
images_upserted = 0 images_upserted = 0
total = 0
failures: list[dict[str, str]] = [] failures: list[dict[str, str]] = []
listing: dict = {}
total = len(vehicle_urls)
logger.info("Starting sync: %d vehicles to process", total)
page = self._get_page()
try: try:
listing = self.collect_listing(make=make, model=model)
vehicle_urls = list(listing.get("vehicle_urls", []))
if limit is not None:
vehicle_urls = vehicle_urls[:max(0, limit)]
total = len(vehicle_urls)
logger.info("Starting sync: %d vehicles to process", total)
for index, vehicle_url in enumerate(vehicle_urls, start=1): for index, vehicle_url in enumerate(vehicle_urls, start=1):
page = self._get_page()
try: try:
# Повторно используем страницу, чтобы не создавать лишний overhead.
logger.info("[%d/%d] Scraping %s", index, total, vehicle_url) logger.info("[%d/%d] Scraping %s", index, total, vehicle_url)
scrape_result = self._scrape_on_page(page, vehicle_url) scrape_result = self._scrape_on_page(page, vehicle_url)
db_record = scrape_result.get("db_record") db_record = scrape_result.get("db_record")
@@ -289,7 +293,8 @@ class IAAIScraper:
raise RuntimeError("Scrape result does not contain db_record") raise RuntimeError("Scrape result does not contain db_record")
record = CarRecord.model_validate(db_record) record = CarRecord.model_validate(db_record)
upsert = self.persistence.upsert_car(record) upsert = self.persistence.upsert_car(record)
cars_upserted += 1 if upsert.get("action") != "skipped":
cars_upserted += 1
img_count = int(upsert.get("images_upserted", 0)) img_count = int(upsert.get("images_upserted", 0))
images_upserted += img_count images_upserted += img_count
logger.info( logger.info(
@@ -299,35 +304,35 @@ class IAAIScraper:
record.year or "?", record.price or "N/A", img_count, record.year or "?", record.price or "N/A", img_count,
) )
except Exception as exc: except Exception as exc:
# После сбоя пересоздаём page, чтобы не остаться в битом состоянии.
cars_failed += 1 cars_failed += 1
failures.append({"vehicle_url": vehicle_url, "error": str(exc)}) failures.append({"vehicle_url": vehicle_url, "error": str(exc)})
logger.error("[%d/%d] Failed %s: %s", index, total, vehicle_url, exc) logger.error("[%d/%d] Failed %s: %s", index, total, vehicle_url, exc)
finally:
try: try:
page.close() page.close()
except Exception: except Exception:
pass pass
page = self._get_page()
if index < total: if index < total:
self.pacer.between_vehicles() self.pacer.between_vehicles()
except Exception as exc:
# collect_listing itself failed — count as total failure
if not failures:
failures.append({"vehicle_url": "collect_listing", "error": str(exc)})
logger.error("sync_listing failed: %s", exc)
finally: finally:
try: status = "success" if not failures else ("partial_success" if cars_upserted else "failed")
page.close() error_summary = "; ".join(item["error"] for item in failures[:10]) if failures else None
except Exception: self.persistence.finish_sync_run(
pass run_id,
status=status,
ids_fetched=total,
cars_upserted=cars_upserted,
cars_failed=cars_failed,
images_upserted=images_upserted,
error_summary=error_summary,
)
status = "success" if not failures else ("partial_success" if cars_upserted else "failed")
error_summary = "; ".join(item["error"] for item in failures[:10]) if failures else None
self.persistence.finish_sync_run(
run_id,
status=status,
ids_fetched=total,
cars_upserted=cars_upserted,
cars_failed=cars_failed,
images_upserted=images_upserted,
error_summary=error_summary,
)
logger.info( logger.info(
"Sync run #%d finished: %d/%d upserted, %d failed, %d images", "Sync run #%d finished: %d/%d upserted, %d failed, %d images",
run_id, cars_upserted, total, cars_failed, images_upserted, run_id, cars_upserted, total, cars_failed, images_upserted,
@@ -348,17 +353,26 @@ class IAAIScraper:
def run_scheduled(self) -> None: def run_scheduled(self) -> None:
# Простой бесконечный цикл без внешнего планировщика. # Простой бесконечный цикл без внешнего планировщика.
# Graceful shutdown по SIGINT/SIGTERM.
def _handle_shutdown(signum, frame):
logger.info("Received signal %s, shutting down gracefully...", signum)
self._shutdown_requested = True
signal.signal(signal.SIGINT, _handle_shutdown)
signal.signal(signal.SIGTERM, _handle_shutdown)
interval = self.settings.scheduler_interval_minutes * 60 interval = self.settings.scheduler_interval_minutes * 60
logger.info( logger.info(
"Scheduler started: syncing every %d minutes", "Scheduler started: syncing every %d minutes",
self.settings.scheduler_interval_minutes, self.settings.scheduler_interval_minutes,
) )
cycle = 0 cycle = 0
while True: while not self._shutdown_requested:
cycle += 1 cycle += 1
logger.info("=== Scheduler cycle #%d starting ===", cycle) logger.info("=== Scheduler cycle #%d starting ===", cycle)
start = time.time() start = time.time()
try: try:
# Сбрасываем контекст перед каждым циклом.
if self.context: if self.context:
try: try:
self.context.close() self.context.close()
@@ -366,6 +380,12 @@ class IAAIScraper:
pass pass
self.context = None self.context = None
# Проверяем, что browser/playwright живы; пересоздаём при необходимости.
if self.browser is None or self.playwright is None:
logger.info("Browser/Playwright not available, re-initializing...")
self.close()
self.__enter__()
result = self.sync_listing() result = self.sync_listing()
elapsed = time.time() - start elapsed = time.time() - start
logger.info( logger.info(
@@ -377,17 +397,31 @@ class IAAIScraper:
except Exception as exc: except Exception as exc:
elapsed = time.time() - start elapsed = time.time() - start
logger.error("Cycle #%d failed after %.1fs: %s", cycle, elapsed, exc) logger.error("Cycle #%d failed after %.1fs: %s", cycle, elapsed, exc)
if self.context: # Полный сброс при любой ошибке цикла — следующий цикл пересоздаст всё.
try: try:
self.context.close() self.close()
except PlaywrightError: except Exception:
pass pass
self.context = None # Пересоздаём browser для следующего цикла.
try:
self.__enter__()
except Exception as reinit_exc:
logger.error("Failed to re-initialize browser: %s", reinit_exc)
if self._shutdown_requested:
break
sleep_time = max(0, interval - (time.time() - start)) sleep_time = max(0, interval - (time.time() - start))
if sleep_time > 0: if sleep_time > 0:
logger.info("Sleeping %.0f seconds until next cycle...", sleep_time) logger.info("Sleeping %.0f seconds until next cycle...", sleep_time)
time.sleep(sleep_time) # Прерываемый sleep — проверяем shutdown каждые 5 секунд.
slept = 0.0
while slept < sleep_time and not self._shutdown_requested:
chunk = min(5.0, sleep_time - slept)
time.sleep(chunk)
slept += chunk
logger.info("Scheduler stopped gracefully after %d cycles.", cycle)
# helpers # helpers