diff --git a/.env.example b/.env.example
index e86b247..67a09fe 100644
--- a/.env.example
+++ b/.env.example
@@ -1,24 +1,24 @@
- # mobile.de scraper Docker defaults
+# Параметры mobile.de для Docker.
-# PostgreSQL used by docker-compose.
+# Настройки PostgreSQL.
POSTGRES_USER=mobilede
POSTGRES_PASSWORD=mobilede
POSTGRES_DB=mobilede_scraper
MOBILEDE_DATABASE_URL=postgresql+psycopg2://mobilede:mobilede@postgres:5432/mobilede_scraper
-# Mobile.de deployment ports
+# Порты сервисов.
MOBILEDE_HOST_API_PORT=8000
MOBILEDE_HOST_POSTGRES_PORT=5432
MOBILEDE_HOST_REDIS_PORT=6379
-# Legacy env names still consumed by Settings until the old IAAI core is fully removed.
-IAAI_DATABASE_URL=postgresql+psycopg2://mobilede:mobilede@postgres:5432/mobilede_scraper
-IAAI_DATABASE_ECHO=false
-IAAI_DATABASE_POOL_SIZE=5
-IAAI_DATABASE_MAX_OVERFLOW=5
-IAAI_DATABASE_POOL_RECYCLE_SECONDS=1800
+# Временные legacy-переменные.
+MOBILEDE_DATABASE_URL=postgresql+psycopg2://mobilede:mobilede@postgres:5432/mobilede_scraper
+MOBILEDE_DATABASE_ECHO=false
+MOBILEDE_DATABASE_POOL_SIZE=5
+MOBILEDE_DATABASE_MAX_OVERFLOW=5
+MOBILEDE_DATABASE_POOL_RECYCLE_SECONDS=1800
-# Redis / Celery stack.
-IAAI_REDIS_URL=redis://redis:6379/0
+# Настройки Redis и Celery.
+MOBILEDE_REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_TASK_SOFT_TIME_LIMIT=86400
@@ -30,7 +30,7 @@ IAAI_STARTUP_SYNC_ENABLED=true
MOBILEDE_STARTUP_MAX_PAGES=5
MOBILEDE_BEAT_MAX_PAGES=5
-# mobile.de one-shot CLI services.
+# Одноразовые CLI-сервисы.
MOBILEDE_SEARCH_START_PAGE=1
MOBILEDE_SEARCH_MAX_PAGES=1
MOBILEDE_REQUEST_DELAY_SECONDS=0.7
@@ -40,8 +40,8 @@ MOBILEDE_CONTINUOUS_SYNC_ENABLED=true
MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS=0
MOBILEDE_PROGRESS_LOG_EVERY_PAGES=10
-# Proxy for direct HTTP requests (requests / search path).
-# Example:
+# Прокси для HTTP-запросов.
+# Пример:
# MOBILEDE_PROXY_SERVER=http://host:port
# MOBILEDE_PROXY_USERNAME=username
# MOBILEDE_PROXY_PASSWORD=password
@@ -49,20 +49,20 @@ MOBILEDE_PROXY_SERVER=
MOBILEDE_PROXY_USERNAME=
MOBILEDE_PROXY_PASSWORD=
-# Optional SOCKS5 proxy bridge for browser runtime.
-# If SOCKS5_PROXY_HOST is set, entrypoint starts local bridge on http://127.0.0.1:8899.
-# In that case MOBILEDE_PROXY_SERVER can be left empty and will be injected automatically for browser runtime.
+# Опциональный SOCKS5 bridge.
+# При SOCKS5_PROXY_HOST мост поднимается на http://127.0.0.1:8899.
+# Тогда MOBILEDE_PROXY_SERVER можно не задавать.
SOCKS5_PROXY_HOST=
SOCKS5_PROXY_PORT=1002
SOCKS5_PROXY_USER=
SOCKS5_PROXY_PASS=
-# Logging / runtime.
+# Логи и runtime.
IAAI_LOG_LEVEL=INFO
IAAI_RUNTIME_CONFIG_FILE=/app/runtime_config.json
TZ=UTC
-# Legacy browser settings kept for old deprecated IAAI commands only.
+# Старые browser-переменные.
IAAI_HEADLESS=true
IAAI_BROWSER_ENGINE=chromium
IAAI_TOKENS_FILE=/data/tokens.json
diff --git a/.gitignore b/.gitignore
index daf4eab..dcb8a5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,5 +22,5 @@ tokens_data/
tokens.json
.deploy_tmp/
.tmp_db_check.sql
-deploy-mobilede.tar.gz
-tmp_worker_log.txt
\ No newline at end of file
+deploy-*.tar.gz
+tmp_worker_log.txt
diff --git a/README.md b/README.md
index 6a82af4..d4ef6fa 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,35 @@
# mobile.de Scraper
-Парсер [`mobile.de`](https://www.mobile.de/ru).
+Парсер [`mobile.de`](https://www.mobile.de/ru).
+
+Сервис для сбора объявлений авто с mobile.de, сохранения в PostgreSQL и регулярного обновления.
+
+## Как идет парсинг
+- Парсинг запускается задачами Celery в очереди `mobilede_sync`.
+- Стартуем с поисковой ссылки mobile.de (URL с фильтрами: марка, цена, год, пробег и т.д.).
+- Выдача разбивается на сегменты (по цене, году, пробегу), чтобы обходить лимиты страниц.
+- По каждому сегменту читаются страницы поиска, из них достаются ID и ссылки объявлений.
+- По ID/ссылкам загружаются карточки авто, данные маппятся в единый формат и пишутся в БД через upsert.
+
+## Стек
+- `FastAPI` — API и служебные ручки
+- `Celery + Redis` — очередь задач парсинга
+- `PostgreSQL` — хранение авто и изображений
+- `Docker Compose` — запуск всех сервисов
+
+## Быстрый запуск
+```bash
+docker compose up -d --build
+```
+
+## Прокси
+- В воркере используется прокси-мост: локальный HTTP `127.0.0.1:8899` -> внешний SOCKS5.
+- Это помогает стабилизировать доступ к mobile.de и снизить блокировки.
+- Параметры прокси задаются через переменные окружения (см. `.env` / `docker-compose.yml`).
+
+## Основные сервисы
+- `api` — HTTP API
+- `worker` — парсинг и апдейты
+- `beat` — планировщик задач
+- `postgres` — база данных
+- `redis` — брокер очереди
diff --git a/docker-compose.yml b/docker-compose.yml
index 05b210a..daf7c6e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,13 +1,11 @@
-x-app-env: &app-env
- # Legacy env name is still used by Settings; values are mobile.de defaults.
- IAAI_DATABASE_URL: ${MOBILEDE_DATABASE_URL:-postgresql+psycopg2://mobilede:mobilede@postgres:5432/mobilede_scraper}
+x-app-env: &app-env
MOBILEDE_DATABASE_URL: ${MOBILEDE_DATABASE_URL:-postgresql+psycopg2://mobilede:mobilede@postgres:5432/mobilede_scraper}
- IAAI_REDIS_URL: ${IAAI_REDIS_URL:-redis://redis:6379/0}
+ MOBILEDE_REDIS_URL: ${MOBILEDE_REDIS_URL:-redis://redis:6379/0}
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
- IAAI_DATABASE_POOL_RECYCLE_SECONDS: ${IAAI_DATABASE_POOL_RECYCLE_SECONDS:-1800}
- IAAI_DATABASE_POOL_SIZE: ${IAAI_DATABASE_POOL_SIZE:-20}
- IAAI_DATABASE_MAX_OVERFLOW: ${IAAI_DATABASE_MAX_OVERFLOW:-40}
+ MOBILEDE_DATABASE_POOL_RECYCLE_SECONDS: ${MOBILEDE_DATABASE_POOL_RECYCLE_SECONDS:-1800}
+ MOBILEDE_DATABASE_POOL_SIZE: ${MOBILEDE_DATABASE_POOL_SIZE:-20}
+ MOBILEDE_DATABASE_MAX_OVERFLOW: ${MOBILEDE_DATABASE_MAX_OVERFLOW:-40}
CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-900}
CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-1200}
CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-2400}
@@ -21,7 +19,7 @@ x-app-env: &app-env
IAAI_REQUEST_JITTER_MAX_S: ${IAAI_REQUEST_JITTER_MAX_S:-0}
IAAI_DETAIL_RETRIES: ${IAAI_DETAIL_RETRIES:-1}
IAAI_LISTING_RETRIES: ${IAAI_LISTING_RETRIES:-1}
- # 0 = без лимита.
+ # 0 = без лимита.
CELERY_BEAT_SYNC_LIMIT: ${CELERY_BEAT_SYNC_LIMIT:-0}
CELERY_WORKER_CONCURRENCY: ${CELERY_WORKER_CONCURRENCY:-4}
CELERY_WORKER_POOL: ${CELERY_WORKER_POOL:-prefork}
@@ -31,6 +29,8 @@ x-app-env: &app-env
MOBILEDE_BEAT_MAX_PAGES: ${MOBILEDE_BEAT_MAX_PAGES:-100}
MOBILEDE_REQUEST_DELAY_SECONDS: ${MOBILEDE_REQUEST_DELAY_SECONDS:-0}
MOBILEDE_CONCURRENT_PAGES: ${MOBILEDE_CONCURRENT_PAGES:-2}
+ MOBILEDE_FILTERED_SEARCH_URL: ${MOBILEDE_FILTERED_SEARCH_URL:-}
+ MOBILEDE_FILTERED_SEARCH_URLS: ${MOBILEDE_FILTERED_SEARCH_URLS:-}
MOBILEDE_CURSOR_ENABLED: ${MOBILEDE_CURSOR_ENABLED:-true}
MOBILEDE_CONTINUOUS_SYNC_ENABLED: ${MOBILEDE_CONTINUOUS_SYNC_ENABLED:-true}
MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS: ${MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS:-3600}
@@ -43,12 +43,21 @@ x-app-env: &app-env
MOBILEDE_MAX_PAGE_NUMBER: ${MOBILEDE_MAX_PAGE_NUMBER:-50}
MOBILEDE_SEGMENT_TARGET_RESULTS: ${MOBILEDE_SEGMENT_TARGET_RESULTS:-1000}
MOBILEDE_COMPACT_SEGMENTS: ${MOBILEDE_COMPACT_SEGMENTS:-true}
+ MOBILEDE_PREPLAN_SEGMENT_PROBES: ${MOBILEDE_PREPLAN_SEGMENT_PROBES:-false}
+ MOBILEDE_PREPLAN_MAX_SEGMENTS: ${MOBILEDE_PREPLAN_MAX_SEGMENTS:-1000}
+ MOBILEDE_PREPLAN_MAX_PROBES: ${MOBILEDE_PREPLAN_MAX_PROBES:-40}
MOBILEDE_DYNAMIC_SEGMENT_PROBES: ${MOBILEDE_DYNAMIC_SEGMENT_PROBES:-false}
MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE: ${MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE:-false}
MOBILEDE_SKIP_EMPTY_DYNAMIC_SEGMENTS: ${MOBILEDE_SKIP_EMPTY_DYNAMIC_SEGMENTS:-true}
MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED: ${MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED:-true}
MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP: ${MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP:-true}
MOBILEDE_INCREMENTAL_PAGE_WINDOW: ${MOBILEDE_INCREMENTAL_PAGE_WINDOW:-1}
+ MOBILEDE_FLARESOLVERR_ENABLED: ${MOBILEDE_FLARESOLVERR_ENABLED:-false}
+ MOBILEDE_FLARESOLVERR_URL: ${MOBILEDE_FLARESOLVERR_URL:-http://flaresolverr:8191/v1}
+ MOBILEDE_FLARESOLVERR_TIMEOUT_SECONDS: ${MOBILEDE_FLARESOLVERR_TIMEOUT_SECONDS:-120}
+ MOBILEDE_FLARESOLVERR_MAX_TIMEOUT_MS: ${MOBILEDE_FLARESOLVERR_MAX_TIMEOUT_MS:-60000}
+ MOBILEDE_FLARESOLVERR_SESSION: ${MOBILEDE_FLARESOLVERR_SESSION:-}
+ MOBILEDE_FLARESOLVERR_STATUSES: ${MOBILEDE_FLARESOLVERR_STATUSES:-403,429,503}
IAAI_STARTUP_SYNC_ENABLED: ${IAAI_STARTUP_SYNC_ENABLED:-true}
IAAI_INTER_BATCH_DELAY_SECONDS: ${IAAI_INTER_BATCH_DELAY_SECONDS:-0}
IAAI_FAIL_RATE_THRESHOLD: ${IAAI_FAIL_RATE_THRESHOLD:-0.9}
@@ -65,13 +74,13 @@ x-app-env: &app-env
IAAI_TOKENS_FILE: ${IAAI_TOKENS_FILE:-/home/app/tokens.json}
IAAI_RUNTIME_CONFIG_FILE: ${IAAI_RUNTIME_CONFIG_FILE:-/app/runtime_config.json}
IAAI_BROWSER_ENGINE: chromium
- # Self-heal для worker.
+ # Автовосстановление worker.
IAAI_SELF_HEAL_ENABLED: ${IAAI_SELF_HEAL_ENABLED:-true}
IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS: ${IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS:-30}
IAAI_SELF_HEAL_STALL_SECONDS: ${IAAI_SELF_HEAL_STALL_SECONDS:-900}
IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS: ${IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS:-300}
IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS: ${IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS:-300}
- # Если в Postgres нет записей дольше 60 минут при активной работе — полный restart с segment 1.
+ # При простое БД перезапускаем worker.
IAAI_DB_IDLE_RESTART_SECONDS: ${IAAI_DB_IDLE_RESTART_SECONDS:-3600}
TZ: ${TZ:-UTC}
@@ -93,7 +102,7 @@ x-worker-service: &worker-service
- tokens_data:/data
services:
- # PostgreSQL.
+ # База данных.
postgres:
image: postgres:16-alpine
container_name: mobilede-postgres
@@ -117,7 +126,7 @@ services:
max-size: "10m"
max-file: "5"
- # Redis.
+ # Очередь Redis.
redis:
image: redis:7-alpine
container_name: mobilede-redis
@@ -141,7 +150,24 @@ services:
max-size: "10m"
max-file: "5"
- # Миграции.
+ flaresolverr:
+ image: ghcr.io/flaresolverr/flaresolverr:latest
+ container_name: mobilede-flaresolverr
+ restart: unless-stopped
+ environment:
+ LOG_LEVEL: ${FLARESOLVERR_LOG_LEVEL:-info}
+ LOG_HTML: ${FLARESOLVERR_LOG_HTML:-false}
+ CAPTCHA_SOLVER: ${FLARESOLVERR_CAPTCHA_SOLVER:-none}
+ TZ: ${TZ:-UTC}
+ ports:
+ - "127.0.0.1:${MOBILEDE_HOST_FLARESOLVERR_PORT:-8191}:8191"
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "5"
+
+ # Миграции.
migrate:
<<: *app-service
container_name: mobilede-migrate
@@ -151,7 +177,7 @@ services:
condition: service_healthy
command: alembic upgrade head
- # API.
+ # API сервис.
api:
<<: *app-service
container_name: mobilede-api
@@ -179,7 +205,7 @@ services:
max-size: "10m"
max-file: "5"
- # Worker.
+ # Worker сервис.
worker:
<<: *worker-service
container_name: mobilede-worker
@@ -197,7 +223,7 @@ services:
--pidfile=/tmp/celery-worker.pid
-Q mobilede_sync,iaai_sync --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5}
healthcheck:
- # Проверка worker.
+ # Проверка worker.
test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid) && pgrep -f 'mobilede_scraper.worker.self_heal' >/dev/null"]
interval: 60s
timeout: 10s
@@ -209,7 +235,7 @@ services:
max-size: "10m"
max-file: "5"
- # Beat.
+ # Beat сервис.
beat:
<<: *worker-service
container_name: mobilede-beat
@@ -237,7 +263,7 @@ services:
max-size: "10m"
max-file: "5"
- # One-shot CLI: collect mobile.de search pages into artifacts/json.
+ # Одноразовый сбор страниц поиска.
mobilede-search:
<<: *app-service
container_name: mobilede-search
@@ -256,7 +282,7 @@ services:
--delay ${MOBILEDE_REQUEST_DELAY_SECONDS:-0.7}
--output /app/artifacts/json/mobilede_search.json
- # One-shot CLI: collect and upsert mobile.de search pages into Postgres.
+ # Одноразовый sync поиска в БД.
mobilede-sync-search:
<<: *app-service
container_name: mobilede-sync-search
@@ -276,7 +302,7 @@ services:
--lane ${MOBILEDE_SYNC_LANE:-mobile_de_cars}
--output /app/artifacts/json/mobilede_sync_search.json
- # One-shot CLI: collect one detail page by listing id.
+ # Одноразовый сбор карточки по ID.
mobilede-detail:
<<: *app-service
container_name: mobilede-detail
diff --git a/iaai_scraper/cli.py b/iaai_scraper/cli.py
deleted file mode 100644
index 844e536..0000000
--- a/iaai_scraper/cli.py
+++ /dev/null
@@ -1,127 +0,0 @@
-import argparse
-from pathlib import Path
-
-from .core.config import Settings
-from .core.utils import save_to_json
-from .mobile_de import MobileDeClient, MobileDeScraper
-
-
-def build_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description="mobile.de scraper CLI")
- parser.add_argument("--headless", choices=["true", "false"], default=None, help="Override headless mode")
- parser.add_argument("--debug", action="store_true", help="Enable DEBUG logging")
- subparsers = parser.add_subparsers(dest="command", required=True)
-
- default_output_dir = Path("artifacts/json")
-
- subparsers.add_parser("init-db", help="Create DB tables")
-
- mobile_search_parser = subparsers.add_parser("search", aliases=["mobilede-search"], help="Collect mobile.de search result pages")
- mobile_search_parser.add_argument("--page", type=int, default=1)
- mobile_search_parser.add_argument("--max-pages", type=int, default=1)
- mobile_search_parser.add_argument("--delay", type=float, default=0.7)
- mobile_search_parser.add_argument("--search-url", default=None, help="готовая mobile.de ссылка с фильтрами")
- mobile_search_parser.add_argument("--make-id", default=None, help="mobile.de make id, for example BMW=3500")
- mobile_search_parser.add_argument("--model-id", default=None, help="mobile.de model id")
- mobile_search_parser.add_argument("--price-min", default=None)
- mobile_search_parser.add_argument("--price-max", default=None)
- mobile_search_parser.add_argument("--year-min", default=None)
- mobile_search_parser.add_argument("--year-max", default=None)
- mobile_search_parser.add_argument("--output", default=str(default_output_dir / "mobilede_listing_links.json"))
-
- mobile_detail_parser = subparsers.add_parser("detail", aliases=["mobilede-detail"], help="Collect one mobile.de detail page")
- mobile_detail_parser.add_argument("listing_id")
- mobile_detail_parser.add_argument("--output", default=str(default_output_dir / "mobilede_vehicle_detail.json"))
-
- mobile_sync_search_parser = subparsers.add_parser("sync-search", help="Collect and upsert mobile.de search result pages")
- mobile_sync_search_parser.add_argument("--page", type=int, default=1)
- mobile_sync_search_parser.add_argument("--max-pages", type=int, default=1)
- mobile_sync_search_parser.add_argument("--delay", type=float, default=0.7)
- mobile_sync_search_parser.add_argument("--lane", default="mobile_de_cars")
- mobile_sync_search_parser.add_argument("--search-url", default=None, help="готовая mobile.de ссылка с фильтрами")
- mobile_sync_search_parser.add_argument("--make-id", default=None)
- mobile_sync_search_parser.add_argument("--model-id", default=None)
- mobile_sync_search_parser.add_argument("--price-min", default=None)
- mobile_sync_search_parser.add_argument("--price-max", default=None)
- mobile_sync_search_parser.add_argument("--year-min", default=None)
- mobile_sync_search_parser.add_argument("--year-max", default=None)
- mobile_sync_search_parser.add_argument("--output", default=str(default_output_dir / "mobilede_sync_search.json"))
-
- mobile_sync_detail_parser = subparsers.add_parser("sync-detail", help="Collect and upsert one mobile.de detail page")
- mobile_sync_detail_parser.add_argument("listing_id")
- mobile_sync_detail_parser.add_argument("--lane", default="mobile_de_cars")
- mobile_sync_detail_parser.add_argument("--output", default=str(default_output_dir / "mobilede_sync_detail.json"))
-
- return parser
-
-
-def main() -> None:
- parser = build_parser()
- args = parser.parse_args()
-
- runtime_settings: Settings | None = None
- if args.headless is not None or args.debug:
- runtime_settings = Settings()
- if args.headless is not None:
- runtime_settings.headless = args.headless == "true"
- if args.debug:
- runtime_settings.log_level = "DEBUG"
-
- if args.command in {"search", "mobilede-search"}:
- scraper = MobileDeScraper(MobileDeClient(delay_seconds=args.delay))
- data = scraper.collect_search(
- start_page=args.page,
- max_pages=args.max_pages,
- search_url=args.search_url,
- make_id=args.make_id,
- model_id=args.model_id,
- price_min=args.price_min,
- price_max=args.price_max,
- year_min=args.year_min,
- year_max=args.year_max,
- )
- save_to_json(data, Path(args.output))
- print(f"Saved to {Path(args.output).resolve()}")
- return
-
- if args.command in {"detail", "mobilede-detail"}:
- scraper = MobileDeScraper()
- data = scraper.collect_detail(args.listing_id)
- save_to_json(data, Path(args.output))
- print(f"Saved to {Path(args.output).resolve()}")
- return
-
- if args.command == "sync-search":
- scraper = MobileDeScraper(MobileDeClient(delay_seconds=args.delay))
- data = scraper.sync_search(
- start_page=args.page,
- max_pages=args.max_pages,
- lane=args.lane,
- search_url=args.search_url,
- make_id=args.make_id,
- model_id=args.model_id,
- price_min=args.price_min,
- price_max=args.price_max,
- year_min=args.year_min,
- year_max=args.year_max,
- )
- save_to_json(data, Path(args.output))
- print(f"Saved to {Path(args.output).resolve()}")
- return
-
- if args.command == "sync-detail":
- scraper = MobileDeScraper()
- data = scraper.sync_detail(args.listing_id, lane=args.lane)
- save_to_json(data, Path(args.output))
- print(f"Saved to {Path(args.output).resolve()}")
- return
-
- if args.command == "init-db":
- scraper = MobileDeScraper()
- data = scraper.init_db()
- print(f"DB initialized: {data}")
- return
-
-
-if __name__ == "__main__":
- main()
diff --git a/mobilede_scraper/api/routes/__init__.py b/mobilede_scraper/api/routes/__init__.py
index 0f33059..a9cf553 100644
--- a/mobilede_scraper/api/routes/__init__.py
+++ b/mobilede_scraper/api/routes/__init__.py
@@ -1,224 +1,7 @@
-# Роуты запуска задач синхронизации и просмотра истории sync-runs.
+"""API route package.
-import json
-
-from fastapi import APIRouter, Depends, Query
-from pydantic import BaseModel
-from redis import Redis
-from sqlalchemy import select, func
-
-from ...core.config import Settings
-from ..deps import get_persistence
-from ...storage.db import PersistenceService
-from ...storage.models import SyncRun
-from ...worker.celery_app import MOBILEDE_SYNC_QUEUE, MOBILEDE_SYNC_QUEUE, celery_app
-from ...worker.tasks import mobilede_sync_detail_task, mobilede_sync_runtime_segments_task, mobilede_sync_search_task, sync_vehicle_task, sync_listing_task
-
-router = APIRouter()
-
-
-class SyncVehicleRequest(BaseModel):
- vehicle_url: str
- lane: str = "MOBILEDE"
-
-
-class SyncListingRequest(BaseModel):
- make: str | None = None
- model: str | None = None
- lane: str = "MOBILEDE_cars"
- limit: int | None = None
- only_new: bool | None = None
-
-
-class MobileDeSyncSearchRequest(BaseModel):
- start_page: int = 1
- max_pages: int = 5
- lane: str = "mobile_de_cars"
- search_url: str | None = None
- make_id: str | None = None
- model_id: str | None = None
- price_min: str | None = None
- price_max: str | None = None
- year_min: str | None = None
- year_max: str | None = None
- delay_seconds: float = 0.7
- use_cursor: bool = False
- continuous: bool = False
-
-
-class MobileDeSyncDetailRequest(BaseModel):
- listing_id: str
- lane: str = "mobile_de_cars"
-
-
-class MobileDeRuntimeSegmentsRequest(BaseModel):
- lane: str = "mobile_de_cars"
- delay_seconds: float = 0.7
- use_cursor: bool = True
- continuous: bool = False
-
-
-@router.post("/mobilede/tasks/sync-search")
-def start_mobilede_sync_search(body: MobileDeSyncSearchRequest):
- result = mobilede_sync_search_task.apply_async(
- kwargs=body.model_dump(),
- queue=MOBILEDE_SYNC_QUEUE,
- )
- return {
- "task_id": result.id,
- "status": "queued",
- "queue": MOBILEDE_SYNC_QUEUE,
- }
-
-
-@router.post("/mobilede/tasks/sync-detail")
-def start_mobilede_sync_detail(body: MobileDeSyncDetailRequest):
- result = mobilede_sync_detail_task.apply_async(
- kwargs=body.model_dump(),
- queue=MOBILEDE_SYNC_QUEUE,
- )
- return {
- "task_id": result.id,
- "status": "queued",
- "queue": MOBILEDE_SYNC_QUEUE,
- "listing_id": body.listing_id,
- }
-
-
-@router.post("/mobilede/tasks/sync-runtime-segments")
-def start_mobilede_runtime_segments(body: MobileDeRuntimeSegmentsRequest):
- result = mobilede_sync_runtime_segments_task.apply_async(
- kwargs=body.model_dump(),
- queue=MOBILEDE_SYNC_QUEUE,
- )
- return {
- "task_id": result.id,
- "status": "queued",
- "queue": MOBILEDE_SYNC_QUEUE,
- }
-
-
-@router.post("/tasks/sync-vehicle")
-def start_sync_vehicle(
- body: SyncVehicleRequest,
-):
- # Запустить задачу скрапинга одного автомобиля через Celery
- result = sync_vehicle_task.apply_async(
- kwargs={"vehicle_url": body.vehicle_url, "lane": body.lane},
- queue=MOBILEDE_SYNC_QUEUE,
- )
-
- return {
- "task_id": result.id,
- "status": "queued",
- "vehicle_url": body.vehicle_url,
- }
-
-
-@router.post("/tasks/sync-listing")
-def start_sync_listing(
- body: SyncListingRequest,
-):
- # Запустить задачу полного цикла листинга через Celery
- result = sync_listing_task.apply_async(
- kwargs={
- "make": body.make,
- "model": body.model,
- "lane": body.lane,
- "limit": body.limit,
- "only_new": body.only_new,
- },
- queue=MOBILEDE_SYNC_QUEUE,
- )
-
- return {
- "task_id": result.id,
- "status": "queued",
- }
-
-
-@router.get("/tasks/{task_id}")
-def get_task_status(task_id: str):
- result = celery_app.AsyncResult(task_id)
-
- payload: dict = {
- "task_id": task_id,
- "state": result.state,
- }
-
- if result.successful():
- payload["result"] = result.result
- elif result.failed():
- payload["error"] = str(result.result)
- elif result.info is not None:
- payload["meta"] = result.info
-
- progress = _read_task_progress(task_id)
- if progress is not None:
- payload["progress"] = progress
-
- return payload
-
-
-def _read_task_progress(task_id: str) -> dict | None:
- redis_client = None
- try:
- settings = Settings()
- redis_client = Redis.from_url(
- settings.redis.url,
- decode_responses=True,
- socket_connect_timeout=settings.redis.socket_connect_timeout_seconds,
- socket_timeout=settings.redis.socket_timeout_seconds,
- health_check_interval=settings.redis.health_check_interval_seconds,
- retry_on_timeout=True,
- )
- raw = redis_client.get(f"mobilede:state:task_progress:{task_id}")
- if not raw:
- return None
- data = json.loads(raw)
- return data if isinstance(data, dict) else None
- except Exception:
- return None
- finally:
- if redis_client is not None:
- try:
- redis_client.close()
- except Exception:
- pass
-
-
-@router.get("/sync-runs")
-def list_sync_runs(
- page: int = Query(1, ge=1),
- per_page: int = Query(20, ge=1, le=100),
- persistence: PersistenceService = Depends(get_persistence),
-):
- # Рстория запусков синхронизации
- with persistence.session_scope() as session:
- total = session.execute(select(func.count(SyncRun.id))).scalar() or 0
- offset = (page - 1) * per_page
- runs = session.execute(
- select(SyncRun).order_by(SyncRun.started_at.desc()).offset(offset).limit(per_page)
- ).scalars().all()
-
- return {
- "total": total,
- "page": page,
- "per_page": per_page,
- "items": [
- {
- "id": r.id,
- "started_at": r.started_at.isoformat() if r.started_at else None,
- "finished_at": r.finished_at.isoformat() if r.finished_at else None,
- "status": r.status,
- "lane": r.lane,
- "ids_fetched": r.ids_fetched,
- "cars_upserted": r.cars_upserted,
- "cars_failed": r.cars_failed,
- "images_upserted": r.images_upserted,
- "error_summary": r.error_summary,
- }
- for r in runs
- ],
- }
+Routers are defined in dedicated modules (`cars`, `health`, `tasks`). This
+package intentionally does not create routes to avoid duplicate task endpoints
+and accidental imports of legacy parser channels during FastAPI startup.
+"""
diff --git a/mobilede_scraper/api/routes/tasks.py b/mobilede_scraper/api/routes/tasks.py
index dc3fa2e..0b1d02f 100644
--- a/mobilede_scraper/api/routes/tasks.py
+++ b/mobilede_scraper/api/routes/tasks.py
@@ -11,25 +11,12 @@ from ...core.config import Settings
from ..deps import get_persistence
from ...storage.db import PersistenceService
from ...storage.models import SyncRun
-from ...worker.celery_app import MOBILEDE_SYNC_QUEUE, MOBILEDE_SYNC_QUEUE, celery_app
-from ...worker.tasks import mobilede_sync_detail_task, mobilede_sync_runtime_segments_task, mobilede_sync_search_task, sync_vehicle_task, sync_listing_task
+from ...worker.celery_app import MOBILEDE_SYNC_QUEUE, celery_app
+from ...worker.tasks import mobilede_sync_detail_task, mobilede_sync_runtime_segments_task, mobilede_sync_search_task
router = APIRouter()
-class SyncVehicleRequest(BaseModel):
- vehicle_url: str
- lane: str = "MOBILEDE"
-
-
-class SyncListingRequest(BaseModel):
- make: str | None = None
- model: str | None = None
- lane: str = "MOBILEDE_cars"
- limit: int | None = None
- only_new: bool | None = None
-
-
class MobileDeSyncSearchRequest(BaseModel):
start_page: int = 1
max_pages: int = 5
@@ -43,7 +30,7 @@ class MobileDeSyncSearchRequest(BaseModel):
year_max: str | None = None
delay_seconds: float = 0.7
use_cursor: bool = False
- continuous: bool | None = None
+ continuous: bool | None = False
class MobileDeSyncDetailRequest(BaseModel):
@@ -55,7 +42,7 @@ class MobileDeRuntimeSegmentsRequest(BaseModel):
lane: str = "mobile_de_cars"
delay_seconds: float = 0.7
use_cursor: bool = True
- continuous: bool | None = None
+ continuous: bool | None = False
@router.post("/mobilede/tasks/sync-search")
@@ -98,45 +85,6 @@ def start_mobilede_runtime_segments(body: MobileDeRuntimeSegmentsRequest):
}
-@router.post("/tasks/sync-vehicle")
-def start_sync_vehicle(
- body: SyncVehicleRequest,
-):
- # Запустить задачу скрапинга одного автомобиля через Celery
- result = sync_vehicle_task.apply_async(
- kwargs={"vehicle_url": body.vehicle_url, "lane": body.lane},
- queue=MOBILEDE_SYNC_QUEUE,
- )
-
- return {
- "task_id": result.id,
- "status": "queued",
- "vehicle_url": body.vehicle_url,
- }
-
-
-@router.post("/tasks/sync-listing")
-def start_sync_listing(
- body: SyncListingRequest,
-):
- # Запустить задачу полного цикла листинга через Celery
- result = sync_listing_task.apply_async(
- kwargs={
- "make": body.make,
- "model": body.model,
- "lane": body.lane,
- "limit": body.limit,
- "only_new": body.only_new,
- },
- queue=MOBILEDE_SYNC_QUEUE,
- )
-
- return {
- "task_id": result.id,
- "status": "queued",
- }
-
-
@router.get("/tasks/{task_id}")
def get_task_status(task_id: str):
result = celery_app.AsyncResult(task_id)
diff --git a/mobilede_scraper/browser/__init__.py b/mobilede_scraper/browser/__init__.py
deleted file mode 100644
index 38bb3f4..0000000
--- a/mobilede_scraper/browser/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .factory import BrowserFactory
-from .listing import ListingCollector
-from .network import NetworkCapture
-from .pace import HumanPacer
-
-__all__ = ["BrowserFactory", "ListingCollector", "NetworkCapture", "HumanPacer"]
diff --git a/mobilede_scraper/browser/factory.py b/mobilede_scraper/browser/factory.py
deleted file mode 100644
index 861c193..0000000
--- a/mobilede_scraper/browser/factory.py
+++ /dev/null
@@ -1,214 +0,0 @@
-import json
-import logging
-import random
-
-from playwright.sync_api import Browser, BrowserContext, Playwright
-
-try:
- from playwright_stealth import stealth_sync
-except ImportError:
- stealth_sync = None
-
-from ..core.config import Settings
-
-logger = logging.getLogger("MOBILEDE_scraper.browser")
-
-
-def _build_init_script() -> str:
- # Маскировка браузера.
- hardware_concurrency = random.choice([4, 8, 12, 16])
- device_memory = random.choice([4, 8, 16])
- languages = ["en-US", "en"]
-
- return f"""
-(() => {{
- const define = (obj, prop, value) => {{
- try {{
- Object.defineProperty(obj, prop, {{ get: () => value, configurable: true }});
- }} catch (e) {{}}
- }};
-
- define(navigator, 'webdriver', undefined);
- define(navigator, 'platform', 'Win32');
- define(navigator, 'vendor', 'Google Inc.');
- define(navigator, 'language', '{languages[0]}');
- define(navigator, 'languages', {json.dumps(languages)});
- define(navigator, 'hardwareConcurrency', {hardware_concurrency});
- define(navigator, 'deviceMemory', {device_memory});
- define(navigator, 'maxTouchPoints', 0);
-
- if (!window.chrome) {{
- Object.defineProperty(window, 'chrome', {{
- value: {{ runtime: {{}}, app: {{}}, csi: () => ({{}}), loadTimes: () => ({{}}) }},
- configurable: true
- }});
- }}
-
- const originalQuery = navigator.permissions && navigator.permissions.query;
- if (originalQuery) {{
- navigator.permissions.query = (params) => (
- params && params.name === 'notifications'
- ? Promise.resolve({{ state: Notification.permission }})
- : originalQuery(params)
- );
- }}
-
- const originalGetParameter = WebGLRenderingContext.prototype.getParameter;
- WebGLRenderingContext.prototype.getParameter = function(parameter) {{
- if (parameter === 37445) return 'Intel Inc.';
- if (parameter === 37446) return 'Intel Iris OpenGL Engine';
- return originalGetParameter.call(this, parameter);
- }};
-}})();
-"""
-
-
-class BrowserFactory:
-
- def __init__(self, settings: Settings) -> None:
- self.settings = settings
-
- def _resolve_engine(self) -> str:
- # Выбор движка.
- engine = self.settings.browser_engine.strip().lower()
- if engine == "auto":
- # Для MOBILEDE стабильнее Chromium.
- return "chromium"
- if engine in ("firefox", "chromium"):
- return engine
- logger.warning("Unknown MOBILEDE_BROWSER_ENGINE=%r, falling back to auto", engine)
- return "chromium"
-
- def create_browser(self, playwright: Playwright) -> Browser:
- engine = self._resolve_engine()
- proxy_dict = self.settings.proxy.to_playwright_dict()
- logger.info("Resolved browser engine: requested=%s resolved=%s", self.settings.browser_engine, engine)
-
- if engine == "firefox":
- launch_kwargs: dict = {"headless": self.settings.headless}
- if proxy_dict:
- launch_kwargs["proxy"] = proxy_dict
- logger.info("Using proxy: %s", self.settings.proxy.server)
- # Настройки Firefox.
- launch_kwargs["firefox_user_prefs"] = {
- "dom.webdriver.enabled": False,
- "useAutomationExtension": False,
- # Базовые оптимизации.
- "media.autoplay.default": 5,
- "media.volume_scale": "0.0",
- "media.audio.playback.standalone": False,
- "dom.ipc.processCount": 1,
- "dom.ipc.plugins.enabled": False,
- "browser.cache.disk.enable": False,
- "browser.cache.memory.enable": True,
- "browser.cache.memory.max_entry_size": 8192,
- "network.prefetch-next": False,
- "network.dns.disablePrefetch": True,
- "permissions.default.image": 2,
- "javascript.options.mem.gc_incremental_mark_slice_ms": 20,
- }
- logger.info("Launching Firefox (headless=%s)", self.settings.headless)
- return playwright.firefox.launch(**launch_kwargs)
-
- # Запуск Chromium.
- args = [
- "--disable-blink-features=AutomationControlled",
- "--no-default-browser-check",
- "--disable-dev-shm-usage",
- "--disable-features=IsolateOrigins,site-per-process",
- ]
- if self.settings.headless:
- args.append("--headless=new")
- pw_headless = False
- logger.info("Using Chromium new-headless mode (--headless=new)")
- else:
- pw_headless = False
-
- launch_kwargs = {"headless": pw_headless, "args": args}
- if proxy_dict:
- launch_kwargs["proxy"] = proxy_dict
- logger.info("Using proxy: %s", self.settings.proxy.server)
- try:
- logger.info("Trying to launch real Chrome channel")
- return playwright.chromium.launch(channel="chrome", **launch_kwargs)
- except Exception:
- logger.warning("Chrome channel launch failed, falling back to Chromium")
- return playwright.chromium.launch(**launch_kwargs)
-
- def create_context(self, browser: Browser) -> BrowserContext:
- viewport = random.choice(self.settings.fingerprint.viewport_presets)
- timezone_id = random.choice(self.settings.fingerprint.timezone_candidates)
- color_scheme = random.choice(["light", "dark"])
-
- is_firefox = browser.browser_type.name == "firefox"
-
- ctx_kwargs: dict = {
- "viewport": viewport,
- "screen": viewport,
- "locale": self.settings.fingerprint.locale,
- "timezone_id": timezone_id,
- "color_scheme": color_scheme,
- "java_script_enabled": True,
- "ignore_https_errors": False,
- }
-
- if is_firefox:
- # Заголовки Firefox.
- ctx_kwargs["user_agent"] = (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
- "Gecko/20100101 Firefox/128.0"
- )
- ctx_kwargs["extra_http_headers"] = {
- "Accept-Language": "en-US,en;q=0.5",
- "DNT": "1",
- "Upgrade-Insecure-Requests": "1",
- }
- else:
- ctx_kwargs["user_agent"] = self.settings.fingerprint.user_agent
- ctx_kwargs["device_scale_factor"] = random.choice([1, 1.25])
- ctx_kwargs["is_mobile"] = False
- ctx_kwargs["has_touch"] = False
- ctx_kwargs["extra_http_headers"] = {
- "Accept-Language": "en-US,en;q=0.9",
- "DNT": "1",
- "Upgrade-Insecure-Requests": "1",
- "Sec-CH-UA": self.settings.fingerprint.sec_ch_ua,
- "Sec-CH-UA-Mobile": "?0",
- "Sec-CH-UA-Platform": '"Windows"',
- }
-
- context = browser.new_context(**ctx_kwargs)
- context.set_default_timeout(self.settings.default_timeout_ms)
- context.set_default_navigation_timeout(self.settings.default_timeout_ms)
-
- if not is_firefox:
- # Маскировка Chromium.
- context.add_init_script(_build_init_script())
- if stealth_sync:
- context.on("page", lambda page: stealth_sync(page))
- logger.debug("playwright-stealth attached to context")
-
- return context
-
- @staticmethod
- def enable_resource_blocking(page) -> None:
- # Блокируем тяжёлые ресурсы.
- BLOCKED_TYPES = {"image", "stylesheet", "font", "media"}
- BLOCKED_URL_PATTERNS = (
- "google-analytics", "googletagmanager", "facebook.net",
- "doubleclick.net", "hotjar", "newrelic", ".woff", ".woff2",
- "analytics", "tracking", "adservice",
- )
-
- def _handle_route(route):
- req = route.request
- if req.resource_type in BLOCKED_TYPES:
- route.abort()
- return
- url = req.url.lower()
- if any(pat in url for pat in BLOCKED_URL_PATTERNS):
- route.abort()
- return
- route.continue_()
-
- page.route("**/*", _handle_route)
diff --git a/mobilede_scraper/browser/fast_client.py b/mobilede_scraper/browser/fast_client.py
deleted file mode 100644
index c43ab56..0000000
--- a/mobilede_scraper/browser/fast_client.py
+++ /dev/null
@@ -1,863 +0,0 @@
-from __future__ import annotations
-
-import html
-import json
-import logging
-import math
-import re
-import threading
-import time
-from dataclasses import dataclass
-from typing import Any, Iterator
-from urllib.parse import quote, urljoin
-
-import requests
-from requests.adapters import HTTPAdapter
-
-from ..core.config import Settings
-
-logger = logging.getLogger("MOBILEDE_scraper.fast_client")
-
-TRANSIENT_HTTP_CODES = {408, 425, 429, 500, 502, 503, 504}
-CHALLENGE_MARKERS = (
- "_incapsula_resource",
- "incapsula",
- "incident id",
- "request unsuccessful",
- "access denied",
-)
-COOKIE_ACCEPT_SELECTORS = (
- "button:has-text('Accept All')",
- "button:has-text('Accept all')",
- "button:has-text('I Agree')",
- "button:has-text('Agree')",
- "button:has-text('Only necessary')",
- "button:has-text('Только необходимые')",
- "button:has-text('Принять все')",
- "[id*='accept']",
- "[class*='accept']",
-)
-LISTING_MARKER = 'id="GBPSearchQuery"'
-DETAIL_MARKER = 'id="ProductDetailsVM"'
-RESIZER_URL = "https://vis.MOBILEDE.com/resizer"
-BRAND_SCOPE_OVERRIDES = {
- # MOBILEDE does not resolve every rare make through /Vehiclelisting/Cars/{make}.
- # CUPRA is available through a saved Search scope URL from the site UI.
- "CUPRA": "/Search?url=Ck7mLZr7Vc2sWBshBCBOx9WhRn%2fOPJoWOhUHRQ7JNhQ%3d",
-}
-DEFAULT_USER_AGENT = (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/124.0.0.0 Safari/537.36"
-)
-PLAYWRIGHT_REFRESH_POLLS = 8
-
-
-@dataclass(frozen=True)
-class FastListingVehicle:
- inventory_id: str
- tenant: str | None
- auction_id: str | None
- auction_date: str | None
- inventory_status: str | None
- currency: str | None
- timed_auction_closed: bool
- timed_auction_indicator: bool
- prebid_indicator: bool
- buynow_indicator: bool
-
-
-@dataclass(frozen=True)
-class FastListingPage:
- vehicles: list[FastListingVehicle]
- result_count: int
- page_size: int
- current_page: int
- gbp_search_query: dict[str, Any]
-
-
-class HybridSessionAuth:
- """Requests session with Playwright cookie refresh fallback.
-
- Fast path is direct HTTP. Playwright is used only to obtain/refresh anti-bot
- cookies when MOBILEDE returns a challenge or an expected hidden payload is absent.
- """
-
- def __init__(self, settings: Settings) -> None:
- self._settings = settings
- self._thread_local = threading.local()
- self._lock = threading.Lock()
- self._refresh_lock = threading.Lock()
- self._bootstrap_cookies_loaded = False
- self._anonymous_bootstrap_attempted = False
- self._refresh_generation = 0
- self._latest_refresh_cookies: list[dict[str, Any]] = []
-
- def request(
- self,
- method: str,
- url: str,
- *,
- timeout: int,
- retries: int,
- retry_backoff_ms: int,
- headers: dict[str, str] | None = None,
- data: Any | None = None,
- json_body: Any | None = None,
- expected_marker: str | None = None,
- ) -> requests.Response:
- session = self._get_session()
- self._ensure_anonymous_session_bootstrap(session=session)
- self._sync_session_with_latest_refresh(session)
- last_error: Exception | None = None
- refresh_attempts = 0
- attempt = 0
- max_refresh_attempts = max(0, int(self._settings.scraping_profile.challenge_refresh_attempts))
-
- while attempt <= retries:
- try:
- request_started_at = time.perf_counter()
- if self._settings.scraping_profile.verbose_http_logs:
- logger.debug(
- "HTTP request started method=%s url=%s attempt=%s/%s timeout=%s marker=%s",
- method,
- url,
- attempt + 1,
- retries + 1,
- timeout,
- expected_marker,
- )
- response = session.request(
- method=method,
- url=url,
- headers=headers,
- data=data,
- json=json_body,
- timeout=(min(10, max(1, timeout)), max(1, timeout)),
- )
- if self._settings.scraping_profile.verbose_http_logs or response.status_code >= 400:
- logger.debug(
- "HTTP request completed method=%s url=%s status=%s elapsed=%.1fs marker=%s",
- method,
- url,
- response.status_code,
- time.perf_counter() - request_started_at,
- expected_marker,
- )
- except requests.RequestException as exc:
- last_error = exc
- if attempt >= retries:
- break
- self._sleep_backoff(retry_backoff_ms, attempt)
- attempt += 1
- continue
-
- if response.status_code in TRANSIENT_HTTP_CODES and attempt < retries:
- response.close()
- self._sleep_backoff(retry_backoff_ms, attempt)
- attempt += 1
- continue
-
- if is_challenge_response(
- status_code=response.status_code,
- body_text=response.text,
- expected_marker=expected_marker,
- ):
- response.close()
- if not self._settings.scraping_profile.challenge_refresh_enabled or refresh_attempts >= max_refresh_attempts:
- raise RuntimeError(
- "MOBILEDE challenge persisted after "
- f"{refresh_attempts} Playwright refresh attempts for url={url}"
- )
- refresh_attempts += 1
- logger.info(
- "Challenge detected for url=%s status=%s marker=%s refresh_attempt=%s/%s",
- url,
- response.status_code,
- expected_marker,
- refresh_attempts,
- max_refresh_attempts,
- )
- self._refresh_session_via_playwright(expected_marker=LISTING_MARKER, session=session)
- logger.info("Retrying HTTP request after Playwright refresh url=%s", url)
- if refresh_attempts > 1:
- self._sleep_backoff(retry_backoff_ms, refresh_attempts - 1)
- continue
-
- return response
-
- if last_error is not None:
- raise RuntimeError(f"Request failed url={url}: {last_error}") from last_error
- raise RuntimeError(f"Request failed url={url} after retries")
-
- def persist_storage_state(self) -> None:
- session = self._get_session()
- with self._lock:
- self._save_storage_state(session)
-
- def _get_session(self) -> requests.Session:
- session = getattr(self._thread_local, "session", None)
- if session is None:
- session = requests.Session()
- pool_size = max(20, int(self._settings.fetch_concurrency) * 2)
- adapter = HTTPAdapter(pool_connections=pool_size, pool_maxsize=pool_size)
- session.mount("http://", adapter)
- session.mount("https://", adapter)
- session.headers.update(
- {
- "user-agent": DEFAULT_USER_AGENT,
- "accept-language": "en-US,en;q=0.9",
- "cache-control": "no-cache",
- "pragma": "no-cache",
- }
- )
- if self._settings.proxy.enabled:
- proxies = self._settings.proxy.to_requests_proxies()
- if proxies:
- session.proxies.update(proxies)
- with self._lock:
- self._bootstrap_session_cookies(session)
- self._thread_local.session = session
- self._thread_local.session_generation = 0
- self._sync_session_with_latest_refresh(session)
- return session
-
- def _sync_session_with_latest_refresh(self, session: requests.Session) -> None:
- with self._lock:
- latest_generation = self._refresh_generation
- session_generation = getattr(self._thread_local, "session_generation", 0)
- if latest_generation <= session_generation or not self._latest_refresh_cookies:
- return
- cookies = list(self._latest_refresh_cookies)
- self._apply_cookies_to_session(session, cookies)
- self._thread_local.session_generation = latest_generation
-
- def _ensure_anonymous_session_bootstrap(self, *, session: requests.Session) -> None:
- if self._anonymous_bootstrap_attempted or not self._settings.scraping_profile.anonymous_bootstrap_enabled:
- return
- if self._session_has_MOBILEDE_cookies(session):
- self._anonymous_bootstrap_attempted = True
- return
- with self._lock:
- if self._anonymous_bootstrap_attempted:
- return
- self._anonymous_bootstrap_attempted = True
- logger.info("No MOBILEDE cookies preloaded. Attempting anonymous session bootstrap via Playwright.")
- try:
- self._refresh_session_via_playwright(expected_marker=LISTING_MARKER, session=session)
- except Exception as exc:
- logger.warning("Anonymous session bootstrap via Playwright failed; continuing with direct HTTP flow: %s", exc)
-
- @staticmethod
- def _session_has_MOBILEDE_cookies(session: requests.Session) -> bool:
- for item in session.cookies:
- domain = str(getattr(item, "domain", "") or "")
- if not domain or "MOBILEDE.com" in domain.lower():
- return True
- return False
-
- def _bootstrap_session_cookies(self, session: requests.Session) -> None:
- if self._bootstrap_cookies_loaded:
- return
- self._load_storage_state_cookies(session)
- self._bootstrap_cookies_loaded = True
-
- def _load_storage_state_cookies(self, session: requests.Session) -> None:
- tokens_file = self._settings.tokens_file
- if not tokens_file:
- return
- path = __import__("pathlib").Path(tokens_file)
- if not path.exists():
- return
- try:
- payload = json.loads(path.read_text(encoding="utf-8"))
- except Exception as exc:
- logger.warning("Failed to read storage state file '%s': %s", path, exc)
- return
- cookies = payload.get("cookies") if isinstance(payload, dict) else None
- if not isinstance(cookies, list):
- return
- applied = 0
- for item in cookies:
- if not isinstance(item, dict):
- continue
- name = parse_text(item.get("name"))
- value = parse_text(item.get("value"))
- if not name or value is None:
- continue
- domain = parse_text(item.get("domain")) or ".MOBILEDE.com"
- cookie_path = parse_text(item.get("path")) or "/"
- expires = parse_int(item.get("expires"))
- session.cookies.set(name, value, domain=domain, path=cookie_path, expires=expires)
- applied += 1
- if applied:
- logger.info("Loaded %s cookies from storage state", applied)
-
- def _refresh_session_via_playwright(
- self,
- *,
- expected_marker: str | None = None,
- session: requests.Session | None = None,
- ) -> None:
- target_session = session or self._get_session()
- with self._lock:
- baseline_generation = self._refresh_generation
-
- with self._refresh_lock:
- with self._lock:
- if self._refresh_generation > baseline_generation and self._latest_refresh_cookies:
- self._apply_cookies_to_session(target_session, self._latest_refresh_cookies)
- self._thread_local.session_generation = self._refresh_generation
- return
-
- logger.info("MOBILEDE session challenge detected. Refreshing session via Playwright.")
- cookies = self._fetch_cookies_via_playwright(expected_marker=expected_marker)
- logger.info("Playwright refresh returned %d cookies", len(cookies))
- self._apply_cookies_to_session(target_session, cookies)
- logger.info("Playwright cookies applied to requests session")
-
- with self._lock:
- self._refresh_generation += 1
- self._latest_refresh_cookies = list(cookies)
- self._anonymous_bootstrap_attempted = True
- refreshed_generation = self._refresh_generation
- self._thread_local.session_generation = refreshed_generation
- logger.info("Playwright refresh completed generation=%s", refreshed_generation)
-
- def _fetch_cookies_via_playwright(self, *, expected_marker: str | None = None) -> list[dict[str, Any]]:
- from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
- from playwright.sync_api import sync_playwright
-
- with sync_playwright() as playwright:
- browser = playwright.chromium.launch(headless=self._settings.headless)
- try:
- context = browser.new_context(
- locale=self._settings.fingerprint.locale,
- viewport={"width": 1366, "height": 768},
- user_agent=DEFAULT_USER_AGENT,
- proxy=self._settings.proxy.to_playwright_dict(),
- )
- page = context.new_page()
- home_target = self._settings.home_url
- filtered_urls = self._settings.listing.filtered_search_urls
- target = filtered_urls[0] if filtered_urls else urljoin(self._settings.home_url, "Vehiclelisting/Cars")
- timeout_ms = max(30_000, self._settings.default_timeout_ms)
- logger.info("Playwright session refresh opening target=%s marker=%s", target, expected_marker or LISTING_MARKER)
- page.goto(home_target, wait_until="domcontentloaded", timeout=timeout_ms)
- self._accept_cookie_banner(page)
- page.goto(target, wait_until="domcontentloaded", timeout=timeout_ms)
- self._accept_cookie_banner(page)
- self._wait_until_non_challenge(
- page=page,
- target=target,
- timeout_ms=timeout_ms,
- expected_marker=expected_marker or LISTING_MARKER,
- )
- cookies = context.cookies()
- logger.info("Playwright context returned %d cookies", len(cookies))
- except PlaywrightTimeoutError as exc:
- raise RuntimeError(f"Playwright refresh timed out: {exc}") from exc
- finally:
- try:
- browser.close()
- except Exception as exc:
- logger.info("Playwright browser close failed after cookie refresh: %s", exc)
-
- if not isinstance(cookies, list) or not cookies:
- raise RuntimeError("Playwright refresh did not return cookies")
- return [cookie for cookie in cookies if isinstance(cookie, dict)]
-
- @staticmethod
- def _apply_cookies_to_session(session: requests.Session, cookies: list[dict[str, Any]]) -> None:
- session.cookies.clear()
- for cookie in cookies:
- name = parse_text(cookie.get("name"))
- value = parse_text(cookie.get("value"))
- if not name or value is None:
- continue
- domain = parse_text(cookie.get("domain")) or ".MOBILEDE.com"
- cookie_path = parse_text(cookie.get("path")) or "/"
- expires = parse_int(cookie.get("expires"))
- session.cookies.set(name, value, domain=domain, path=cookie_path, expires=expires)
-
- @staticmethod
- def _wait_until_non_challenge(*, page: Any, target: str, timeout_ms: int, expected_marker: str | None) -> None:
- poll_ms = max(1000, min(5000, timeout_ms // PLAYWRIGHT_REFRESH_POLLS))
- navigation_error_count = 0
- for poll_index in range(PLAYWRIGHT_REFRESH_POLLS):
- try:
- page.wait_for_load_state("domcontentloaded", timeout=poll_ms)
- except Exception:
- pass
- page.wait_for_timeout(poll_ms)
- body: str | None = None
- for _ in range(3):
- try:
- body = page.content()
- break
- except Exception as exc:
- message = str(exc).lower()
- if "page.content" not in message or "navigating and changing the content" not in message:
- raise
- navigation_error_count += 1
- page.wait_for_timeout(max(200, poll_ms // 4))
- if body is not None and not is_challenge_response(
- status_code=200,
- body_text=body,
- expected_marker=expected_marker,
- ):
- logger.info(
- "Playwright session refresh passed challenge target=%s poll=%s/%s marker=%s",
- target,
- poll_index + 1,
- PLAYWRIGHT_REFRESH_POLLS,
- expected_marker,
- )
- return
- try:
- logger.info(
- "Playwright session refresh still waiting target=%s poll=%s/%s marker=%s",
- target,
- poll_index + 1,
- PLAYWRIGHT_REFRESH_POLLS,
- expected_marker,
- )
- page.goto(target, wait_until="domcontentloaded", timeout=timeout_ms)
- except Exception:
- pass
- raise RuntimeError(
- "Playwright refresh completed but challenge page is still active "
- f"(navigation_content_errors={navigation_error_count})"
- )
-
- @staticmethod
- def _accept_cookie_banner(page: Any) -> None:
- for selector in COOKIE_ACCEPT_SELECTORS:
- try:
- locator = page.locator(selector).first
- if locator.count() == 0 or not locator.is_visible(timeout=500):
- continue
- locator.click(timeout=2_000)
- page.wait_for_timeout(250)
- return
- except Exception:
- continue
-
- def _save_storage_state(self, session: requests.Session) -> None:
- tokens_file = self._settings.tokens_file
- if not tokens_file:
- return
- path = __import__("pathlib").Path(tokens_file)
- cookies: list[dict[str, Any]] = []
- for cookie in session.cookies:
- payload: dict[str, Any] = {
- "name": cookie.name,
- "value": cookie.value,
- "domain": cookie.domain or ".MOBILEDE.com",
- "path": cookie.path or "/",
- "httpOnly": False,
- "secure": bool(cookie.secure),
- "sameSite": "Lax",
- }
- if cookie.expires is not None:
- payload["expires"] = int(cookie.expires)
- cookies.append(payload)
- try:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps({"cookies": cookies, "origins": []}, ensure_ascii=False, indent=2), encoding="utf-8")
- except PermissionError as exc:
- logger.info("Cannot persist MOBILEDE storage state to '%s': %s", path, exc)
- except OSError as exc:
- logger.info("Failed to persist MOBILEDE storage state to '%s': %s", path, exc)
-
- @staticmethod
- def _sleep_backoff(retry_backoff_ms: int, attempt: int) -> None:
- if retry_backoff_ms <= 0:
- return
- time.sleep(retry_backoff_ms * (2**attempt) / 1000)
-
-
-class MobiledeFastClient:
- def __init__(self, settings: Settings) -> None:
- self._settings = settings
- self._auth = HybridSessionAuth(settings)
-
- def persist_session_state(self) -> None:
- self._auth.persist_storage_state()
-
- def iter_listing_vehicles(
- self,
- *,
- listing_start_url: str | None = None,
- make: str | None = None,
- max_pages: int | None = None,
- ) -> Iterator[FastListingVehicle]:
- seen_inventory_ids: set[str] = set()
- scope_paths = resolve_listing_scope_paths(
- listing_start_url=listing_start_url or "",
- brands={make} if make else set(),
- )
- for scope_path in scope_paths:
- first_page_html = self._fetch_listing_first_page(scope_path)
- search_scope_path = build_search_scope_path_from_html(first_page_html)
- if search_scope_path and search_scope_path != scope_path:
- logger.info(
- "Resolved listing scope to fast Search URL: scope=%s search_scope=%s",
- scope_path,
- search_scope_path,
- )
- scope_path = search_scope_path
- first_page = parse_listing_page(first_page_html)
- for vehicle in first_page.vehicles:
- if vehicle.inventory_id in seen_inventory_ids:
- continue
- seen_inventory_ids.add(vehicle.inventory_id)
- yield vehicle
-
- page_size = max(1, first_page.page_size)
- total_pages = max(1, math.ceil(max(first_page.result_count, len(first_page.vehicles)) / page_size))
- if max_pages is not None and max_pages > 0:
- total_pages = min(total_pages, max_pages)
- gbp_search_query = first_page.gbp_search_query
- for page_number in range(2, total_pages + 1):
- page_html = self._fetch_listing_page(scope_path, gbp_search_query, page_number, page_size)
- parsed_page = parse_listing_page(page_html)
- gbp_search_query = parsed_page.gbp_search_query
- for vehicle in parsed_page.vehicles:
- if vehicle.inventory_id in seen_inventory_ids:
- continue
- seen_inventory_ids.add(vehicle.inventory_id)
- yield vehicle
-
- def fetch_vehicle_detail_payload(self, inventory_id: str) -> dict[str, Any]:
- escaped_id = quote(inventory_id, safe="~")
- url = urljoin(self._settings.home_url, f"VehicleDetail/{escaped_id}")
- response = self._auth.request(
- "GET",
- url,
- timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
- retries=max(0, self._settings.scraping_profile.detail_retries if self._settings.scraping_profile.detail_retries is not None else self._settings.max_retries),
- retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
- headers={"accept": "text/html,application/xhtml+xml"},
- expected_marker=DETAIL_MARKER,
- )
- with response:
- if response.status_code >= 400:
- raise RuntimeError(f"Vehicle detail request failed id={inventory_id} status={response.status_code}")
- return parse_product_details_vm(response.text)
-
- def fetch_vehicle_detail_html(self, inventory_id: str) -> str:
- escaped_id = quote(inventory_id, safe="~")
- url = urljoin(self._settings.home_url, f"VehicleDetail/{escaped_id}")
- response = self._auth.request(
- "GET",
- url,
- timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
- retries=max(0, self._settings.scraping_profile.detail_retries if self._settings.scraping_profile.detail_retries is not None else self._settings.max_retries),
- retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
- headers={"accept": "text/html,application/xhtml+xml"},
- expected_marker=DETAIL_MARKER,
- )
- with response:
- if response.status_code >= 400:
- raise RuntimeError(f"Vehicle detail request failed id={inventory_id} status={response.status_code}")
- return response.text
-
- def _fetch_listing_first_page(self, scope_path: str) -> str:
- url = scope_path if scope_path.lower().startswith(("http://", "https://")) else urljoin(self._settings.home_url, scope_path.lstrip("/"))
- response = self._auth.request(
- "GET",
- url,
- timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
- retries=max(0, self._settings.scraping_profile.listing_retries if self._settings.scraping_profile.listing_retries is not None else self._settings.max_retries),
- retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
- headers={"accept": "text/html,application/xhtml+xml"},
- expected_marker=LISTING_MARKER,
- )
- with response:
- if response.status_code >= 400:
- raise RuntimeError(f"Listing request failed path={scope_path} status={response.status_code}")
- return response.text
-
- def _fetch_listing_page(self, scope_path: str, gbp_search_query: dict[str, Any], page_number: int, page_size: int) -> str:
- query_payload = dict(gbp_search_query)
- query_payload["CurrentPage"] = page_number
- query_payload["PageSize"] = page_size
- search_url = urljoin(self._settings.home_url, "Search")
- common_headers = {
- "accept": "text/html,application/xhtml+xml,*/*",
- "x-requested-with": "XMLHttpRequest",
- }
- attempts: list[tuple[dict[str, str], Any, Any]] = [
- ({**common_headers, "content-type": "application/json"}, None, query_payload),
- ({**common_headers, "content-type": "application/json"}, None, {"GBPSearchQuery": query_payload}),
- ({**common_headers}, {"GBPSearchQuery": json.dumps(query_payload, separators=(",", ":"))}, None),
- ({**common_headers, "content-type": "application/json"}, json.dumps({"GBPSearchQuery": json.dumps(query_payload, separators=(",", ":"))}), None),
- ]
- attempts = attempts[:max(1, min(len(attempts), int(self._settings.scraping_profile.listing_post_attempts)))]
- last_error: Exception | None = None
- for headers, data, json_body in attempts:
- try:
- response = self._auth.request(
- "POST",
- search_url,
- timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
- retries=max(0, self._settings.scraping_profile.listing_retries if self._settings.scraping_profile.listing_retries is not None else self._settings.max_retries),
- retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
- headers=headers,
- data=data,
- json_body=json_body,
- expected_marker=LISTING_MARKER,
- )
- with response:
- if response.status_code >= 400:
- raise RuntimeError(f"Listing page request failed status={response.status_code} page={page_number}")
- body = response.text
- if LISTING_MARKER not in body:
- raise RuntimeError("Listing page response does not include GBPSearchQuery")
- return body
- except Exception as exc:
- last_error = exc
- continue
- if last_error is not None:
- raise RuntimeError(f"Failed to load listing page={page_number} for {scope_path}: {last_error}") from last_error
- raise RuntimeError(f"Failed to load listing page={page_number} for {scope_path}")
-
-
-def build_brand_scope_paths(brands: set[str]) -> list[str]:
- if not brands:
- return ["/Vehiclelisting/Cars"]
- paths: list[str] = []
- for brand in sorted(brands):
- raw = brand.strip()
- if not raw:
- continue
- override = BRAND_SCOPE_OVERRIDES.get(raw.upper())
- if override:
- if override not in paths:
- paths.append(override)
- continue
- slug_hyphen = quote(raw.replace(" ", "-"), safe="-")
- slug_raw = quote(raw, safe="")
- for slug in (slug_hyphen, slug_raw):
- path = f"/Vehiclelisting/Cars/{slug}"
- if path not in paths:
- paths.append(path)
- return paths or ["/Vehiclelisting/Cars"]
-
-
-def resolve_listing_scope_paths(*, listing_start_url: str, brands: set[str]) -> list[str]:
- explicit_scope = listing_start_url.strip()
- if explicit_scope:
- if explicit_scope.lower().startswith(("http://", "https://", "/")):
- return [explicit_scope]
- return [f"/Search?url={explicit_scope}"]
- return build_brand_scope_paths(brands)
-
-
-def build_search_scope_path_from_html(html_text: str) -> str | None:
- tiny_url = parse_attribute_value(html_text, "data-tinyurl")
- if tiny_url:
- return f"/Search?url={tiny_url}"
-
- data_query_raw = parse_attribute_value(html_text, "data-query")
- if data_query_raw:
- try:
- data_query = json.loads(data_query_raw)
- except (json.JSONDecodeError, ValueError, TypeError):
- data_query = None
- if isinstance(data_query, dict):
- url_value = parse_text(data_query.get("Url"))
- if url_value:
- return f"/Search?url={url_value}"
- return None
-
-
-def parse_listing_page(html_text: str) -> FastListingPage:
- gbp_raw = parse_hidden_input_value(html_text, "GBPSearchQuery")
- vehicle_raw = parse_hidden_input_value(html_text, "VehicleDetails")
- result_count_raw = parse_hidden_input_value(html_text, "ResultCount")
- page_size_raw = parse_hidden_input_value(html_text, "PageSize")
- current_page_raw = parse_hidden_input_value(html_text, "CurrentPage")
- if not gbp_raw:
- raise RuntimeError("Listing page missing GBPSearchQuery")
- if vehicle_raw is None:
- raise RuntimeError("Listing page missing VehicleDetails")
- gbp_payload = json.loads(gbp_raw)
- if not isinstance(gbp_payload, dict):
- raise RuntimeError("GBPSearchQuery payload is not object")
- vehicle_payload = json.loads(vehicle_raw)
- if not isinstance(vehicle_payload, list):
- raise RuntimeError("VehicleDetails payload is not array")
-
- vehicles: list[FastListingVehicle] = []
- for item in vehicle_payload:
- if not isinstance(item, dict):
- continue
- inventory_id = parse_text(item.get("Id"))
- if not inventory_id:
- continue
- vehicles.append(
- FastListingVehicle(
- inventory_id=inventory_id,
- tenant=parse_text(item.get("Tenant")),
- auction_id=parse_text(item.get("ActnLnId")),
- auction_date=parse_text(item.get("AuctionDate")) or parse_text(item.get("ActnDtTm")),
- inventory_status=parse_text(item.get("InventoryStatus")),
- currency=parse_text(item.get("Currency")),
- timed_auction_closed=parse_bool(item.get("TimedAuctionClosedIndicator")),
- timed_auction_indicator=parse_bool(item.get("TimedAuctionIndicator")),
- prebid_indicator=parse_bool(item.get("PreBidIndicator")),
- buynow_indicator=parse_bool(item.get("BuyNowIndicator")),
- )
- )
- return FastListingPage(
- vehicles=vehicles,
- result_count=parse_int(result_count_raw) or len(vehicles),
- page_size=parse_int(page_size_raw) or max(1, len(vehicles)),
- current_page=parse_int(current_page_raw) or 1,
- gbp_search_query=gbp_payload,
- )
-
-
-def parse_product_details_vm(html_text: str) -> dict[str, Any]:
- match = re.search(
- r"",
- html_text,
- flags=re.DOTALL | re.IGNORECASE,
- )
- if match is None:
- raise RuntimeError("ProductDetailsVM script not found")
- payload = json.loads(match.group(1))
- if not isinstance(payload, dict):
- raise RuntimeError("ProductDetailsVM root is not object")
- return payload
-
-
-def parse_hidden_input_value(html_text: str, input_id: str) -> str | None:
- escaped_id = re.escape(input_id)
- patterns = (
- rf"]*\bid=\"{escaped_id}\"[^>]*\bvalue=\"([^\"]*)\"",
- rf"]*\bid='{escaped_id}'[^>]*\bvalue='([^']*)'",
- )
- for pattern in patterns:
- match = re.search(pattern, html_text, flags=re.IGNORECASE)
- if match is not None:
- return html.unescape(match.group(1))
- return None
-
-
-def parse_attribute_value(html_text: str, attribute_name: str) -> str | None:
- escaped_name = re.escape(attribute_name)
- patterns = (
- rf"\b{escaped_name}=\"([^\"]*)\"",
- rf"\b{escaped_name}='([^']*)'",
- )
- for pattern in patterns:
- match = re.search(pattern, html_text, flags=re.IGNORECASE)
- if match is not None:
- value = html.unescape(match.group(1)).strip()
- return value or None
- return None
-
-
-def build_resizer_images_from_keys(image_keys: list[dict[str, Any]]) -> list[dict[str, str | int]]:
- seen_fullres: set[str] = set()
- images: list[dict[str, str | int]] = []
- for index, item in enumerate(image_keys):
- if not isinstance(item, dict):
- continue
- key = parse_text(item.get("k"))
- if key is None:
- continue
- width = parse_int(item.get("w")) or 1600
- height = parse_int(item.get("h")) or 1200
- if width <= 0:
- width = 1600
- if height <= 0:
- height = 1200
- order_index = parse_int(item.get("i"))
- if order_index is None:
- order_index = parse_int(item.get("in"))
- if order_index is None:
- order_index = index
- preview_width = min(640, width)
- preview_height = max(1, int(round(height * (preview_width / width))))
- escaped_key = quote(key, safe="~")
- fullres = f"{RESIZER_URL}?imageKeys={escaped_key}&width={width}&height={height}"
- preview = f"{RESIZER_URL}?imageKeys={escaped_key}&width={preview_width}&height={preview_height}"
- if fullres in seen_fullres:
- continue
- seen_fullres.add(fullres)
- images.append({"order_index": order_index, "fullres_image": fullres, "preview_image": preview})
- images.sort(key=lambda row: (parse_int(row.get("order_index")) or 0, str(row.get("fullres_image"))))
- return images
-
-
-def is_challenge_response(*, status_code: int, body_text: str, expected_marker: str | None = None) -> bool:
- if status_code in {401, 403}:
- return True
- if _expected_marker_present(body_text=body_text, expected_marker=expected_marker):
- return False
- lowered = (body_text or "").lower()
- if any(marker in lowered for marker in CHALLENGE_MARKERS):
- return True
- if expected_marker and not _expected_marker_present(body_text=body_text, expected_marker=expected_marker):
- if " bool:
- if not expected_marker:
- return False
- if expected_marker in body_text:
- return True
- if '"' in expected_marker and expected_marker.replace('"', "'") in body_text:
- return True
- if "'" in expected_marker and expected_marker.replace("'", '"') in body_text:
- return True
- marker_match = re.search(r"id=['\"]([^'\"]+)['\"]", expected_marker)
- if marker_match is None:
- return False
- marker_id = re.escape(marker_match.group(1))
- return bool(re.search(rf"id\s*=\s*['\"]{marker_id}['\"]", body_text, flags=re.IGNORECASE))
-
-
-def parse_text(value: Any) -> str | None:
- if isinstance(value, str):
- text = value.strip()
- return text if text else None
- return None
-
-
-def parse_bool(value: Any) -> bool:
- if isinstance(value, bool):
- return value
- if isinstance(value, str):
- return value.strip().lower() in {"true", "1", "yes", "on"}
- if isinstance(value, (int, float)) and not isinstance(value, bool):
- return value != 0
- return False
-
-
-def parse_int(value: Any) -> int | None:
- if value is None or isinstance(value, bool):
- return None
- if isinstance(value, int):
- return value
- if isinstance(value, float):
- return int(round(value))
- if isinstance(value, str):
- text = value.strip()
- if not text:
- return None
- normalized = text.replace(",", "").replace(" ", "").replace("$", "")
- match = re.search(r"-?\d+(?:\.\d+)?", normalized)
- if match is None:
- return None
- try:
- return int(round(float(match.group(0))))
- except ValueError:
- return None
- return None
diff --git a/mobilede_scraper/browser/listing.py b/mobilede_scraper/browser/listing.py
deleted file mode 100644
index 06961ca..0000000
--- a/mobilede_scraper/browser/listing.py
+++ /dev/null
@@ -1,714 +0,0 @@
-import logging
-import re
-import time
-from dataclasses import asdict, dataclass, field
-from typing import Any
-from urllib.parse import urljoin
-
-from playwright.sync_api import Page
-
-from .pace import HumanPacer
-from ..core.config import Settings
-from ..core.utils import first_non_empty
-
-logger = logging.getLogger("MOBILEDE_scraper.listing")
-VEHICLE_HREF_RE = re.compile(r"/VehicleDetail/(\d+)(?:~[A-Z]{2})?", re.IGNORECASE)
-VEHICLE_LINK_SELECTOR = "a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']"
-COOKIE_ACCEPT_SELECTORS: tuple[str, ...] = (
- "button:has-text('Accept All')",
- "button:has-text('Accept all')",
- "button:has-text('I Agree')",
- "button:has-text('Agree')",
- "button:has-text('Only necessary')",
- "button:has-text('Только необходимые')",
- "button:has-text('Принять все')",
- "[id*='accept']",
- "[class*='accept']",
-)
-
-
-@dataclass(slots=True)
-class ListingVehicleLink:
- href: str
- title: str = ""
- lot_number: str | None = None
-
-
-@dataclass(slots=True)
-class ListingPageResult:
- source_url: str
- page_number: int
- vehicle_links: list[ListingVehicleLink] = field(default_factory=list)
- pagination_available: bool = False
- next_page_detected: bool = False
-
-
-class ListingCollector:
- _NEXT_PAGE_SELECTORS: tuple[str, ...] = (
- "a[aria-label*='Next']",
- "button[aria-label*='Next']",
- "a[aria-label*='next']",
- "button[aria-label*='next']",
- "a[title*='Next']",
- "button[title*='Next']",
- "a[title*='next']",
- "button[title*='next']",
- "a[rel='next']",
- "link[rel='next']",
- "a.pagination-next",
- "button.pagination-next",
- "a.next",
- "button.next",
- "a:has-text('Next')",
- "button:has-text('Next')",
- "a:has-text('NEXT')",
- "button:has-text('NEXT')",
- "a:has-text('›')",
- "button:has-text('›')",
- "a:has-text('»')",
- "button:has-text('»')",
- "a:has(img[src*='icon-arrow-right'])",
- "button:has(img[src*='icon-arrow-right'])",
- "a:has(img[src*='arrow-right'])",
- "button:has(img[src*='arrow-right'])",
- )
-
- def __init__(self, settings: Settings, pacer: HumanPacer) -> None:
- self.settings = settings
- 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
-
- @staticmethod
- def has_page_number(page: Page, target_page_number: int) -> bool:
- try:
- return bool(page.evaluate(
- """
- (targetPageNumber) => {
- const visible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
- const controls = Array.from(document.querySelectorAll('a,button,[role="button"],span,div'));
- return controls.some((el) => {
- const text = (el.textContent || '').trim();
- const cls = (el.getAttribute('class') || '').toLowerCase();
- const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase();
- const disabled = el.hasAttribute('disabled') || ariaDisabled === 'true' || cls.includes('disabled');
- return visible(el) && !disabled && /^\d+$/.test(text) && parseInt(text, 10) === targetPageNumber;
- });
- }
- """,
- target_page_number,
- ))
- except Exception:
- return False
-
- def open_cars_listing(self, page: Page, *, url_override: str | None = None) -> None:
- url = url_override or self.settings.listing.cars_url
- logger.info("Opening cars listing page: %s", url)
- last_err = None
- for attempt in range(3):
- try:
- page.goto(url, wait_until="commit", timeout=60_000)
- last_err = None
- break
- except Exception as e:
- last_err = e
- logger.warning("goto listing attempt %d failed: %s", attempt + 1, e)
- # Небольшой backoff при ошибках открытия листинга.
- time.sleep(5 * (attempt + 1))
- if last_err:
- logger.warning("All goto attempts failed, trying JS navigation")
- try:
- page.evaluate(f"window.location.href = '{url}'")
- except Exception:
- pass
- # Быстрая проверка готовности страницы.
- try:
- page.wait_for_load_state("domcontentloaded", timeout=12_000)
- except Exception:
- pass
- self._accept_cookie_banner(page)
- self._wait_for_listing_content(page)
- logger.info("Listing page URL: %s", page.url)
- self.pacer.after_listing_open()
-
- def _accept_cookie_banner(self, page: Page) -> None:
- for selector in COOKIE_ACCEPT_SELECTORS:
- locator = page.locator(selector).first
- try:
- if locator.count() == 0:
- continue
- if not locator.is_visible(timeout=500):
- continue
- locator.click(timeout=2_000)
- logger.info("Accepted cookie banner using selector: %s", selector)
- try:
- page.wait_for_load_state("domcontentloaded", timeout=3_000)
- except Exception:
- pass
- return
- except Exception:
- continue
-
- def _wait_for_listing_content(self, page: Page) -> None:
- try:
- page.wait_for_selector(VEHICLE_LINK_SELECTOR, timeout=12_000)
- return
- except Exception:
- pass
-
- # Fallback: React/SSR разметка может появиться не сразу, даже если ещё нет в DOM.
- try:
- page.wait_for_function(
- """() => {
- const html = document.documentElement?.innerHTML || '';
- const text = document.body?.innerText || '';
- return html.includes('/VehicleDetail/') || /\\b\d+\s+VEHICLES\b/i.test(text);
- }""",
- timeout=12_000,
- )
- except Exception:
- # Короткая пауза вместо длинного sleep.
- time.sleep(1.0)
-
- def apply_filters(
- self,
- page: Page,
- make: str | None = None,
- model: str | None = None,
- year_min: int | None = None,
- year_max: int | None = None,
- ) -> dict[str, str | int | None]:
- applied: dict[str, str | int | None] = {"make": None, "model": None, "year_min": None, "year_max": None}
- if make and self._try_fill_filter_input(page, ["input[placeholder*='Make']", "input[aria-label*='Make']"], make):
- applied["make"] = make
- self.pacer.after_filter_action()
- if model and self._try_fill_filter_input(page, ["input[placeholder*='Model']", "input[aria-label*='Model']"], model):
- applied["model"] = model
- self.pacer.after_filter_action()
- if year_min is not None or year_max is not None:
- if self._apply_year_range(page, year_min, year_max):
- applied["year_min"] = year_min
- applied["year_max"] = year_max
- self.pacer.after_filter_action()
- return applied
-
- def _apply_year_range(self, page: Page, year_min: int | None, year_max: int | None) -> bool:
- """Заполняет поля фильтра Year и нажимает Apply Year."""
- if year_min is None and year_max is None:
- return False
- try:
- success = page.evaluate(
- """([yearMin, yearMax]) => {
- const inputs = Array.from(document.querySelectorAll('input'));
- const yearInputs = inputs.filter(inp => {
- const v = parseInt(inp.value, 10);
- return !isNaN(v) && v >= 1900 && v <= 2100;
- });
- if (yearInputs.length < 2) return false;
- yearInputs.sort((a, b) => parseInt(a.value) - parseInt(b.value));
- const setVal = (el, val) => {
- const setter = Object.getOwnPropertyDescriptor(
- HTMLInputElement.prototype, 'value'
- ).set;
- setter.call(el, String(val));
- el.dispatchEvent(new Event('input', {bubbles: true}));
- el.dispatchEvent(new Event('change', {bubbles: true}));
- };
- if (yearMin !== null) setVal(yearInputs[0], yearMin);
- if (yearMax !== null) setVal(yearInputs[yearInputs.length - 1], yearMax);
- const container = yearInputs[0].closest(
- '[class*="filter"], [class*="year"], section, fieldset'
- ) || yearInputs[0].parentElement.parentElement;
- if (container) {
- const btn = Array.from(container.querySelectorAll(
- 'button, a, [role="button"], span[class*="apply"]'
- )).find(el => /apply|\u043f\u0440\u0438\u043c\u0435\u043d/i.test(el.textContent));
- if (btn) { btn.click(); return true; }
- }
- yearInputs[yearInputs.length - 1].dispatchEvent(
- new KeyboardEvent('keydown', {
- key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
- })
- );
- return true;
- }""",
- [year_min, year_max],
- )
- if success:
- try:
- page.wait_for_load_state("domcontentloaded", timeout=15_000)
- except Exception:
- pass
- self._wait_for_listing_content(page)
- logger.info("Applied year range filter: %s — %s", year_min, year_max)
- return True
- except Exception as exc:
- logger.warning("Failed to apply year range filter: %s", exc)
- return False
-
- def collect_current_page(self, page: Page, page_number: int = 1) -> ListingPageResult:
- # Считываем ссылки одним проходом по DOM.
- self._accept_cookie_banner(page)
- self._wait_for_listing_content(page)
- try:
- raw_items = page.eval_on_selector_all(
- "a[href], [data-href], [href]",
- """
- (nodes) => nodes.map((node) => ({
- href:
- node.getAttribute('href') ||
- node.getAttribute('data-href') ||
- node.getAttribute('data-url') ||
- '',
- title: node.getAttribute('title') || node.getAttribute('aria-label') || '',
- text: (node.textContent || '').trim(),
- }))
- """,
- )
- except Exception as exc:
- logger.warning("collect_current_page failed on page %d: %s", page_number, exc)
- raw_items = []
- total = min(len(raw_items), self.settings.listing.page_link_limit)
- links: list[ListingVehicleLink] = []
- seen: set[str] = set()
- for idx in range(total):
- item = raw_items[idx] if isinstance(raw_items[idx], dict) else {}
- href = str(item.get("href") or "")
- match = VEHICLE_HREF_RE.search(href)
- if not match:
- continue
- lot_number = match.group(1)
- absolute = urljoin(self.settings.home_url, match.group(0))
- if absolute in seen:
- continue
- seen.add(absolute)
- title = first_non_empty([item.get("title"), item.get("text"), ""]) or ""
- links.append(ListingVehicleLink(href=absolute, title=str(title).strip(), lot_number=lot_number))
- if len(links) >= self.settings.listing.max_vehicles_per_run:
- break
-
- # Fallback: на MOBILEDE ссылки иногда не рендерятся как ,
- # но присутствуют в hydration/inline JSON внутри HTML (часто как \/VehicleDetail\/").
- if not links:
- try:
- page.wait_for_timeout(1_500)
- except Exception:
- pass
- try:
- html = page.content()
- except Exception as exc:
- logger.debug("page.content() failed on page %d: %s", page_number, exc)
- html = ""
-
- for absolute, lot_number in self._extract_vehicle_links_from_html(html):
- if absolute in seen:
- continue
- seen.add(absolute)
- links.append(ListingVehicleLink(href=absolute, title="", lot_number=lot_number))
- if len(links) >= self.settings.listing.max_vehicles_per_run:
- break
-
- if links:
- logger.info(
- "Page %d: recovered %d vehicle links from HTML fallback",
- page_number,
- len(links),
- )
- next_page_detected = self._has_next_page(page)
- return ListingPageResult(source_url=page.url, page_number=page_number, vehicle_links=links, pagination_available=next_page_detected, next_page_detected=next_page_detected)
-
- def _extract_vehicle_links_from_html(self, html: str) -> list[tuple[str, str]]:
- if not html:
- return []
-
- # Частый формат в JSON внутри HTML: "\/VehicleDetail\/12345678~US"
- normalized = html.replace("\\/", "/")
- found: list[tuple[str, str]] = []
- seen: set[str] = set()
-
- for match in VEHICLE_HREF_RE.finditer(normalized):
- lot_number = match.group(1)
- absolute = urljoin(self.settings.home_url, match.group(0))
- if absolute in seen:
- continue
- seen.add(absolute)
- found.append((absolute, lot_number))
- if len(found) >= self.settings.listing.page_link_limit:
- break
-
- return found
-
- def go_to_next_page(self, page: Page, expected_page_number: int | None = None) -> bool:
- # Запоминаем первую ссылку текущей страницы для определения смены контента.
- old_first_href = ""
- try:
- first_link = page.locator(VEHICLE_LINK_SELECTOR).first
- if first_link.count() > 0:
- old_first_href = first_link.get_attribute("href") or ""
- except Exception:
- pass
-
- for selector in self._NEXT_PAGE_SELECTORS:
- locator = page.locator(selector).first
- if locator.count() == 0:
- continue
- try:
- disabled = (locator.get_attribute("disabled", timeout=1500) or "").lower()
- aria_disabled = (locator.get_attribute("aria-disabled", timeout=1500) or "").lower()
- classes = (locator.get_attribute("class", timeout=1500) or "").lower()
- except Exception:
- continue
- if disabled or aria_disabled == "true" or "disabled" in classes:
- continue
- try:
- self.pacer.move_mouse_to(page, locator)
- locator.click(timeout=8000)
- except Exception:
- continue
-
- if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
- self.pacer.after_page_change()
- return True
-
- # Fallback для mobilede: пагинация часто рендерится как набор номеров страниц
- # + стрелка с иконкой, без явного текста Next.
- try:
- clicked = bool(page.evaluate(
- """
- () => {
- const visible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
- const disabled = (el) => {
- if (!el) return true;
- const cls = (el.getAttribute('class') || '').toLowerCase();
- const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase();
- return el.hasAttribute('disabled') || ariaDisabled === 'true' || cls.includes('disabled');
- };
-
- const controls = Array.from(document.querySelectorAll('a,button,[role="button"]'))
- .filter((el) => visible(el) && !disabled(el));
-
- 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) {
- const currentPage = parseInt((current.textContent || '').trim(), 10);
- const nextNumber = controls.find((el) => {
- const text = (el.textContent || '').trim();
- return /^\d+$/.test(text) && parseInt(text, 10) === currentPage + 1;
- });
- if (nextNumber) {
- nextNumber.click();
- return true;
- }
- }
-
- const iconNext = controls.find((el) => {
- const text = (el.textContent || '').trim().toLowerCase();
- const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
- const title = (el.getAttribute('title') || '').trim().toLowerCase();
- const rel = (el.getAttribute('rel') || '').trim().toLowerCase();
- const cls = (el.getAttribute('class') || '').trim().toLowerCase();
- const hasRightArrowIcon = !!el.querySelector('img[src*="icon-arrow-right"], img[src*="arrow-right"]');
- return hasRightArrowIcon || rel === 'next' || aria.includes('next') || title.includes('next') || cls.includes('next') || ['next', '›', '»', '>'].includes(text);
- });
-
- if (iconNext) {
- iconNext.click();
- return true;
- }
-
- return false;
- }
- """
- ))
- if clicked:
- if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
- self.pacer.after_page_change()
- return True
- except Exception as exc:
- logger.debug("Numeric/icon pagination fallback failed: %s", exc)
-
- # JS fallback: ищем любой видимый pagination-control «next» по атрибутам/тексту.
- try:
- clicked = bool(page.evaluate(
- """
- () => {
- const candidates = Array.from(document.querySelectorAll('a,button,[role="button"]'));
- for (const el of candidates) {
- const text = (el.textContent || '').trim().toLowerCase();
- const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
- const title = (el.getAttribute('title') || '').trim().toLowerCase();
- const rel = (el.getAttribute('rel') || '').trim().toLowerCase();
- const cls = (el.getAttribute('class') || '').trim().toLowerCase();
- const disabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true' || cls.includes('disabled');
- const visible = !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
- const looksNext = rel === 'next' || aria.includes('next') || title.includes('next') || cls.includes('next') || ['next', '›', '»', '>'].includes(text);
- if (!disabled && visible && looksNext) {
- el.click();
- return true;
- }
- }
- return false;
- }
- """
- ))
- if clicked:
- if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
- self.pacer.after_page_change()
- return True
- except Exception as exc:
- logger.debug("JS next-page fallback failed: %s", exc)
-
- logger.warning("Could not navigate to next page from %s", page.url)
- return False
-
- def collect_listing_links(
- self,
- page: Page,
- *,
- make: str | None = None,
- model: str | None = None,
- known_origin_ids: set[str] | None = None,
- max_duration_seconds: float | None = None,
- ) -> dict[str, Any]:
- self.open_cars_listing(page)
- applied_filters = self.apply_filters(page, make=make, model=model)
- started_at = time.perf_counter()
- truncated_by_time_budget = False
- pages: list[dict[str, object]] = []
- all_links: list[str] = []
- early_stopped = False
- threshold = self.settings.listing.early_stop_threshold
-
- for page_number in range(1, max(1, self.settings.listing.max_pages_per_run) + 1):
- if max_duration_seconds is not None and max_duration_seconds > 0:
- elapsed = time.perf_counter() - started_at
- if elapsed >= max_duration_seconds:
- truncated_by_time_budget = True
- logger.warning(
- "Listing collection stopped by time budget: page=%d elapsed=%.1fs budget=%.1fs",
- page_number,
- elapsed,
- max_duration_seconds,
- )
- break
- page_result = self.collect_current_page(page, page_number=page_number)
- pages.append({
- "page_number": page_result.page_number,
- "source_url": page_result.source_url,
- "links_found": len(page_result.vehicle_links),
- "vehicle_links": [asdict(item) for item in page_result.vehicle_links],
- "next_page_detected": page_result.next_page_detected,
- })
- for item in page_result.vehicle_links:
- if item.href not in all_links:
- all_links.append(item.href)
- if len(all_links) >= self.settings.listing.max_vehicles_per_run:
- break
-
- # Ранний останов: если на этой странице много известных И нет новых — дальше нет смысла.
- # Важно: если есть хоть одна новая машина — продолжаем листать (новые могут быть на любой странице).
- if (
- known_origin_ids is not None
- and threshold > 0.0
- and page_result.vehicle_links
- ):
- page_known = sum(
- 1 for item in page_result.vehicle_links
- if item.lot_number and f"mobilede:{item.lot_number}" in known_origin_ids
- )
- page_new = len(page_result.vehicle_links) - page_known
- ratio = page_known / len(page_result.vehicle_links)
- # Останавливаемся только если нет новых И порог превышен
- if ratio >= threshold and page_new == 0:
- logger.info(
- "Early stop on page %d: %.0f%% known (%d/%d), 0 new >= threshold %.0f%%",
- page_number, ratio * 100, page_known,
- len(page_result.vehicle_links), threshold * 100,
- )
- early_stopped = True
- break
- elif page_new > 0 and ratio >= threshold:
- logger.info(
- "Page %d: %.0f%% known but %d new found — продолжаем",
- page_number, ratio * 100, page_new,
- )
-
- if (
- len(all_links) >= self.settings.listing.max_vehicles_per_run
- or self.settings.listing.collect_current_page_only
- or not self.settings.listing.include_pagination
- or not page_result.next_page_detected
- ):
- break
- if not self.go_to_next_page(page):
- break
-
- return {
- "listing_url": self.settings.listing.cars_url,
- "applied_filters": applied_filters,
- "pages_collected": len(pages),
- "vehicles_collected": len(all_links),
- "vehicle_urls": all_links,
- "early_stopped": early_stopped,
- "truncated_by_time_budget": truncated_by_time_budget,
- "pages": pages,
- "strategy": {
- "sequential": True,
- "collect_current_page_only": self.settings.listing.collect_current_page_only,
- "include_pagination": self.settings.listing.include_pagination,
- "max_pages_per_run": self.settings.listing.max_pages_per_run,
- "max_vehicles_per_run": self.settings.listing.max_vehicles_per_run,
- "early_stop_threshold": threshold,
- },
- }
-
- @staticmethod
- def _try_fill_filter_input(page: Page, selectors: list[str], value: str) -> bool:
- for selector in selectors:
- locator = page.locator(selector).first
- if locator.count() == 0:
- continue
- try:
- locator.click()
- locator.fill(value)
- page.keyboard.press("Enter")
- 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
- return True
- except Exception:
- continue
- return False
-
- @staticmethod
- def _has_next_page(page: Page) -> bool:
- for selector in ListingCollector._NEXT_PAGE_SELECTORS:
- locator = page.locator(selector)
- count = locator.count()
- if count == 0:
- continue
- if not hasattr(locator, "nth"):
- return True
- # Проверяем, что хотя бы один элемент не disabled.
- # Disabled "Next" на последней странице не означает наличия следующей.
- for i in range(min(count, 3)):
- try:
- el = locator.nth(i)
- disabled_attr = el.get_attribute("disabled", timeout=300)
- aria_disabled = el.get_attribute("aria-disabled", timeout=300)
- cls = (el.get_attribute("class", timeout=300) or "").lower()
- if disabled_attr is None and aria_disabled != "true" and "disabled" not in cls:
- return True
- except Exception:
- continue
- try:
- return bool(page.evaluate(
- """
- () => {
- const visible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
- const controls = Array.from(document.querySelectorAll('a,button,[role="button"]')).filter((el) => {
- const cls = (el.getAttribute('class') || '').trim().toLowerCase();
- const disabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true' || cls.includes('disabled');
- return !disabled && visible(el);
- });
-
- const hasExplicitNext = controls.some((el) => {
- const text = (el.textContent || '').trim().toLowerCase();
- const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
- const title = (el.getAttribute('title') || '').trim().toLowerCase();
- const rel = (el.getAttribute('rel') || '').trim().toLowerCase();
- const cls = (el.getAttribute('class') || '').trim().toLowerCase();
- const hasRightArrowIcon = !!el.querySelector('img[src*="icon-arrow-right"], img[src*="arrow-right"]');
- return rel === 'next' || aria.includes('next') || title.includes('next') || cls.includes('next') || hasRightArrowIcon || ['next', '›', '»', '>'].includes(text);
- });
-
- if (hasExplicitNext) {
- return true;
- }
-
- 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 false;
- }
-
- const currentPage = parseInt((current.textContent || '').trim(), 10);
- return controls.some((el) => {
- const text = (el.textContent || '').trim();
- return /^\d+$/.test(text) && parseInt(text, 10) === currentPage + 1;
- });
- }
- """
- ))
- except Exception:
- return False
- return False
diff --git a/mobilede_scraper/browser/network.py b/mobilede_scraper/browser/network.py
deleted file mode 100644
index 8e36dd6..0000000
--- a/mobilede_scraper/browser/network.py
+++ /dev/null
@@ -1,130 +0,0 @@
-import json
-import logging
-from dataclasses import dataclass, field
-from typing import Any
-from urllib.parse import urlparse
-
-from playwright.sync_api import Page, Request, Response
-
-from ..core.config import Settings
-
-logger = logging.getLogger("MOBILEDE_scraper.network")
-
-
-@dataclass
-class NetworkCapture:
- # Перехватчик сетевых запросов.
-
- settings: Settings
- requests: list[dict[str, Any]] = field(default_factory=list)
- json_responses: list[dict[str, Any]] = field(default_factory=list)
- _seen_req: set[str] = field(default_factory=set)
- _seen_resp: set[str] = field(default_factory=set)
- _origin: str | None = None
- _page: Any = field(default=None)
-
- def attach(self, page: Page, origin_url: str | None = None) -> None:
- # Снимаем старые подписки перед повторным attach.
- try:
- page.remove_listener("request", self._on_request)
- page.remove_listener("response", self._on_response)
- except Exception:
- pass
- # Подписка на сетевые события
- try:
- self._origin = urlparse(origin_url or page.url).netloc.lower() or None
- except Exception:
- self._origin = None
- self._page = page
- page.on("request", self._on_request)
- page.on("response", self._on_response)
-
- def _is_same_origin(self, url: str) -> bool:
- # Фильтр по домену
- if not self.settings.capture.capture_same_origin_only or not self._origin:
- return True
- netloc = urlparse(url).netloc.lower()
- return netloc == self._origin or netloc.endswith(".MOBILEDE.com")
-
- def _on_request(self, request: Request) -> None:
- # Берём только xhr/fetch
- if request.resource_type not in {"xhr", "fetch"}:
- return
- if not self._is_same_origin(request.url):
- return
- if len(self.requests) >= self.settings.capture.max_requests:
- return
- key = f"{request.method}:{request.url}:{request.post_data or ''}"
- # Убираем дубли
- if key in self._seen_req:
- return
- self._seen_req.add(key)
- self.requests.append({
- "url": request.url, "method": request.method,
- "resource_type": request.resource_type, "post_data": request.post_data,
- })
-
- def _on_response(self, response: Response) -> None:
- # Сохраняем JSON
- request = response.request
- if request.resource_type not in {"xhr", "fetch"}:
- return
- if not self._is_same_origin(response.url):
- return
- if len(self.json_responses) >= self.settings.capture.max_json_responses:
- return
- if not self._is_json(response):
- return
- key = f"{request.method}:{response.url}:{response.status}"
- if key in self._seen_resp:
- return
- self._seen_resp.add(key)
- try:
- payload = response.json()
- except Exception:
- try:
- payload = json.loads(response.text())
- except Exception:
- return
- self.json_responses.append({
- "url": response.url, "status": response.status,
- "request_method": request.method, "post_data": request.post_data,
- "resource_type": request.resource_type, "payload": payload,
- "category": self._categorize(response.url),
- })
-
- @staticmethod
- def _is_json(response: Response) -> bool:
- ct = (response.headers.get("content-type") or "").lower()
- if "application/json" in ct or "+json" in ct:
- return True
- url = response.url.lower()
- return any(m in url for m in ["/api/", "/graphql", "vehicledetail", "vehicle", "auction", "bid", "images", "media"])
-
- @staticmethod
- def _categorize(url: str) -> str:
- # Простая категория URL
- low = url.lower()
- mapping = {
- "images": ["image", "media", "photos", "gallery"],
- "bids": ["bid", "offer", "buy-now", "buynow"],
- "auction": ["auction", "sale", "lane", "branch"],
- "vehicle": ["vehicle", "detail", "vin", "damage", "runanddrive"],
- "documents": ["title", "document", "report"],
- }
- for cat, markers in mapping.items():
- if any(m in low for m in markers):
- return cat
- return "other"
-
- def export(self) -> dict[str, Any]:
- responses = sorted(self.json_responses, key=lambda i: (i["category"] == "other", i["url"]))
- return {
- "requests": self.requests,
- "json_responses": responses,
- "capture_limits": {
- "same_origin_only": self.settings.capture.capture_same_origin_only,
- "max_requests": self.settings.capture.max_requests,
- "max_json_responses": self.settings.capture.max_json_responses,
- },
- }
diff --git a/mobilede_scraper/browser/pace.py b/mobilede_scraper/browser/pace.py
deleted file mode 100644
index 10c2551..0000000
--- a/mobilede_scraper/browser/pace.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import random
-import time
-
-from playwright.sync_api import Locator, Page
-
-from ..core.config import Settings
-
-
-class HumanPacer:
- # Случайные паузы между действиями.
-
- def __init__(self, settings: Settings) -> None:
- self.settings = settings
-
- def pause(self, min_s: float, max_s: float) -> None:
- if self.settings.pace.enabled:
- time.sleep(random.uniform(min_s, max_s))
-
- def after_listing_open(self) -> None:
- self.pause(self.settings.pace.after_listing_open_min_s, self.settings.pace.after_listing_open_max_s)
-
- def after_filter_action(self) -> None:
- self.pause(self.settings.pace.after_filter_action_min_s, self.settings.pace.after_filter_action_max_s)
-
- def before_vehicle_open(self) -> None:
- self.pause(self.settings.pace.before_vehicle_open_min_s, self.settings.pace.before_vehicle_open_max_s)
-
- def after_vehicle_open(self) -> None:
- self.pause(self.settings.pace.after_vehicle_open_min_s, self.settings.pace.after_vehicle_open_max_s)
-
- def between_vehicles(self) -> None:
- self.pause(self.settings.pace.between_vehicles_min_s, self.settings.pace.between_vehicles_max_s)
-
- def after_page_change(self) -> None:
- self.pause(self.settings.pace.after_page_change_min_s, self.settings.pace.after_page_change_max_s)
-
- def move_mouse_to(self, page: Page, locator: Locator) -> None:
- try:
- box = locator.bounding_box()
- except Exception:
- box = None
- if not box:
- return
- x = box["x"] + box["width"] * random.uniform(0.2, 0.8)
- y = box["y"] + box["height"] * random.uniform(0.2, 0.8)
- page.mouse.move(x, y, steps=random.randint(8, 18))
diff --git a/mobilede_scraper/core/config.py b/mobilede_scraper/core/config.py
index 3173f2e..9ccd241 100644
--- a/mobilede_scraper/core/config.py
+++ b/mobilede_scraper/core/config.py
@@ -1,4 +1,4 @@
-import json
+import json
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -12,7 +12,7 @@ load_dotenv()
TRUE_VALUES = {"1", "true", "yes", "on"}
-# Хелперы для чтения env-переменных с приведением типов
+# Хелперы env.
def _env_str(name: str, default: str) -> str:
value = os.getenv(name)
@@ -47,80 +47,13 @@ def _env_float(name: str, default: float) -> float:
return float(_env_str(name, str(default)).strip())
-# Конфиг браузерного отпечатка (User-Agent, viewport, timezone)
-
-@dataclass(slots=True)
-class FingerprintConfig:
- user_agent: str = (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/135.0.0.0 Safari/537.36"
- )
- viewport_presets: tuple[dict[str, int], ...] = (
- {"width": 1920, "height": 1080},
- {"width": 1600, "height": 900},
- {"width": 1536, "height": 864},
- {"width": 1440, "height": 900},
- {"width": 1366, "height": 768},
- )
- timezone_candidates: tuple[str, ...] = (
- "America/New_York",
- "America/Chicago",
- "America/Los_Angeles",
- )
- locale: str = "en-US"
- sec_ch_ua: str = '"Google Chrome";v="135", "Chromium";v="135", "Not.A/Brand";v="24"'
-
-
-# Конфиг перехвата сетевых запросов (лимиты на кол-во)
-
-@dataclass(slots=True)
-class CaptureConfig:
- capture_same_origin_only: bool = _env_bool("MOBILEDE_CAPTURE_SAME_ORIGIN_ONLY", True)
- max_requests: int = _env_int("MOBILEDE_MAX_CAPTURED_REQUESTS", 40)
- max_json_responses: int = _env_int("MOBILEDE_MAX_CAPTURED_JSON_RESPONSES", 30)
-
-
-# Конфиг пауз между действиями (имитация человека)
-
-@dataclass(slots=True)
-class HumanPaceConfig:
- enabled: bool = _env_bool("MOBILEDE_HUMAN_PACE_ENABLED", True)
- after_listing_open_min_s: float = _env_float("MOBILEDE_AFTER_LISTING_OPEN_MIN_S", 0.5)
- after_listing_open_max_s: float = _env_float("MOBILEDE_AFTER_LISTING_OPEN_MAX_S", 1.2)
- after_filter_action_min_s: float = _env_float("MOBILEDE_AFTER_FILTER_ACTION_MIN_S", 0.5)
- after_filter_action_max_s: float = _env_float("MOBILEDE_AFTER_FILTER_ACTION_MAX_S", 1.2)
- before_vehicle_open_min_s: float = _env_float("MOBILEDE_BEFORE_VEHICLE_OPEN_MIN_S", 0.1)
- before_vehicle_open_max_s: float = _env_float("MOBILEDE_BEFORE_VEHICLE_OPEN_MAX_S", 0.3)
- after_vehicle_open_min_s: float = _env_float("MOBILEDE_AFTER_VEHICLE_OPEN_MIN_S", 0.05)
- after_vehicle_open_max_s: float = _env_float("MOBILEDE_AFTER_VEHICLE_OPEN_MAX_S", 0.15)
- between_vehicles_min_s: float = _env_float("MOBILEDE_BETWEEN_VEHICLES_MIN_S", 0.05)
- between_vehicles_max_s: float = _env_float("MOBILEDE_BETWEEN_VEHICLES_MAX_S", 0.15)
- after_page_change_min_s: float = _env_float("MOBILEDE_AFTER_PAGE_CHANGE_MIN_S", 0.8)
- after_page_change_max_s: float = _env_float("MOBILEDE_AFTER_PAGE_CHANGE_MAX_S", 1.8)
-
-
-# Конфиг сбора листинга (URL, лимиты страниц и машин)
+# Конфиг ссылок.
@dataclass(slots=True)
class ListingConfig:
- cars_url: str = _env_str("MOBILEDE_CARS_LISTING_URL", "https://www.MOBILEDE.com/Vehiclelisting/Cars")
+ cars_url: str = _env_str("MOBILEDE_CARS_LISTING_URL", "https://www.mobile.de/Vehiclelisting/Cars")
filtered_search_url: str | None = _env_optional_str("MOBILEDE_FILTERED_SEARCH_URL")
filtered_search_urls_raw: str | None = _env_optional_str("MOBILEDE_FILTERED_SEARCH_URLS")
- max_pages_per_run: int = _env_int("MOBILEDE_MAX_PAGES_PER_RUN", 9999)
- max_vehicles_per_run: int = _env_int("MOBILEDE_MAX_VEHICLES_PER_RUN", 50000)
- page_link_limit: int = _env_int("MOBILEDE_PAGE_LINK_LIMIT", 500)
- include_pagination: bool = _env_bool("MOBILEDE_INCLUDE_PAGINATION", True)
- collect_current_page_only: bool = _env_bool("MOBILEDE_COLLECT_CURRENT_PAGE_ONLY", False)
- # Порог раннего останова: если доля уже известных машин на странице >= этого значения,
- # прекращаем листать — все новые машины уже найдены. 0 = отключено.
- early_stop_threshold: float = _env_float("MOBILEDE_EARLY_STOP_THRESHOLD", 0.8)
- # Сегментация листинга по брендам для обхода лимита пагинации MOBILEDE (~22 600 машин).
- # JSON-массив объектов: [{"make":"TOYOTA"},{"make":"FORD"},...], "auto" для авто-списка
- # или "runtime" для построения по runtime_config.filters.brands.
- # Пустая строка = без сегментации (backward compatible).
- listing_segments_json: str = _env_str("MOBILEDE_LISTING_SEGMENTS", "")
- fast_segment_year_splits: bool = _env_bool("MOBILEDE_FAST_SEGMENT_YEAR_SPLITS", True)
@property
def filtered_search_urls(self) -> list[str]:
@@ -143,106 +76,7 @@ class ListingConfig:
return urls
-# Список брендов MOBILEDE для автоматической сегментации.
-# Покрывает >99% автомобилей на сайте. Порядок: от крупных к мелким.
-MOBILEDE_DEFAULT_MAKES: tuple[str, ...] = (
- "TOYOTA", "FORD", "CHEVROLET", "HONDA", "NISSAN", "HYUNDAI",
- "KIA", "DODGE", "JEEP", "BMW", "MERCEDES-BENZ", "SUBARU",
- "VOLKSWAGEN", "GMC", "MAZDA", "LEXUS", "CHRYSLER", "AUDI",
- "RAM", "BUICK", "CADILLAC", "ACURA", "INFINITI", "LINCOLN",
- "MITSUBISHI", "VOLVO", "JAGUAR", "LAND ROVER", "PORSCHE",
- "MINI", "TESLA", "GENESIS", "FIAT", "ALFA ROMEO", "MASERATI",
- "SCION", "PONTIAC", "SATURN", "MERCURY", "SAAB", "SUZUKI",
- "OLDSMOBILE", "ISUZU", "HUMMER", "PLYMOUTH", "SMART",
- "RIVIAN", "LUCID", "POLESTAR", "FERRARI", "LAMBORGHINI",
- "BENTLEY", "ROLLS-ROYCE", "ASTON MARTIN", "MCLAREN", "LOTUS",
- "MAYBACH", "FISKER", "GEO", "DAEWOO", "EAGLE",
-)
-
-# Пагинационный потолок mobilede: ~226 страниц × 100 = 22 600 результатов.
-MOBILEDE_PAGINATION_CEILING = 22_600
-
-# Бренды, потенциально превышающие потолок пагинации — разбиваем по годам.
-_LARGE_MAKES: frozenset[str] = frozenset({
- "TOYOTA", "FORD", "CHEVROLET", "HONDA", "NISSAN", "HYUNDAI",
- "KIA", "DODGE", "JEEP",
-})
-_YEAR_SPLITS: tuple[tuple[int, int], ...] = (
- (1900, 2012),
- (2013, 2019),
- (2020, 2027),
-)
-
-
-def build_listing_segments_for_makes(makes: tuple[str, ...] | list[str]) -> list[dict[str, str | int | None]]:
- """Строит сегменты по переданному списку брендов.
-
- Крупные бренды режутся по годовым диапазонам так же, как в ``auto``.
- """
- segments: list[dict[str, str | int | None]] = []
- seen: set[str] = set()
- for raw_make in makes:
- make = str(raw_make or "").strip().upper()
- if not make or make in seen:
- continue
- seen.add(make)
- if make in _LARGE_MAKES:
- for yr_min, yr_max in _YEAR_SPLITS:
- segments.append({"make": make, "year_min": yr_min, "year_max": yr_max})
- else:
- segments.append({"make": make, "year_min": None, "year_max": None})
- return segments
-
-
-def build_fast_listing_segments_for_makes(makes: tuple[str, ...] | list[str]) -> list[dict[str, str | int | None]]:
- """Строит HTTP-first сегменты без UI-фильтров годов.
-
- Это ближе к MOBILEDE-fast: один бренд = один hidden-payload HTTP обход.
- Годовые split-сегменты требуют браузерный UI-фильтр и ломают стабильность fast-профиля.
- """
- segments: list[dict[str, str | int | None]] = []
- seen: set[str] = set()
- for raw_make in makes:
- make = str(raw_make or "").strip().upper()
- if not make or make in seen:
- continue
- seen.add(make)
- segments.append({"make": make, "year_min": None, "year_max": None})
- return segments
-
-
-def parse_listing_segments(raw: str) -> list[dict[str, str | int | None]]:
- """Парсит MOBILEDE_LISTING_SEGMENTS в список сегментов.
-
- Каждый сегмент — dict с ключами: make (str), year_min/year_max (int|None).
- Специальное значение ``"auto"`` генерирует сегменты из MOBILEDE_DEFAULT_MAKES.
- Крупные бренды автоматически разбиваются по диапазонам годов.
- """
- raw = raw.strip()
- if not raw:
- return []
- if raw.lower() == "auto":
- return build_listing_segments_for_makes(list(MOBILEDE_DEFAULT_MAKES))
- try:
- data = json.loads(raw)
- except (json.JSONDecodeError, ValueError):
- return []
- if not isinstance(data, list):
- return []
- segments = []
- for item in data:
- if isinstance(item, str):
- segments.append({"make": item.upper(), "year_min": None, "year_max": None})
- elif isinstance(item, dict):
- segments.append({
- "make": str(item.get("make") or "").upper() or None,
- "year_min": int(item["year_min"]) if item.get("year_min") is not None else None,
- "year_max": int(item["year_max"]) if item.get("year_max") is not None else None,
- })
- return segments
-
-
-# - Конфиг PostgreSQL (URL, пул соединений, pool_recycle)
+# Конфиг PostgreSQL.
@dataclass(slots=True)
class DatabaseConfig:
@@ -254,7 +88,7 @@ class DatabaseConfig:
auto_create_tables: bool = _env_bool("MOBILEDE_DATABASE_AUTO_CREATE_TABLES", False)
-# --- Конфиг Redis (URL для Celery broker) ---
+# Конфиг Redis.
@dataclass(slots=True)
class RedisConfig:
@@ -264,17 +98,7 @@ class RedisConfig:
health_check_interval_seconds: int = _env_int("MOBILEDE_REDIS_HEALTH_CHECK_INTERVAL_SECONDS", 30)
-# --- Конфиг Discovery (режим обнаружения, hourly batch) ---
-
-@dataclass(slots=True)
-class DiscoveryConfig:
- mode: str = _env_str("MOBILEDE_DISCOVERY_MODE", "sitemap")
- hourly_mode: str = _env_str("MOBILEDE_HOURLY_MODE", "rolling_refresh")
- hourly_refresh_batch_size: int = _env_int("MOBILEDE_HOURLY_REFRESH_BATCH_SIZE", 500)
- always_full_scan: bool = _env_bool("MOBILEDE_ALWAYS_FULL_SCAN", False)
-
-
-# --- Конфиг Celery (лимиты задач, concurrency, beat-расписание) ---
+# Конфиг Celery.
@dataclass(slots=True)
class CeleryConfig:
@@ -334,7 +158,7 @@ class ScrapingProfileConfig:
self.listing_retries = None
-# --- Конфиг прокси (server, username, password) ---
+# Конфиг прокси.
@dataclass(slots=True)
class ProxyConfig:
@@ -383,11 +207,11 @@ class ProxyConfig:
return {"http": proxy_url, "https": proxy_url}
-# Главный объект настроек: собирает все блоки конфигурации
+# Общие настройки.
@dataclass(slots=True)
class Settings:
- home_url: str = "https://www.MOBILEDE.com/"
+ home_url: str = "https://www.mobile.de/"
default_timeout_ms: int = _env_int("MOBILEDE_TIMEOUT_MS", 45000)
network_settle_ms: int = _env_int("MOBILEDE_NETWORK_SETTLE_MS", 400)
fast_path_timeout_ms: int = _env_int("MOBILEDE_FAST_PATH_TIMEOUT_MS", 15000)
@@ -407,15 +231,11 @@ class Settings:
tokens_file: str | None = _env_path_str("MOBILEDE_TOKENS_FILE")
runtime_config_file: str | None = _env_path_str("MOBILEDE_RUNTIME_CONFIG_FILE")
scheduler_interval_minutes: int = _env_int("MOBILEDE_SCHEDULER_INTERVAL_MINUTES", 60)
- fingerprint: FingerprintConfig = field(default_factory=FingerprintConfig)
- capture: CaptureConfig = field(default_factory=CaptureConfig)
- pace: HumanPaceConfig = field(default_factory=HumanPaceConfig)
listing: ListingConfig = field(default_factory=ListingConfig)
database: DatabaseConfig = field(default_factory=DatabaseConfig)
redis: RedisConfig = field(default_factory=RedisConfig)
celery: CeleryConfig = field(default_factory=CeleryConfig)
proxy: ProxyConfig = field(default_factory=ProxyConfig)
- discovery: DiscoveryConfig = field(default_factory=DiscoveryConfig)
scraping_profile: ScrapingProfileConfig = field(default_factory=ScrapingProfileConfig)
@property
@@ -430,5 +250,5 @@ class Settings:
def block_resources(self) -> bool:
return self.celery.block_resources
-# Глобальный синглтон — используется по умолчанию во всех модулях.
+# Глобальные настройки.
settings = Settings()
diff --git a/mobilede_scraper/core/logs.py b/mobilede_scraper/core/logs.py
index 45e151a..4326ad9 100644
--- a/mobilede_scraper/core/logs.py
+++ b/mobilede_scraper/core/logs.py
@@ -39,7 +39,7 @@ def setup_logging(level: str = "INFO", log_file: str | None = None) -> None:
root.addHandler(handler)
root.propagate = False
fmt = logging.Formatter(
- "%(asctime)s | %(levelname)s | %(name)s | trace=%(trace_id)s | %(message)s"
+ "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
for handler in root.handlers:
handler.setFormatter(fmt)
diff --git a/mobilede_scraper/discovery/__init__.py b/mobilede_scraper/discovery/__init__.py
index 86402b2..c9c2ef6 100644
--- a/mobilede_scraper/discovery/__init__.py
+++ b/mobilede_scraper/discovery/__init__.py
@@ -1,15 +1 @@
-from .sitemap import (
- SitemapDiscoveryError,
- SitemapDiscoveryResult,
- SitemapDiscoveryStats,
- discover_vehicle_urls_from_sitemap,
- discover_vehicle_urls_from_sitemap_with_stats,
-)
-
-__all__ = [
- "SitemapDiscoveryError",
- "SitemapDiscoveryResult",
- "SitemapDiscoveryStats",
- "discover_vehicle_urls_from_sitemap",
- "discover_vehicle_urls_from_sitemap_with_stats",
-]
+__all__: list[str] = []
diff --git a/mobilede_scraper/discovery/sitemap.py b/mobilede_scraper/discovery/sitemap.py
deleted file mode 100644
index 768f376..0000000
--- a/mobilede_scraper/discovery/sitemap.py
+++ /dev/null
@@ -1,210 +0,0 @@
-from __future__ import annotations
-
-import gzip
-import io
-import logging
-import re
-from dataclasses import dataclass, field
-from typing import Iterable
-from urllib.parse import urlsplit, urlunsplit
-from urllib.request import Request, urlopen, ProxyHandler, build_opener
-import xml.etree.ElementTree as ET
-
-logger = logging.getLogger("MOBILEDE_scraper.discovery.sitemap")
-
-DEFAULT_SITEMAP_INDEX_URL = "https://www.MOBILEDE.com/Xj9rDOVMEi0hc38S/sitemap_index.xml"
-_SITEMAP_TIMEOUT_SECONDS = 30
-_LOC_TAG_RE = re.compile(rb"\s*(.*?)\s*", re.IGNORECASE | re.DOTALL)
-
-
-class SitemapDiscoveryError(RuntimeError):
- pass
-
-
-def _is_vehicle_sitemap_url(url: str) -> bool:
- lowered = url.strip().lower()
- # Берём только sitemap с авто.
- if "sitemapbranches" in lowered or "sitemapauctions" in lowered:
- return False
- return lowered.endswith(".xml") or lowered.endswith(".xml.gz")
-
-
-def _normalize_vehicle_url(url: str) -> str:
- parts = urlsplit(url.strip())
- return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
-
-
-def _download_bytes(url: str, proxy_url: str | None = None) -> bytes:
- request = Request(
- url,
- headers={
- "User-Agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
- ),
- "Accept": "application/xml,text/xml,application/xhtml+xml,text/html;q=0.9,*/*;q=0.8",
- "Accept-Encoding": "gzip",
- },
- )
- if proxy_url:
- handler = ProxyHandler({"http": proxy_url, "https": proxy_url})
- opener = build_opener(handler)
- response = opener.open(request, timeout=_SITEMAP_TIMEOUT_SECONDS)
- else:
- response = urlopen(request, timeout=_SITEMAP_TIMEOUT_SECONDS)
- with response:
- payload = response.read()
- encoding = str(response.headers.get("Content-Encoding") or "").lower()
- if encoding == "gzip" or url.lower().endswith(".gz"):
- return gzip.GzipFile(fileobj=io.BytesIO(payload)).read()
- return payload
-
-
-def _local_name(tag: str) -> str:
- if "}" in tag:
- return tag.rsplit("}", 1)[1]
- return tag
-
-
-def _iter_loc_values_fallback(xml_bytes: bytes) -> Iterable[str]:
- for match in _LOC_TAG_RE.finditer(xml_bytes):
- try:
- value = match.group(1).decode("utf-8", errors="ignore").strip()
- except Exception:
- continue
- if value:
- yield value
-
-
-def _iter_loc_values(xml_bytes: bytes) -> Iterable[str]:
- try:
- root = ET.fromstring(xml_bytes)
- except ET.ParseError as exc:
- fallback_values = list(_iter_loc_values_fallback(xml_bytes))
- if fallback_values:
- logger.warning(
- "Falling back to regex sitemap loc extraction after XML parse error: %s",
- exc,
- )
- yield from fallback_values
- return
- raise SitemapDiscoveryError(f"Invalid sitemap XML: {exc}") from exc
-
- for element in root.iter():
- if _local_name(element.tag) != "loc":
- continue
- if not element.text:
- continue
- value = element.text.strip()
- if value:
- yield value
-
-
-def _filter_vehicle_urls(urls: Iterable[str]) -> list[str]:
- result: list[str] = []
- seen: set[str] = set()
- for url in urls:
- normalized = _normalize_vehicle_url(url)
- if "/VehicleDetail/" not in normalized and "/vehicledetail/" not in normalized:
- continue
- if normalized in seen:
- continue
- seen.add(normalized)
- result.append(normalized)
- return result
-
-
-def _looks_like_vehicle_detail_sitemap(urls: list[str]) -> bool:
- return any("/vehicledetail/" in url.lower() for url in urls)
-
-
-def discover_vehicle_urls_from_sitemap(index_url: str = DEFAULT_SITEMAP_INDEX_URL, proxy_url: str | None = None) -> list[str]:
- logger.info("Downloading sitemap index: %s", index_url)
- index_xml = _download_bytes(index_url, proxy_url=proxy_url)
- sitemap_urls = [url for url in _iter_loc_values(index_xml) if _is_vehicle_sitemap_url(url)]
- if not sitemap_urls:
- raise SitemapDiscoveryError("Sitemap index returned no sitemap URLs")
-
- all_vehicle_urls: list[str] = []
- for sitemap_url in sitemap_urls:
- logger.info("Downloading sitemap: %s", sitemap_url)
- try:
- sitemap_xml = _download_bytes(sitemap_url, proxy_url=proxy_url)
- raw_urls = list(_iter_loc_values(sitemap_xml))
- except SitemapDiscoveryError as exc:
- logger.warning("Skipping malformed sitemap %s: %s", sitemap_url, exc)
- continue
-
- vehicle_urls = _filter_vehicle_urls(raw_urls)
- if raw_urls and not vehicle_urls and not _looks_like_vehicle_detail_sitemap(raw_urls):
- logger.info("Skipping non-vehicle sitemap %s", sitemap_url)
- continue
- logger.info("Sitemap %s yielded %d vehicle URLs", sitemap_url, len(vehicle_urls))
- all_vehicle_urls.extend(vehicle_urls)
-
- deduped = _filter_vehicle_urls(all_vehicle_urls)
- if not deduped:
- raise SitemapDiscoveryError("No vehicle detail URLs discovered from vehicle sitemaps")
- logger.info("Sitemap discovery done: %d vehicle URLs", len(deduped))
- return deduped
-
-
-@dataclass
-class SitemapDiscoveryStats:
- transport: str = "http"
- fetched_sitemaps: int = 0
- blocked_sitemaps: int = 0
- malformed_sitemaps: int = 0
- direct_probe_hits: int = 0
- direct_probe_misses: int = 0
-
-
-@dataclass
-class SitemapDiscoveryResult:
- vehicle_urls: list[str] = field(default_factory=list)
- stats: SitemapDiscoveryStats = field(default_factory=SitemapDiscoveryStats)
-
-
-def discover_vehicle_urls_from_sitemap_with_stats(
- settings=None,
- index_url: str = DEFAULT_SITEMAP_INDEX_URL,
-) -> SitemapDiscoveryResult:
- """Возвращает URL и статистику."""
- proxy_url = None
- if settings is not None and hasattr(settings, 'proxy') and settings.proxy.server:
- proxy_url = settings.proxy.server
- stats = SitemapDiscoveryStats(transport="http")
- logger.info("Downloading sitemap index: %s", index_url)
- index_xml = _download_bytes(index_url, proxy_url=proxy_url)
- sitemap_urls = [url for url in _iter_loc_values(index_xml) if _is_vehicle_sitemap_url(url)]
- if not sitemap_urls:
- raise SitemapDiscoveryError("Sitemap index returned no sitemap URLs")
-
- all_vehicle_urls: list[str] = []
- for sitemap_url in sitemap_urls:
- logger.info("Downloading sitemap: %s", sitemap_url)
- try:
- sitemap_xml = _download_bytes(sitemap_url, proxy_url=proxy_url)
- raw_urls = list(_iter_loc_values(sitemap_xml))
- stats.fetched_sitemaps += 1
- except SitemapDiscoveryError as exc:
- logger.warning("Skipping malformed sitemap %s: %s", sitemap_url, exc)
- stats.malformed_sitemaps += 1
- continue
- except Exception as exc:
- logger.warning("Blocked/failed sitemap %s: %s", sitemap_url, exc)
- stats.blocked_sitemaps += 1
- continue
-
- vehicle_urls = _filter_vehicle_urls(raw_urls)
- if raw_urls and not vehicle_urls and not _looks_like_vehicle_detail_sitemap(raw_urls):
- logger.info("Skipping non-vehicle sitemap %s", sitemap_url)
- continue
- logger.info("Sitemap %s yielded %d vehicle URLs", sitemap_url, len(vehicle_urls))
- all_vehicle_urls.extend(vehicle_urls)
-
- deduped = _filter_vehicle_urls(all_vehicle_urls)
- if not deduped:
- raise SitemapDiscoveryError("No vehicle detail URLs discovered from vehicle sitemaps")
- logger.info("Sitemap discovery done: %d vehicle URLs", len(deduped))
- return SitemapDiscoveryResult(vehicle_urls=deduped, stats=stats)
diff --git a/mobilede_scraper/fast_sync.py b/mobilede_scraper/fast_sync.py
deleted file mode 100644
index 3fb27fc..0000000
--- a/mobilede_scraper/fast_sync.py
+++ /dev/null
@@ -1,472 +0,0 @@
-from __future__ import annotations
-
-import concurrent.futures
-import logging
-import random
-import time
-from dataclasses import dataclass
-from typing import Any, Callable
-
-from .browser.fast_client import FastListingVehicle, MobiledeFastClient
-from .core.runtime_config import RuntimeConfig
-from .parsing.fast_mapper import INACTIVE_STATUS_VALUES, FastCarMapper
-from .storage.db import PersistenceService
-from .storage.schemas import CarRecord
-
-logger = logging.getLogger("MOBILEDE_scraper.fast_sync")
-
-
-@dataclass(slots=True)
-class FastSyncStats:
- ids_fetched: int = 0
- cars_upserted: int = 0
- cars_failed: int = 0
- cars_filtered: int = 0
- images_upserted: int = 0
- skipped_existing: int = 0
- protection_events: int = 0
-
-
-def passes_condition_check(row: FastListingVehicle) -> bool:
- if row.timed_auction_closed:
- return False
- status = (row.inventory_status or "").strip().upper()
- return status not in INACTIVE_STATUS_VALUES
-
-
-class FastSyncEngine:
- """Full MOBILEDE-fast style sync pipeline adapted to this project's storage.
-
- Flow: hidden listing payloads -> concurrent ProductDetailsVM HTTP fetch ->
- CarRecord preparation in memory -> single batch DB upsert. Browser is used
- only inside MobiledeFastClient to refresh cookies when MOBILEDE challenge appears.
- """
-
- def __init__(
- self,
- *,
- client: MobiledeFastClient,
- mapper: FastCarMapper,
- persistence: PersistenceService,
- batch_size: int,
- fetch_concurrency: int,
- report_progress: Callable[[str, Any], None] | None = None,
- ) -> None:
- self.client = client
- self.mapper = mapper
- self.persistence = persistence
- self.batch_size = max(1, int(batch_size))
- self.fetch_concurrency = max(1, int(fetch_concurrency))
- self.report_progress = report_progress
-
- @staticmethod
- def _is_transient_detail_error(exc: Exception) -> bool:
- text = str(exc).lower()
- return any(
- marker in text
- for marker in (
- "sslerror",
- "ssleoferror",
- "unexpected_eof_while_reading",
- "eof occurred in violation of protocol",
- "max retries exceeded",
- "read timed out",
- "readtimeout",
- "connection reset",
- "connection aborted",
- "connection closed",
- "temporarily unavailable",
- "too many requests",
- "status=429",
- "status=500",
- "status=502",
- "status=503",
- "status=504",
- )
- )
-
- def sync_listing(
- self,
- *,
- runtime_config: RuntimeConfig,
- make: str | None = None,
- model: str | None = None,
- lane: str = "MOBILEDE_cars",
- limit: int | None = None,
- only_new: bool = False,
- listing_url: str | None = None,
- max_pages: int | None = None,
- skip_mark_sold: bool = False,
- ) -> dict[str, Any]:
- del lane
- started_at = time.perf_counter()
- stats = FastSyncStats()
- errors: list[dict[str, str]] = []
- selected: dict[str, FastListingVehicle] = {}
- rows_seen = 0
- rows_skipped_condition = 0
-
- filters = runtime_config.filters
- logger.info(
- "Fast HTTP-first listing started: make=%s listing_url=%s max_pages=%s concurrency=%s batch_size=%s",
- make or "ALL",
- listing_url or "default",
- max_pages,
- self.fetch_concurrency,
- self.batch_size,
- )
- for vehicle in self.client.iter_listing_vehicles(
- listing_start_url=listing_url,
- make=make,
- max_pages=max_pages,
- ):
- rows_seen += 1
- if runtime_config.sync.condition_check_enabled and not passes_condition_check(vehicle):
- rows_skipped_condition += 1
- continue
- if vehicle.inventory_id in selected:
- continue
- selected[vehicle.inventory_id] = vehicle
- if limit is not None and limit > 0 and len(selected) >= limit:
- break
-
- candidates = list(selected.values())
- all_listing_origin_urls = {f"https://www.MOBILEDE.com/VehicleDetail/{v.inventory_id}" for v in candidates}
- if only_new and candidates:
- origin_urls = [f"https://www.MOBILEDE.com/VehicleDetail/{v.inventory_id}" for v in candidates]
- origin_ids = [f"mobilede:{v.inventory_id}" for v in candidates]
- existing_urls, existing_ids = self.persistence.get_existing_urls_and_ids(origin_urls, origin_ids)
- fresh: list[FastListingVehicle] = []
- for vehicle in candidates:
- if f"https://www.MOBILEDE.com/VehicleDetail/{vehicle.inventory_id}" in existing_urls or f"mobilede:{vehicle.inventory_id}" in existing_ids:
- stats.skipped_existing += 1
- continue
- fresh.append(vehicle)
- candidates = fresh
-
- self._progress(
- "fast_listing_collected",
- rows_seen=rows_seen,
- rows_filtered_condition=rows_skipped_condition,
- rows_selected=len(candidates),
- skipped_existing=stats.skipped_existing,
- )
- logger.info(
- "Fast HTTP-first listing collected: rows_seen=%d selected=%d skipped_existing=%d filtered_condition=%d only_new=%s",
- rows_seen,
- len(candidates),
- stats.skipped_existing,
- rows_skipped_condition,
- only_new,
- )
-
- scan_completed = not (limit is not None and limit > 0) and not errors
-
- if not candidates:
- return self._result(
- started_at=started_at,
- stats=stats,
- failures=errors,
- listing={
- "mode": "fast_hidden_payload",
- "vehicles_collected": 0,
- "vehicle_urls": [],
- "early_stopped": False,
- "truncated_by_time_budget": False,
- "rows_seen": rows_seen,
- "rows_filtered_condition": rows_skipped_condition,
- },
- all_listing_origin_urls=all_listing_origin_urls,
- full_scan_completed=scan_completed,
- )
-
- prepared_rows: list[CarRecord] = []
- db_processed = 0
- retry_candidates: list[FastListingVehicle] = []
- started_details = time.perf_counter()
- with concurrent.futures.ThreadPoolExecutor(max_workers=min(self.fetch_concurrency, len(candidates))) as executor:
- future_to_vehicle = {
- executor.submit(self._fetch_detail_payload, vehicle.inventory_id): vehicle
- for vehicle in candidates
- }
- for index, future in enumerate(concurrent.futures.as_completed(future_to_vehicle), start=1):
- vehicle = future_to_vehicle[future]
- try:
- payload = future.result()
- record = self.mapper.map_payload_to_record(
- detail_payload=payload,
- vehicle_url=f"https://www.MOBILEDE.com/VehicleDetail/{vehicle.inventory_id}",
- listing_vehicle=vehicle,
- )
- if model and model.casefold() not in record.model.casefold():
- stats.cars_filtered += 1
- continue
- if not filters.matches({
- "brand": record.brand,
- "model": record.model,
- "year": record.year,
- "body_type": record.body_type,
- "color": record.color,
- "drive": record.drive,
- "gearbox": record.gearbox,
- "price": record.price,
- "mileage": record.mileage,
- }):
- stats.cars_filtered += 1
- continue
- prepared_rows.append(record)
- stats.ids_fetched += 1
- if len(prepared_rows) >= self.batch_size:
- db_processed += self._flush_db_records(
- rows=prepared_rows,
- stats=stats,
- errors=errors,
- processed=db_processed + len(prepared_rows),
- total=len(candidates),
- )
- prepared_rows.clear()
- except Exception as exc:
- stats.cars_failed += 1
- errors.append({"vehicle_url": f"https://www.MOBILEDE.com/VehicleDetail/{vehicle.inventory_id}", "error": str(exc)})
- if _looks_like_protection(exc):
- stats.protection_events += 1
- if self._is_transient_detail_error(exc):
- retry_candidates.append(vehicle)
- logger.info("Fast detail transient failure queued for retry inventory_id=%s: %s", vehicle.inventory_id, exc)
- else:
- logger.exception("Fast detail parse failed inventory_id=%s: %s", vehicle.inventory_id, exc)
-
- if index % 100 == 0 or index == len(candidates):
- elapsed = max(0.001, time.perf_counter() - started_details)
- if self.client._settings.scraping_profile.verbose_progress_logs:
- logger.info(
- "Fast HTTP-first details progress: processed=%d/%d ok=%d failed=%d queued_db=%d rate=%.2f/s",
- index,
- len(candidates),
- stats.ids_fetched,
- stats.cars_failed,
- len(prepared_rows),
- index / elapsed,
- )
- self._progress(
- "fast_detail_progress",
- processed=index,
- total=len(candidates),
- ids_fetched=stats.ids_fetched,
- cars_failed=stats.cars_failed,
- queued_for_db=len(prepared_rows),
- throughput=round(index / elapsed, 2),
- )
-
- if retry_candidates:
- retry_started = time.perf_counter()
- retry_workers = max(1, min(8, self.fetch_concurrency // 2, len(retry_candidates)))
- retry_errors: list[dict[str, str]] = []
- logger.info(
- "Fast HTTP-first retrying transient detail failures: total=%d workers=%d",
- len(retry_candidates),
- retry_workers,
- )
- with concurrent.futures.ThreadPoolExecutor(max_workers=retry_workers) as executor:
- future_to_vehicle = {
- executor.submit(self._fetch_detail_payload, vehicle.inventory_id): vehicle
- for vehicle in retry_candidates
- }
- for retry_index, future in enumerate(concurrent.futures.as_completed(future_to_vehicle), start=1):
- vehicle = future_to_vehicle[future]
- try:
- payload = future.result()
- record = self.mapper.map_payload_to_record(
- detail_payload=payload,
- vehicle_url=f"https://www.MOBILEDE.com/VehicleDetail/{vehicle.inventory_id}",
- listing_vehicle=vehicle,
- )
- if model and model.casefold() not in record.model.casefold():
- stats.cars_filtered += 1
- continue
- if not filters.matches({
- "brand": record.brand,
- "model": record.model,
- "year": record.year,
- "body_type": record.body_type,
- "color": record.color,
- "drive": record.drive,
- "gearbox": record.gearbox,
- "price": record.price,
- "mileage": record.mileage,
- }):
- stats.cars_filtered += 1
- continue
- prepared_rows.append(record)
- stats.ids_fetched += 1
- stats.cars_failed = max(0, stats.cars_failed - 1)
- if len(prepared_rows) >= self.batch_size:
- db_processed += self._flush_db_records(
- rows=prepared_rows,
- stats=stats,
- errors=errors,
- processed=db_processed + len(prepared_rows),
- total=len(candidates),
- )
- prepared_rows.clear()
- except Exception as exc:
- retry_errors.append({
- "vehicle_url": f"https://www.MOBILEDE.com/VehicleDetail/{vehicle.inventory_id}",
- "error": str(exc),
- })
- if _looks_like_protection(exc):
- stats.protection_events += 1
- if retry_index % 100 == 0 or retry_index == len(retry_candidates):
- elapsed = max(0.001, time.perf_counter() - retry_started)
- logger.info(
- "Fast HTTP-first retry progress: processed=%d/%d recovered=%d remaining_failed=%d rate=%.2f/s",
- retry_index,
- len(retry_candidates),
- len(retry_candidates) - len(retry_errors),
- len(retry_errors),
- retry_index / elapsed,
- )
- transient_urls = {f"https://www.MOBILEDE.com/VehicleDetail/{v.inventory_id}" for v in retry_candidates}
- errors = [error for error in errors if error.get("vehicle_url") not in transient_urls]
- errors.extend(retry_errors)
-
- if prepared_rows:
- db_processed += self._flush_db_records(
- rows=prepared_rows,
- stats=stats,
- errors=errors,
- processed=db_processed + len(prepared_rows),
- total=len(candidates),
- )
- prepared_rows.clear()
-
- self.client.persist_session_state()
-
- scan_completed = not (limit is not None and limit > 0) and not errors
- mark_sold_scope_partial = bool(
- (limit is not None and limit > 0)
- or make
- or model
- or listing_url
- or only_new
- )
- if all_listing_origin_urls and not mark_sold_scope_partial and not skip_mark_sold and scan_completed:
- try:
- sold_count = self.persistence.mark_sold_not_in_listing_by_urls(all_listing_origin_urls, lane="MOBILEDE")
- except Exception as exc:
- sold_count = 0
- logger.warning("Fast sold reconcile failed: %s", exc)
- else:
- sold_count = 0
-
- listing = {
- "mode": "fast_hidden_payload",
- "vehicles_collected": len(candidates),
- "vehicle_urls": [f"https://www.MOBILEDE.com/VehicleDetail/{v.inventory_id}" for v in candidates],
- "early_stopped": False,
- "truncated_by_time_budget": False,
- "rows_seen": rows_seen,
- "rows_filtered_condition": rows_skipped_condition,
- "sold_marked": sold_count,
- }
- return self._result(
- started_at=started_at,
- stats=stats,
- failures=errors,
- listing=listing,
- all_listing_origin_urls=all_listing_origin_urls,
- full_scan_completed=scan_completed,
- )
-
- def _result(
- self,
- *,
- started_at: float,
- stats: FastSyncStats,
- failures: list[dict[str, str]],
- listing: dict[str, Any],
- all_listing_origin_urls: set[str],
- full_scan_completed: bool,
- ) -> dict[str, Any]:
- status = "success" if not failures else ("partial_success" if stats.cars_upserted else "failed")
- total = int(listing.get("vehicles_collected") or 0)
- fail_ratio = (stats.cars_failed / total) if total > 0 else 0.0
- protection_ratio = (stats.protection_events / total) if total > 0 else 0.0
- anti_bot_detected = total > 0 and ((stats.protection_events >= 30 and protection_ratio >= 0.10) or fail_ratio >= 0.30)
- return {
- "status": status,
- "listing": listing,
- "total": total,
- "total_discovered": total,
- "skipped_existing": stats.skipped_existing,
- "cars_upserted": stats.cars_upserted,
- "cars_failed": stats.cars_failed,
- "cars_filtered": stats.cars_filtered,
- "images_upserted": stats.images_upserted,
- "protection_events": stats.protection_events,
- "failures": failures,
- "all_listing_origin_urls": all_listing_origin_urls,
- "full_scan_completed": full_scan_completed and not anti_bot_detected,
- "anti_bot_detected": anti_bot_detected,
- "fail_ratio": round(fail_ratio, 4),
- "protection_ratio": round(protection_ratio, 4),
- "elapsed_seconds": round(time.perf_counter() - started_at, 3),
- }
-
- def _progress(self, stage: str, **meta: Any) -> None:
- if self.report_progress is None:
- return
- try:
- self.report_progress(stage, **meta)
- except Exception:
- pass
-
- def _fetch_detail_payload(self, inventory_id: str) -> dict[str, Any]:
- jitter = float(self.client._settings.scraping_profile.request_jitter_max_s)
- if jitter > 0:
- time.sleep(random.uniform(0.0, jitter))
- return self.client.fetch_vehicle_detail_payload(inventory_id)
-
- def _flush_db_records(
- self,
- *,
- rows: list[CarRecord],
- stats: FastSyncStats,
- errors: list[dict[str, str]],
- processed: int,
- total: int,
- ) -> int:
- if not rows:
- return 0
- batch = list(rows)
- try:
- upsert = self.persistence.upsert_cars_batch(batch)
- stats.cars_upserted += int(upsert.get("inserted", 0)) + int(upsert.get("updated", 0))
- stats.images_upserted += int(upsert.get("images_upserted", 0))
- except Exception as exc:
- stats.cars_failed += len(batch)
- errors.append({"vehicle_url": f"db_batch_{processed - len(batch)}", "error": str(exc)})
- logger.exception("Fast DB apply failed processed=%s size=%s: %s", processed, len(batch), exc)
- self._progress(
- "fast_db_progress",
- processed=processed,
- total=total,
- cars_upserted=stats.cars_upserted,
- cars_failed=stats.cars_failed,
- images_upserted=stats.images_upserted,
- )
- logger.info(
- "Fast HTTP-first DB batch: processed=%d/%d batch=%d upserted=%d failed=%d images=%d",
- processed,
- total,
- len(batch),
- stats.cars_upserted,
- stats.cars_failed,
- stats.images_upserted,
- )
- return len(batch)
-
-
-def _looks_like_protection(exc: Exception) -> bool:
- message = str(exc).lower()
- return any(token in message for token in ("captcha", "antibot", "challenge", "blocked", "403", "429", "incapsula"))
diff --git a/mobilede_scraper/mobile_de/client.py b/mobilede_scraper/mobile_de/client.py
index 9bacbc4..2f002d2 100644
--- a/mobilede_scraper/mobile_de/client.py
+++ b/mobilede_scraper/mobile_de/client.py
@@ -3,7 +3,6 @@ from __future__ import annotations
import logging
import os
import random
-import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from collections.abc import Callable, Iterable
@@ -34,6 +33,16 @@ MOBILEDE_HTTP_BACKOFF_BASE_SECONDS = max(0.0, float(os.getenv("MOBILEDE_HTTP_BAC
MOBILEDE_HTTP_BACKOFF_MAX_SECONDS = max(0.0, float(os.getenv("MOBILEDE_HTTP_BACKOFF_MAX_SECONDS", "20")))
MOBILEDE_HTTP_JITTER_SECONDS = max(0.0, float(os.getenv("MOBILEDE_HTTP_JITTER_SECONDS", "0.5")))
MOBILEDE_HTTP_RETRY_STATUSES = {403, 429, 500, 502, 503, 504}
+MOBILEDE_FLARESOLVERR_ENABLED = os.getenv("MOBILEDE_FLARESOLVERR_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
+MOBILEDE_FLARESOLVERR_URL = os.getenv("MOBILEDE_FLARESOLVERR_URL", "http://flaresolverr:8191/v1").strip()
+MOBILEDE_FLARESOLVERR_TIMEOUT_SECONDS = max(1.0, float(os.getenv("MOBILEDE_FLARESOLVERR_TIMEOUT_SECONDS", "120")))
+MOBILEDE_FLARESOLVERR_MAX_TIMEOUT_MS = max(1000, int(os.getenv("MOBILEDE_FLARESOLVERR_MAX_TIMEOUT_MS", "60000")))
+MOBILEDE_FLARESOLVERR_SESSION = os.getenv("MOBILEDE_FLARESOLVERR_SESSION", "").strip()
+MOBILEDE_FLARESOLVERR_STATUSES = {
+ int(item.strip())
+ for item in os.getenv("MOBILEDE_FLARESOLVERR_STATUSES", "403,429,503").split(",")
+ if item.strip().isdigit()
+}
class MobileDeClient:
@@ -43,11 +52,24 @@ class MobileDeClient:
self.session = session or requests.Session()
self.session.headers.update(DEFAULT_HEADERS)
self.delay_seconds = max(0.0, delay_seconds)
+ self.proxy_config = ProxyConfig()
+ proxies = self.proxy_config.to_requests_proxies()
+ if proxies:
+ self.session.proxies.update(proxies)
@staticmethod
def _is_retryable_status(status_code: int) -> bool:
return int(status_code) in MOBILEDE_HTTP_RETRY_STATUSES
+ @staticmethod
+ def _should_use_flaresolverr(status_code: int | None) -> bool:
+ return (
+ MOBILEDE_FLARESOLVERR_ENABLED
+ and bool(MOBILEDE_FLARESOLVERR_URL)
+ and status_code is not None
+ and int(status_code) in MOBILEDE_FLARESOLVERR_STATUSES
+ )
+
@staticmethod
def _compute_backoff(attempt: int) -> float:
base = MOBILEDE_HTTP_BACKOFF_BASE_SECONDS * (2 ** max(0, attempt - 1))
@@ -61,12 +83,10 @@ class MobileDeClient:
adapter = requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100, max_retries=0)
session.mount("https://", adapter)
session.mount("http://", adapter)
- proxy_cfg = ProxyConfig()
- proxies = proxy_cfg.to_requests_proxies()
- if proxies:
- session.proxies.update(proxies)
- logger.info("mobile.de worker HTTP client using proxy: %s", proxy_cfg.server)
- return cls(session=session, delay_seconds=delay_seconds)
+ client = cls(session=session, delay_seconds=delay_seconds)
+ if client.proxy_config.enabled:
+ logger.info("mobile.de worker HTTP client using proxy: %s", client.proxy_config.server)
+ return client
@staticmethod
def build_make_model_param(make_id: str | int, model_id: str | int | None = None) -> str:
@@ -118,19 +138,25 @@ class MobileDeClient:
for attempt in range(1, attempts + 1):
try:
response = self.session.get(url, timeout=timeout)
- if self._is_retryable_status(response.status_code) and attempt < attempts:
- sleep_seconds = self._compute_backoff(attempt)
- logger.warning(
- "mobile.de retryable status=%s attempt=%s/%s sleep=%.2fs url=%s",
- response.status_code,
- attempt,
- attempts,
- sleep_seconds,
- url,
- )
- if sleep_seconds:
- time.sleep(sleep_seconds)
- continue
+ if self._is_retryable_status(response.status_code):
+ if attempt < attempts:
+ sleep_seconds = self._compute_backoff(attempt)
+ logger.warning(
+ "mobile.de retryable status=%s attempt=%s/%s sleep=%.2fs url=%s",
+ response.status_code,
+ attempt,
+ attempts,
+ sleep_seconds,
+ url,
+ )
+ if sleep_seconds:
+ time.sleep(sleep_seconds)
+ continue
+ if self._should_use_flaresolverr(response.status_code):
+ try:
+ return self._fetch_html_with_flaresolverr(url)
+ except Exception as exc:
+ logger.warning("mobile.de FlareSolverr fallback failed url=%s error=%s", url, exc)
response.raise_for_status()
return response.text
except requests.RequestException as exc:
@@ -138,6 +164,11 @@ class MobileDeClient:
status_code = getattr(getattr(exc, "response", None), "status_code", None)
retryable = bool(status_code is not None and self._is_retryable_status(int(status_code)))
if attempt >= attempts or not retryable:
+ if self._should_use_flaresolverr(status_code):
+ try:
+ return self._fetch_html_with_flaresolverr(url)
+ except Exception as flaresolverr_exc:
+ logger.warning("mobile.de FlareSolverr fallback failed url=%s error=%s", url, flaresolverr_exc)
raise
sleep_seconds = self._compute_backoff(attempt)
logger.warning(
@@ -155,6 +186,56 @@ class MobileDeClient:
raise last_error
raise RuntimeError("mobile.de fetch_html failed without a captured exception")
+ def _fetch_html_with_flaresolverr(self, url: str) -> str:
+ payload: dict[str, object] = {
+ "cmd": "request.get",
+ "url": url,
+ "maxTimeout": MOBILEDE_FLARESOLVERR_MAX_TIMEOUT_MS,
+ }
+ if MOBILEDE_FLARESOLVERR_SESSION:
+ payload["session"] = MOBILEDE_FLARESOLVERR_SESSION
+
+ response = requests.post(
+ MOBILEDE_FLARESOLVERR_URL,
+ json=payload,
+ timeout=MOBILEDE_FLARESOLVERR_TIMEOUT_SECONDS,
+ )
+ response.raise_for_status()
+ data = response.json()
+ if data.get("status") != "ok":
+ raise RuntimeError(str(data.get("message") or data))
+
+ solution = data.get("solution")
+ if not isinstance(solution, dict):
+ raise RuntimeError("FlareSolverr response does not contain solution")
+
+ html = solution.get("response")
+ if not isinstance(html, str) or not html:
+ raise RuntimeError("FlareSolverr response does not contain HTML")
+
+ user_agent = solution.get("userAgent")
+ if isinstance(user_agent, str) and user_agent:
+ self.session.headers.update({"user-agent": user_agent})
+
+ cookies = solution.get("cookies")
+ if isinstance(cookies, list):
+ for cookie in cookies:
+ if not isinstance(cookie, dict):
+ continue
+ name = cookie.get("name")
+ value = cookie.get("value")
+ if not isinstance(name, str) or not isinstance(value, str):
+ continue
+ self.session.cookies.set(
+ name,
+ value,
+ domain=cookie.get("domain") if isinstance(cookie.get("domain"), str) else None,
+ path=cookie.get("path") if isinstance(cookie.get("path"), str) else "/",
+ )
+
+ logger.info("mobile.de fetched via FlareSolverr url=%s", url)
+ return html
+
def fetch_search_page(
self,
page_number: int = 1,
diff --git a/mobilede_scraper/mobile_de/mapper.py b/mobilede_scraper/mobile_de/mapper.py
index 7bb9b9c..b8a2a6f 100644
--- a/mobilede_scraper/mobile_de/mapper.py
+++ b/mobilede_scraper/mobile_de/mapper.py
@@ -1,4 +1,4 @@
-from __future__ import annotations
+from __future__ import annotations
import hashlib
import re
@@ -11,23 +11,27 @@ from .models import MobileDeListing
_BODY_MAP = {
"cabrio": "OPEN",
- "кабриолет": "OPEN",
+ "roadster": "OPEN",
+ "кабрио": "OPEN",
"limousine": "SEDAN",
- "седан": "SEDAN",
+ "sedan": "SEDAN",
+ "сeдан": "SEDAN",
"suv": "SUV",
- "внедорожник": "SUV",
+ "внедорож": "SUV",
"kombi": "STATION_WAGON",
+ "estate": "STATION_WAGON",
"универсал": "STATION_WAGON",
"van": "MINIVAN",
- "фургон": "MINIVAN",
+ "минивэн": "MINIVAN",
"coupe": "COUPE",
"купе": "COUPE",
+ "hatchback": "HATCHBACK",
"kleinwagen": "HATCHBACK",
- "маленький": "HATCHBACK",
}
_GEARBOX_MAP = {
"автомат": "AT",
+ "automatik": "AT",
"automatic": "AT",
"механ": "MT",
"manual": "MT",
@@ -36,26 +40,33 @@ _GEARBOX_MAP = {
_COLOR_MAP = {
"schwarz": "black",
- "черный": "black",
- "weiß": "white",
+ "черн": "black",
+ "black": "black",
"weiss": "white",
- "белый": "white",
- "серый": "gray",
+ "weiß": "white",
+ "бел": "white",
+ "white": "white",
"grau": "gray",
+ "сер": "gray",
+ "gray": "gray",
"silber": "silver",
"сереб": "silver",
+ "silver": "silver",
"rot": "red",
- "красный": "red",
+ "красн": "red",
+ "red": "red",
"blau": "blue",
- "синий": "blue",
+ "син": "blue",
+ "blue": "blue",
"grün": "green",
"gruen": "green",
- "зеленый": "green",
+ "зелен": "green",
+ "green": "green",
}
class MobileDeMapper:
- """Map mobile.de search/detail payloads into the existing CarRecord schema."""
+ """Map mobile.de payloads into CarRecord."""
def listing_to_car_record(self, listing: MobileDeListing) -> CarRecord:
raw = listing.raw or {}
@@ -76,7 +87,7 @@ class MobileDeMapper:
price=self._money_to_int(listing.price or raw.get("p")),
currency="EUR",
mileage=self._int_from_text(listing.mileage or attr.get("ml")) or 0,
- country="NA",
+ country=self._normalize_country("DE"),
is_sold=False,
color=self._normalize_color(attr.get("ecol")),
drive=None,
@@ -102,17 +113,64 @@ class MobileDeMapper:
)
def detail_to_car_record(self, listing_id: str, detail: dict[str, Any]) -> CarRecord:
- title = self._text(detail.get("shortTitle") or detail.get("make") or "UNKNOWN")
+ attrs = self._detail_attrs_by_tag(detail.get("attributes"))
+ make = detail.get("make") if isinstance(detail.get("make"), dict) else {}
+ model_payload = detail.get("model") if isinstance(detail.get("model"), dict) else {}
+ contact = detail.get("contact") if isinstance(detail.get("contact"), dict) else {}
+
+ short_title = self._text(detail.get("shortTitle") or make.get("localized") or "UNKNOWN")
subtitle = self._text(detail.get("subTitle"))
- fake_listing = MobileDeListing(
- id=str(listing_id),
- url=MobileDeClient.build_detail_url(listing_id),
- title=title,
- subtitle=subtitle,
- price=self._text(detail.get("price") or detail.get("p")),
- raw=detail,
+ title = " ".join(part for part in [short_title, subtitle] if part)
+
+ brand = self._text(make.get("localized") or self._brand_from_title(short_title) or "UNKNOWN")
+ model = self._text(model_payload.get("localized") or self._model_from_title(short_title, brand) or subtitle or "UNKNOWN")
+ origin_id = self.origin_id(str(listing_id))
+
+ price_amount, price_currency = self._detail_price(detail.get("price"))
+ mileage = self._int_from_text(attrs.get("mileage")) or 0
+ year = self._year_from_first_registration(attrs.get("firstRegistration"))
+ gearbox = self._normalize_gearbox(attrs.get("transmission"))
+ body_type = self._normalize_body(attrs.get("category") or detail.get("category"))
+ color = self._normalize_color(attrs.get("color") or attrs.get("manufacturerColorName"))
+
+ damage_text = self._text(attrs.get("damageCondition")).lower()
+ is_damaged = ("дтп" in damage_text and "без дтп" not in damage_text) or bool(detail.get("hasDamage"))
+
+ owners_text = self._text(attrs.get("numPreviousOwners"))
+ one_owner = owners_text in {"1", "01", "1.0"}
+
+ return CarRecord(
+ parser_id=self._parser_id(origin_id),
+ brand=brand[:50] or "UNKNOWN",
+ model=model[:50] or "UNKNOWN",
+ year=year,
+ price=price_amount,
+ currency=price_currency or "EUR",
+ mileage=mileage,
+ country=self._normalize_country(contact.get("countryCode") or contact.get("country") or "DE"),
+ is_sold=False,
+ color=color,
+ drive=self._normalize_drive(attrs.get("wheelDrive") or attrs.get("drivetrain")),
+ gearbox=gearbox,
+ steering_wheel="LEFT",
+ body_type=body_type,
+ engine_volume=self._int_from_text(attrs.get("cubicCapacity") or attrs.get("cc")),
+ selling_type="CLASSIFIED",
+ one_owner=one_owner,
+ new_car=bool(detail.get("isNew") or detail.get("isConditionNew")),
+ is_hidden=False,
+ origin="MOBILE_DE",
+ origin_url=MobileDeClient.build_detail_url(listing_id),
+ origin_id=origin_id,
+ is_damaged=is_damaged,
+ evaluation=self._text(detail.get("priceRating") or detail.get("rating")) or None,
+ non_smoking=True,
+ rental=False,
+ repair_history=is_damaged,
+ slug=self._slugify(title or f"{brand} {model}"),
+ last_seen_at=datetime.now(timezone.utc),
+ images=self._images_from_listing(detail),
)
- return self.listing_to_car_record(fake_listing)
@staticmethod
def origin_id(listing_id: str) -> str:
@@ -129,6 +187,9 @@ class MobileDeMapper:
@classmethod
def _money_to_int(cls, value: Any) -> int | None:
+ if isinstance(value, dict):
+ amount = ((value.get("grs") or {}).get("amount") if isinstance(value.get("grs"), dict) else None) or value.get("amount")
+ return cls._int_from_text(amount)
return cls._int_from_text(value)
@staticmethod
@@ -167,6 +228,32 @@ class MobileDeMapper:
return mapped
return None
+ @staticmethod
+ def _normalize_drive(value: Any) -> str | None:
+ text = "" if value is None else str(value).lower()
+ if any(marker in text for marker in ("front", "fwd", "перед")):
+ return "FWD"
+ if any(marker in text for marker in ("rear", "rwd", "зад")):
+ return "RWD"
+ if any(marker in text for marker in ("all", "awd", "4x4", "quattro", "полный")):
+ return "4WD"
+ return None
+
+ @staticmethod
+ def _normalize_country(value: Any) -> str:
+ text = "" if value is None else str(value).strip().upper()
+ if text in {"DE", "GERMANY", "DEUTSCHLAND"}:
+ return "DE"
+ if text in {"US", "USA", "UNITED STATES"}:
+ return "US"
+ if text in {"CA", "CANADA"}:
+ return "CA"
+ if text in {"JP", "JAPAN"}:
+ return "JP"
+ if text in {"KR", "KOREA", "SOUTH KOREA"}:
+ return "KR"
+ return "NA"
+
@staticmethod
def _normalize_body(value: Any) -> str:
text = "" if value is None else str(value).lower()
@@ -188,6 +275,33 @@ class MobileDeMapper:
slug = re.sub(r"[^a-zA-Z0-9а-яА-ЯёЁ]+", "-", value.lower()).strip("-")
return slug[:180] or "mobilede-car"
+ @staticmethod
+ def _detail_attrs_by_tag(value: Any) -> dict[str, str]:
+ result: dict[str, str] = {}
+ if not isinstance(value, list):
+ return result
+ for item in value:
+ if not isinstance(item, dict):
+ continue
+ tag = str(item.get("tag") or "").strip()
+ val = str(item.get("value") or "").strip()
+ if tag and val and tag not in result:
+ result[tag] = val
+ return result
+
+ @staticmethod
+ def _detail_price(value: Any) -> tuple[int | None, str | None]:
+ if not isinstance(value, dict):
+ return None, None
+ grs = value.get("grs") if isinstance(value.get("grs"), dict) else {}
+ amount = grs.get("amount") if isinstance(grs, dict) else None
+ currency = grs.get("currency") if isinstance(grs, dict) else None
+ if amount is None:
+ amount = value.get("amount")
+ if currency is None:
+ currency = value.get("currency")
+ return MobileDeMapper._int_from_text(amount), (str(currency).strip() if currency else None)
+
@staticmethod
def _images_from_listing(raw: dict[str, Any]) -> list[ImageRecord]:
urls: list[str] = []
@@ -203,6 +317,13 @@ class MobileDeMapper:
src = item.get("src") or item.get("url") or item.get("uri")
if src:
urls.append(MobileDeMapper._normalize_image_url(str(src)))
+ media_gallery = raw.get("mediaGallery")
+ if isinstance(media_gallery, list):
+ for item in media_gallery:
+ if isinstance(item, dict):
+ src = item.get("uri") or item.get("url")
+ if src:
+ urls.append(MobileDeMapper._normalize_image_url(str(src)))
return [
ImageRecord(fullres_image=url, preview_image=url, order_index=index)
for index, url in enumerate(dict.fromkeys(url for url in urls if url))
diff --git a/mobilede_scraper/mobile_de/scraper.py b/mobilede_scraper/mobile_de/scraper.py
index dde5517..35c2146 100644
--- a/mobilede_scraper/mobile_de/scraper.py
+++ b/mobilede_scraper/mobile_de/scraper.py
@@ -314,9 +314,7 @@ class MobileDeScraper:
max_pages,
)
try:
- data = self.collect_search(
- start_page=start_page,
- max_pages=max_pages,
+ params = self._build_search_params(
search_url=search_url,
make_id=make_id,
model_id=model_id,
@@ -328,96 +326,121 @@ class MobileDeScraper:
mileage_max=mileage_max,
sort_by=sort_by,
sort_order=sort_order,
- progress_callback=progress_callback,
)
- pages_payload = list(data.get("pages", []))
- listing_count = int(data.get("listing_count", 0) or 0)
- unique_ids = set(data.get("unique_listing_ids", []))
- pages_collected = len(pages_payload)
+ def _on_page(page, meta: dict[str, int | None]) -> None:
+ if progress_callback is None:
+ return
+ payload = {
+ **meta,
+ "page_url": page.url,
+ "unique_ids_seen": len(unique_ids) + len({listing.id for listing in page.listings if listing.id}),
+ }
+ progress_callback("page_collected", payload)
- records: list[CarRecord] = []
- for page in pages_payload:
- page_records = [self.mapper.listing_to_car_record(MobileDeListing(**item)) for item in page.get("listings", [])]
- records.extend(self._dedupe_page_records(page_records, seen_record_keys))
+ pages_collected = 0
+ early_stopped = False
+ for page in self.client.iter_search_pages(
+ start_page=start_page,
+ max_pages=max_pages,
+ search_url=search_url,
+ progress_callback=_on_page,
+ **params,
+ ):
+ pages_collected += 1
+ pages_payload.append(asdict(page))
+ listing_count += len(page.listings)
+ unique_ids.update(str(listing.id) for listing in page.listings if listing.id)
- if only_new and records:
- existing_origin_ids = self.persistence.get_existing_origin_ids(
- [record.origin_id for record in records if record.origin_id]
- )
- should_cut_tail = self._should_cut_only_new_tail(sort_by, sort_order)
- if should_cut_tail:
- filtered_records: list[CarRecord] = []
- for record_index, record in enumerate(records):
- is_existing = bool(record.origin_id and record.origin_id in existing_origin_ids)
- filtered_records.append(record)
- if is_existing:
- existing_streak += 1
- if (
- existing_streak >= MOBILEDE_ONLY_NEW_STOP_ON_EXISTING_STREAK
- and new_records_kept >= MOBILEDE_ONLY_NEW_MIN_NEW_RECORDS
- ):
- head_cut_triggered = True
- skipped_existing += max(0, len(records) - record_index - 1)
- break
- else:
- existing_streak = 0
- new_records_kept += 1
- records = filtered_records
-
- if progress_callback is not None:
- progress_callback(
- "records_mapped",
- {
- "record_count": len(records),
- "skipped_existing": skipped_existing,
- "only_new": bool(only_new),
- "run_id": run_id,
- "pages_collected": pages_collected,
- },
+ page_records = [self.mapper.listing_to_car_record(listing) for listing in page.listings]
+ page_records = self._dedupe_page_records(page_records, seen_record_keys)
+ page_records, skipped_existing, existing_streak, new_records_kept, head_cut_triggered = (
+ self._apply_only_new_page_policy(
+ page_records=page_records,
+ only_new=only_new,
+ sort_by=sort_by,
+ sort_order=sort_order,
+ skipped_existing=skipped_existing,
+ existing_streak=existing_streak,
+ new_records_kept=new_records_kept,
+ )
)
- upsert = self.persistence.upsert_cars_batch(records) if records else {
- "inserted": 0,
- "updated": 0,
- "images_upserted": 0,
+ if progress_callback is not None:
+ progress_callback(
+ "records_mapped",
+ {
+ "record_count": len(page_records),
+ "skipped_existing": skipped_existing,
+ "only_new": bool(only_new),
+ "run_id": run_id,
+ "pages_collected": pages_collected,
+ "page_number": page.page_number,
+ },
+ )
+
+ upsert = self.persistence.upsert_cars_batch(page_records) if page_records else {
+ "inserted": 0,
+ "updated": 0,
+ "images_upserted": 0,
+ }
+ page_inserted = int(upsert.get("inserted", 0))
+ page_updated = int(upsert.get("updated", 0))
+ page_images = int(upsert.get("images_upserted", 0))
+
+ ids_fetched += len(page_records)
+ inserted_total += page_inserted
+ updated_total += page_updated
+ images_upserted += page_images
+ cars_upserted = inserted_total + updated_total
+
+ logger.debug(
+ "mobile.de sync_search page upsert: run_id=%s page=%s inserted=%s updated=%s images=%s",
+ run_id,
+ page.page_number,
+ page_inserted,
+ page_updated,
+ page_images,
+ )
+ if progress_callback is not None:
+ progress_callback(
+ "db_upsert_done",
+ {
+ "run_id": run_id,
+ "pages_collected": pages_collected,
+ "pages_in_batch": 1,
+ "page_number": page.page_number,
+ "inserted": page_inserted,
+ "updated": page_updated,
+ "images_upserted": page_images,
+ },
+ )
+
+ if head_cut_triggered:
+ early_stopped = True
+ break
+
+ data = {
+ "source": "mobile.de",
+ "strategy_note": MOBILEDE_SEARCH_STRATEGY_NOTE,
+ "search_url": search_url,
+ "pages": pages_payload,
+ "listing_count": listing_count,
+ "unique_listing_count": len(unique_ids),
+ "unique_listing_ids": sorted(unique_ids),
+ "early_stopped": early_stopped,
}
- ids_fetched = len(records)
- inserted_total = int(upsert.get("inserted", 0))
- updated_total = int(upsert.get("updated", 0))
- images_upserted = int(upsert.get("images_upserted", 0))
- cars_upserted = inserted_total + updated_total
- logger.debug(
- "mobile.de sync_search batch upsert: run_id=%s pages=%s inserted=%s updated=%s images=%s",
- run_id,
- pages_collected,
- inserted_total,
- updated_total,
- images_upserted,
- )
if progress_callback is not None:
progress_callback(
- "db_upsert_done",
+ "search_collection_done",
{
- "run_id": run_id,
"pages_collected": pages_collected,
- "pages_in_batch": pages_collected,
- "inserted": inserted_total,
- "updated": updated_total,
- "images_upserted": images_upserted,
+ "listing_count": listing_count,
+ "unique_listing_count": len(unique_ids),
},
)
- if head_cut_triggered:
- logger.info(
- "mobile.de only_new head-cut applied: kept=%s skipped_existing=%s streak=%s",
- len(records),
- skipped_existing,
- MOBILEDE_ONLY_NEW_STOP_ON_EXISTING_STREAK,
- )
-
- data["early_stopped"] = head_cut_triggered
self.persistence.finish_sync_run(
run_id,
status="success",
@@ -432,7 +455,16 @@ class MobileDeScraper:
data.get("listing_count", 0),
data.get("unique_listing_count", 0),
)
- return {"run_id": run_id, "upsert": upsert, "skipped_existing": skipped_existing, **data}
+ return {
+ "run_id": run_id,
+ "upsert": {
+ "inserted": inserted_total,
+ "updated": updated_total,
+ "images_upserted": images_upserted,
+ },
+ "skipped_existing": skipped_existing,
+ **data,
+ }
except Exception as exc:
self.persistence.finish_sync_run(
run_id,
diff --git a/mobilede_scraper/parsing/__init__.py b/mobilede_scraper/parsing/__init__.py
deleted file mode 100644
index c9c2ef6..0000000
--- a/mobilede_scraper/parsing/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-__all__: list[str] = []
diff --git a/mobilede_scraper/parsing/fast_mapper.py b/mobilede_scraper/parsing/fast_mapper.py
deleted file mode 100644
index b2e02ee..0000000
--- a/mobilede_scraper/parsing/fast_mapper.py
+++ /dev/null
@@ -1,515 +0,0 @@
-from __future__ import annotations
-
-import re
-from datetime import datetime, timezone
-from typing import Any
-from urllib.parse import urlparse
-
-from ..browser.fast_client import FastListingVehicle, build_resizer_images_from_keys, parse_int, parse_text
-from ..storage.enums import BODY_TYPE_ENUM_VALUES, DRIVE_ENUM_VALUES, GEARBOX_ENUM_VALUES
-from ..storage.schemas import CarRecord, ImageRecord
-
-ORIGIN_PREFIX = "mobilede:"
-ORIGIN_URL_BASE = "https://www.MOBILEDE.com/VehicleDetail"
-COLOR_ALIASES = {
- "grau": "gray",
- "grau metallic": "gray",
- "gray": "gray",
- "grey": "gray",
- "silber": "silver",
- "silver": "silver",
- "schwarz": "black",
- "black": "black",
- "weiss": "white",
- "weiß": "white",
- "white": "white",
- "blau": "blue",
- "blue": "blue",
- "rot": "red",
- "red": "red",
- "gruen": "green",
- "grün": "green",
- "green": "green",
- "braun": "brown",
- "brown": "brown",
- "beige": "beige",
- "orange": "orange",
- "gelb": "yellow",
- "yellow": "yellow",
- "violett": "purple",
- "lila": "purple",
- "purple": "purple",
- "gold": "gold",
-}
-DAMAGE_NEUTRAL_VALUES = {
- "NORMAL WEAR & TEAR",
- "NORMAL WEAR",
- "NORMALWEAR&TEAR",
- "NONE",
- "NO DAMAGE",
- "NO VISIBLE DAMAGE",
- "MINOR DENT/SCRATCHES",
-}
-INACTIVE_STATUS_VALUES = {"SOLD", "SO", "CLOSED", "CN", "DELIVERED", "WITHDRAWN", "WDR", "COMPLETE", "COMPLETED"}
-
-
-class FastCarMapper:
- """Maps MOBILEDE ProductDetailsVM payloads directly to project CarRecord."""
-
- def map_payload_to_record(
- self,
- *,
- detail_payload: dict[str, Any],
- vehicle_url: str | None = None,
- listing_vehicle: FastListingVehicle | None = None,
- ) -> CarRecord:
- inventory_view = detail_payload.get("inventoryView")
- if not isinstance(inventory_view, dict):
- raise RuntimeError("detail payload missing inventoryView")
- attributes = inventory_view.get("attributes")
- if not isinstance(attributes, dict):
- raise RuntimeError("detail payload missing inventoryView.attributes")
-
- inventory_id = parse_text(attributes.get("Id"))
- if not inventory_id and listing_vehicle is not None:
- inventory_id = listing_vehicle.inventory_id
- if not inventory_id and vehicle_url:
- inventory_id = self._inventory_id_from_url(vehicle_url)
- if not inventory_id:
- raise RuntimeError("missing inventory id")
-
- brand = self._limit_text(self._attr_text(attributes, "Make") or "UNKNOWN", 50)
- model = self._build_model_name(
- self._attr_text(attributes, "Model"),
- self._attr_text(attributes, "Series", "Trim", "Variant"),
- ) or "UNKNOWN"
- model = self._limit_text(model, 50)
- year = self._parse_year(
- self._attr_text(
- attributes,
- "Year",
- "FirstRegistration",
- "InitialRegistration",
- "Erstzulassung",
- "ModelYear",
- )
- )
- descriptor_text = " ".join(
- filter(
- None,
- [
- brand,
- model,
- self._attr_text(attributes, "BodyStyleName", "VehicleClass", "Category", "CategoryDescription"),
- self._attr_text(attributes, "DriveLineTypeDesc", "DriveType", "DriveTrain", "DriveTrainType", "DriveDescription"),
- self._attr_text(attributes, "Transmission", "Gearbox", "TransmissionType"),
- ],
- )
- )
-
- auction_info = detail_payload.get("auctionInformation")
- auction_info = auction_info if isinstance(auction_info, dict) else {}
- bidding_info = auction_info.get("biddingInformation")
- bidding_info = bidding_info if isinstance(bidding_info, dict) else {}
- prebid_info = auction_info.get("prebidInformation")
- prebid_info = prebid_info if isinstance(prebid_info, dict) else {}
-
- high_bid = self._first_positive_int(
- prebid_info.get("decimalHighBidAmount"),
- prebid_info.get("highBidAmount"),
- bidding_info.get("highBidAmount"),
- )
- buy_now = self._first_positive_int(
- bidding_info.get("buyNowAmount"),
- prebid_info.get("buyNowPrice"),
- bidding_info.get("buyNowPrice"),
- )
- price = high_bid if high_bid is not None else buy_now
-
- image_dimensions = inventory_view.get("imageDimensions")
- image_dimensions = image_dimensions if isinstance(image_dimensions, dict) else {}
- keys_container = image_dimensions.get("keys")
- keys_container = keys_container if isinstance(keys_container, dict) else {}
- image_keys = keys_container.get("$values")
- image_keys = image_keys if isinstance(image_keys, list) else []
- images = [ImageRecord.model_validate(row) for row in build_resizer_images_from_keys(image_keys)]
-
- primary_damage = self._attr_text(attributes, "PrimaryDamageDesc")
- secondary_damage = self._attr_text(attributes, "SecondaryDamageDesc")
- origin_url = vehicle_url or f"{ORIGIN_URL_BASE}/{inventory_id}"
- normalized_origin_id = self._normalize_origin_inventory_id(inventory_id)
- origin_id = f"{ORIGIN_PREFIX}{normalized_origin_id}"
- drive_source = self._attr_text(
- attributes,
- "DriveLineTypeDesc",
- "DriveType",
- "DriveTrain",
- "DriveTrainType",
- "DriveDescription",
- "Antrieb",
- ) or descriptor_text
- gearbox_source = self._attr_text(attributes, "Transmission", "Gearbox", "TransmissionType") or descriptor_text
- body_type_source = self._attr_text(
- attributes,
- "BodyStyleName",
- "VehicleClass",
- "Category",
- "CategoryDescription",
- "BodyType",
- ) or descriptor_text
- engine_source = self._attr_text(
- attributes,
- "EngineSize",
- "EngineInformation",
- "EngineDisplacement",
- "Displacement",
- "CubicCapacity",
- )
- color_source = self._attr_text(attributes, "ExteriorColor", "ColorDesc", "Color")
-
- return CarRecord(
- parser_id=self._generate_parser_id(origin_id),
- brand=brand,
- model=model,
- year=year,
- price=price,
- currency=self._map_currency(self._attr_text(attributes, "Currency") or (listing_vehicle.currency if listing_vehicle else None)),
- mileage=self._parse_non_negative_int(self._attr_text(attributes, "ODOValue", "Mileage", "Kilometerstand", "Odometer")) or 0,
- country=self._map_country(inventory_id, tenant=listing_vehicle.tenant if listing_vehicle else None),
- is_sold=self._is_sold(listing_vehicle),
- color=self._normalize_color(color_source),
- drive=self._map_drive(drive_source),
- gearbox=self._map_gearbox(gearbox_source),
- steering_wheel="LEFT",
- body_type=self._map_body_type(body_type_source),
- engine_volume=self._parse_engine_volume(engine_source),
- selling_type="AUCTION",
- one_owner=False,
- new_car=False,
- is_hidden=not bool(images),
- origin="MOBILEDE",
- origin_url=origin_url,
- origin_id=origin_id,
- is_damaged=self._derive_is_damaged(primary_damage=primary_damage, secondary_damage=secondary_damage),
- evaluation=self._attr_text(attributes, "VehicleGrade", "PriceRating"),
- non_smoking=True,
- rental=False,
- repair_history=False,
- slug=self._slugify(" ".join(filter(None, [brand, model, str(year or "")]))),
- last_seen_at=datetime.now(timezone.utc),
- images=images,
- )
-
- def payload_to_summary(self, detail_payload: dict[str, Any], vehicle_url: str) -> dict[str, Any]:
- inventory_view = detail_payload.get("inventoryView") if isinstance(detail_payload, dict) else {}
- inventory_view = inventory_view if isinstance(inventory_view, dict) else {}
- attr = inventory_view.get("attributes")
- attr = attr if isinstance(attr, dict) else {}
- auction_info = detail_payload.get("auctionInformation") if isinstance(detail_payload, dict) else {}
- auction_info = auction_info if isinstance(auction_info, dict) else {}
- bidding_info = auction_info.get("biddingInformation")
- bidding_info = bidding_info if isinstance(bidding_info, dict) else {}
- prebid_info = auction_info.get("prebidInformation")
- prebid_info = prebid_info if isinstance(prebid_info, dict) else {}
- image_dimensions = inventory_view.get("imageDimensions")
- image_dimensions = image_dimensions if isinstance(image_dimensions, dict) else {}
- keys_container = image_dimensions.get("keys")
- keys_container = keys_container if isinstance(keys_container, dict) else {}
- image_keys = keys_container.get("$values")
- image_keys = image_keys if isinstance(image_keys, list) else []
- image_urls = [str(row["fullres_image"]) for row in build_resizer_images_from_keys(image_keys)]
- engine_text = self._attr_text(attr, "EngineInformation", "EngineSize", "EngineDisplacement", "Displacement")
- return {
- "source_url": vehicle_url,
- "lot_number": attr.get("Id") or attr.get("StockNumber") or attr.get("SalvageId"),
- "year": attr.get("Year") or attr.get("FirstRegistration") or attr.get("Erstzulassung"),
- "make": attr.get("Make"),
- "model": attr.get("Model"),
- "trim": attr.get("Series"),
- "body_type": attr.get("BodyStyleName") or attr.get("VehicleClass") or attr.get("Category"),
- "drive": attr.get("DriveLineTypeDesc") or attr.get("DriveType") or attr.get("DriveTrain"),
- "engine": engine_text,
- "fuel_type": attr.get("FuelTypeCode"),
- "gearbox": attr.get("Transmission") or attr.get("Gearbox") or attr.get("TransmissionType"),
- "color": attr.get("ExteriorColor") or attr.get("ColorDesc") or attr.get("Color"),
- "primary_damage": attr.get("PrimaryDamageDesc"),
- "secondary_damage": attr.get("SecondaryDamageDesc"),
- "odometer": attr.get("ODOValue") or attr.get("Mileage") or attr.get("Kilometerstand"),
- "location": attr.get("BranchName"),
- "auction_date": attr.get("AuctionDateTime"),
- "title": attr.get("Title"),
- "current_bid": prebid_info.get("highBidAmount") or bidding_info.get("highBidAmount"),
- "buy_now": prebid_info.get("buyNowPrice") or bidding_info.get("buyNowPrice"),
- "actual_cash_value": attr.get("ProviderACV"),
- "estimated_repair_cost": attr.get("EstRepairCost"),
- "image_urls": image_urls,
- }
-
- @staticmethod
- def _inventory_id_from_url(vehicle_url: str) -> str | None:
- tail = urlparse(vehicle_url).path.rstrip("/").split("/")[-1]
- return tail or None
-
- @staticmethod
- def _normalize_origin_inventory_id(inventory_id: str) -> str:
- return inventory_id.strip().split("~", 1)[0]
-
- @staticmethod
- def _build_model_name(model: str | None, series: str | None) -> str:
- unique_parts: list[str] = []
- seen: set[str] = set()
- for value in (model, series):
- if not value:
- continue
- normalized = " ".join(value.split())
- key = normalized.casefold()
- if key in seen:
- continue
- seen.add(key)
- unique_parts.append(normalized)
- return " ".join(unique_parts).strip()
-
- @staticmethod
- def _parse_year(value: Any) -> int | None:
- if isinstance(value, str):
- match = re.search(r"(19|20)\d{2}", value)
- if match is not None:
- parsed = int(match.group(0))
- if 1900 <= parsed <= 2100:
- return parsed
- parsed = parse_int(value)
- if parsed is None or parsed < 1900 or parsed > 2100:
- return None
- return parsed
-
- @staticmethod
- def _parse_non_negative_int(value: Any) -> int | None:
- if isinstance(value, str):
- text = value.strip()
- if not text:
- return None
- grouped_match = re.search(r"\d{1,3}(?:[.,\s]\d{3})+(?!\d)", text)
- if grouped_match is not None:
- digits_only = re.sub(r"\D", "", grouped_match.group(0))
- return int(digits_only) if digits_only else None
- if re.fullmatch(r"\d{1,3}(?:[.,\s]\d{3})+", text):
- digits_only = re.sub(r"\D", "", text)
- return int(digits_only) if digits_only else None
- parsed = parse_int(value)
- if parsed is None or parsed < 0:
- return None
- return parsed
-
- @staticmethod
- def _attr_text(attributes: dict[str, Any], *keys: str) -> str | None:
- for key in keys:
- value = parse_text(attributes.get(key))
- if value:
- return value
- return None
-
- @classmethod
- def _first_positive_int(cls, *values: Any) -> int | None:
- for value in values:
- parsed = cls._parse_non_negative_int(value)
- if parsed is not None and parsed > 0:
- return parsed
- return None
-
- @staticmethod
- def _normalize_color(value: str | None) -> str:
- if not value:
- return "other"
- normalized = FastCarMapper._simplify_text(value)
- if "/" in normalized:
- normalized = normalized.split("/", 1)[0].strip()
- for alias, mapped in COLOR_ALIASES.items():
- if alias in normalized:
- return mapped
- return normalized or "other"
-
- @staticmethod
- def _map_currency(value: str | None) -> str:
- normalized = (value or "USD").strip().upper()
- return normalized if normalized in {"USD", "CAD", "EUR", "JPY", "RUB", "KRW", "AED", "GBP"} else "USD"
-
- @staticmethod
- def _map_country(inventory_id: str, tenant: str | None = None) -> str:
- upper_id = inventory_id.strip().upper()
- if upper_id.endswith("~CA"):
- return "CA"
- if upper_id.endswith("~US"):
- return "US"
- tenant_normalized = (tenant or "").strip().upper()
- if tenant_normalized in {"US", "CA", "JP", "KR"}:
- return tenant_normalized
- return "NA"
-
- @staticmethod
- def _map_drive(value: str | None) -> str | None:
- if not value:
- return None
- normalized = FastCarMapper._simplify_text(value)
- candidates: tuple[str, ...] | None = None
- if any(marker in normalized for marker in ("front wheel", "front-wheel", "frontantrieb", "fwd", "traction avant")):
- candidates = ("FWD",)
- elif any(
- marker in normalized
- for marker in (
- "all wheel",
- "all-wheel",
- "4x4",
- "awd",
- "four wheel",
- "allrad",
- "4matic",
- "4motion",
- "quattro",
- "xdrive",
- )
- ):
- candidates = ("4WD", "FOUR_WD")
- elif any(marker in normalized for marker in ("rear wheel", "rear-wheel", "heckantrieb", "rwd", "propulsion")):
- candidates = ("RWD",)
- elif "2wd" in normalized or "two wheel" in normalized:
- candidates = ("2WD", "TWO_WD")
- elif "unknown" in normalized or normalized in {"na", "n/a"}:
- candidates = ("NA",)
- return FastCarMapper._select_allowed(candidates, DRIVE_ENUM_VALUES) if candidates else None
-
- @staticmethod
- def _map_gearbox(value: str | None) -> str | None:
- if not value:
- return None
- normalized = FastCarMapper._simplify_text(value)
- candidates: tuple[str, ...] | None = None
- if "cvt" in normalized:
- candidates = ("CVT",)
- elif any(marker in normalized for marker in ("manual", "mt", "schaltgetriebe", "schaltung", "stick shift")):
- candidates = ("MT",)
- elif "electric" in normalized or normalized == "ev":
- candidates = ("EV",)
- elif any(marker in normalized for marker in ("auto", "at", "automatik", "automatic", "dsg", "doppelkupplung", "semi automatic", "halbautomatik")):
- candidates = ("AT",)
- elif "unknown" in normalized or normalized in {"na", "n/a"}:
- candidates = ("NA",)
- return FastCarMapper._select_allowed(candidates, GEARBOX_ENUM_VALUES) if candidates else None
-
- @staticmethod
- def _map_body_type(value: str | None) -> str:
- if not value:
- return "OTHER"
- normalized = FastCarMapper._simplify_text(value)
- candidates: tuple[str, ...] | None = None
- if any(marker in normalized for marker in ("sedan", "limousine", "saloon")):
- candidates = ("SEDAN",)
- elif any(marker in normalized for marker in ("sport utility", "suv", "crossover", "gelandewagen", "gelaendewagen", "off-road")):
- candidates = ("SUV",)
- elif any(marker in normalized for marker in ("hatch", "kleinwagen", "compact", "city car")):
- candidates = ("HATCHBACK",)
- elif any(marker in normalized for marker in ("wagon", "kombi", "estate", "touring", "variant", "avant", "shooting brake")):
- candidates = ("STATION_WAGON", "Station Wagon")
- elif "coupe" in normalized:
- candidates = ("COUPE",)
- elif "pickup" in normalized or ("crew" in normalized and "cab" in normalized):
- candidates = ("PICKUP", "Pickup")
- elif any(marker in normalized for marker in ("convertible", "roadster", "cabrio", "cabriolet", "spyder")):
- candidates = ("OPEN", "Open")
- elif any(marker in normalized for marker in ("van", "bus", "people mover", "mpv", "minivan", "tourer")):
- candidates = ("MINIVAN",)
- elif any(marker in normalized for marker in ("truck", "chassis", "pritsche")):
- candidates = ("TRUCK", "Truck")
- elif "rv" in normalized or "motorized" in normalized:
- candidates = ("RV",)
- elif normalized in {"other", "unknown"}:
- candidates = ("OTHER", "Other")
- return FastCarMapper._select_allowed(candidates, BODY_TYPE_ENUM_VALUES, fallback="OTHER") or "OTHER"
-
- @staticmethod
- def _parse_engine_volume(value: str | None) -> int | None:
- if not value:
- return None
- normalized = value.strip().replace(",", ".")
- match = re.search(r"(\d+(?:\.\d+)?)\s*[lL]\b", normalized)
- if not match:
- cc_match = re.search(r"(\d{3,5})\s*(?:ccm|cm3|cm³|cc)\b", normalized, flags=re.IGNORECASE)
- if cc_match is not None:
- cc = int(cc_match.group(1))
- return cc if 0 < cc <= 10000 else None
- raw_digits = re.fullmatch(r"\s*(\d{3,5})\s*", normalized)
- if raw_digits is not None:
- cc = int(raw_digits.group(1))
- return cc if 0 < cc <= 10000 else None
- return None
- try:
- liters = float(match.group(1))
- except ValueError:
- return None
- cc = int(round(liters * 1000))
- if cc <= 0 or cc > 10000:
- return None
- return cc
-
- @staticmethod
- def _derive_is_damaged(*, primary_damage: str | None, secondary_damage: str | None) -> bool:
- neutral = {item.replace(" ", "").strip().upper() for item in DAMAGE_NEUTRAL_VALUES}
- for value in (primary_damage, secondary_damage):
- if not value:
- continue
- normalized = value.replace(" ", "").strip().upper()
- if normalized and normalized not in neutral:
- return True
- return False
-
- @staticmethod
- def _is_sold(listing_vehicle: FastListingVehicle | None) -> bool:
- if listing_vehicle is None:
- return False
- if listing_vehicle.timed_auction_closed:
- return True
- status = (listing_vehicle.inventory_status or "").strip().upper()
- return status in INACTIVE_STATUS_VALUES
-
- @staticmethod
- def _select_allowed(candidates: tuple[str, ...] | None, allowed: tuple[str, ...], fallback: str | None = None) -> str | None:
- if not candidates:
- return fallback if fallback in allowed else None
- allowed_set = set(allowed)
- for candidate in candidates:
- if candidate in allowed_set:
- return candidate
- return fallback if fallback in allowed_set else None
-
- @staticmethod
- def _limit_text(value: str, max_length: int) -> str:
- return value if len(value) <= max_length else value[:max_length].rstrip()
-
- @staticmethod
- def _simplify_text(value: str) -> str:
- return (
- value.strip()
- .lower()
- .replace("ä", "ae")
- .replace("ö", "oe")
- .replace("ü", "ue")
- .replace("ß", "ss")
- )
-
- @staticmethod
- def _slugify(value: str) -> str:
- return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "car"
-
- @staticmethod
- def _generate_parser_id(origin_id: str) -> str:
- import hashlib
- from string import ascii_letters, digits
-
- digest = hashlib.sha256(origin_id.encode()).digest()
- alphabet = ascii_letters + digits
- base = len(alphabet)
- num = int.from_bytes(digest[:17], "big")
- chars: list[str] = []
- for _ in range(22):
- num, idx = divmod(num, base)
- chars.append(alphabet[idx])
- return "car-" + "".join(chars)
diff --git a/mobilede_scraper/parsing/mapper.py b/mobilede_scraper/parsing/mapper.py
deleted file mode 100644
index 024ab4f..0000000
--- a/mobilede_scraper/parsing/mapper.py
+++ /dev/null
@@ -1,405 +0,0 @@
-import hashlib
-import re
-from datetime import datetime, timezone
-from string import ascii_letters, digits
-from typing import Any
-from urllib.parse import urlparse
-
-from ..core.utils import first_non_empty
-from ..storage.enums import (
- BODY_TYPE_ENUM_VALUES,
- COUNTRY_ENUM_VALUES,
- CURRENCY_ENUM_VALUES,
- DRIVE_ENUM_VALUES,
- GEARBOX_ENUM_VALUES,
- ORIGIN_ENUM_VALUES,
- SELLING_TYPE_ENUM_VALUES,
- STEERING_WHEEL_ENUM_VALUES,
-)
-from ..storage.schemas import CarRecord, ImageRecord
-
-
-class CarMapper:
- # Маппер MOBILEDE в CarRecord.
-
- BODY_MAP = {
- "sedan": "SEDAN", "coupe": "COUPE", "hatchback": "HATCHBACK", "sport utility": "SUV",
- "suv": "SUV", "wagon": "STATION_WAGON", "station wagon": "STATION_WAGON", "pickup": "PICKUP",
- "pickup truck": "PICKUP", "crew cab": "PICKUP", "extended cab": "PICKUP", "regular cab": "PICKUP",
- "quad cab": "PICKUP", "double cab": "PICKUP", "king cab": "PICKUP", "mega cab": "PICKUP",
- "supercab": "PICKUP", "supercrew": "PICKUP", "truck": "TRUCK", "van": "MINIVAN",
- "minivan": "MINIVAN", "convertible": "OPEN", "cabriolet": "OPEN", "rv": "RV", "crossover": "SUV",
- }
- DRIVE_MAP = {
- "front wheel drive": "FWD", "fwd": "FWD", "rear wheel drive": "RWD", "rwd": "RWD",
- "all wheel drive": "4WD", "awd": "4WD", "4x4": "4WD", "four wheel drive": "4WD",
- "4wd": "4WD", "2wd": "2WD", "two wheel drive": "2WD",
- }
- GEARBOX_MAP = {
- "automatic": "AT", "automatic transmission": "AT", "a/t": "AT", "aut": "AT",
- "manual": "MT", "manual transmission": "MT", "m/t": "MT", "cvt": "CVT",
- "continuously variable transmission": "CVT", "electric": "EV", "ev": "EV",
- }
- STEERING_MAP = {"left": "LEFT", "left hand drive": "LEFT", "right": "RIGHT", "right hand drive": "RIGHT"}
- COUNTRY_MAP = {
- "us": "US", "usa": "US", "united states": "US", "ca": "CA", "canada": "CA",
- "jp": "JP", "japan": "JP", "kr": "KR", "korea": "KR", "south korea": "KR",
- }
- NO_DAMAGE_MARKERS = {"normal wear", "normal wear & tear", "normal wear and tear", "n/a", "na", "none", "no damage", "minor dents/scratches"}
-
- def map_to_car_record(self, vehicle_url: str, vehicle_summary: dict[str, Any], payload_insights: dict[str, Any]) -> CarRecord:
- # Собираем DB-модель.
- vehicle_summary = vehicle_summary or {}
- payload_insights = payload_insights or {}
- core = payload_insights.get("vehicle_core", {})
- pricing = payload_insights.get("pricing", {})
- damage = payload_insights.get("damage", {})
- auction = payload_insights.get("auction", {})
- images = payload_insights.get("images", {})
-
- origin_id = self._build_origin_id(vehicle_url, vehicle_summary, core)
- parser_id = self._generate_parser_id(origin_id)
- brand = self._as_str(first_non_empty([core.get("make"), vehicle_summary.get("make")])) or "UNKNOWN"
- model = self._as_str(first_non_empty([core.get("model"), vehicle_summary.get("model")])) or "UNKNOWN"
- year = self._to_year(first_non_empty([core.get("year"), vehicle_summary.get("year")]))
- price = self._first_parsed_int(
- [
- pricing.get("buy_now"),
- pricing.get("current_bid"),
- vehicle_summary.get("buy_now"),
- vehicle_summary.get("current_bid"),
- pricing.get("actual_cash_value"),
- ],
- self._to_money_int,
- )
- mileage = self._parse_odometer(
- first_non_empty([core.get("odometer"), vehicle_summary.get("odometer")])
- )
- color = self._normalize_color(first_non_empty([core.get("color"), vehicle_summary.get("color"), "other"]))
- drive = self._normalize_drive(first_non_empty([core.get("drive"), vehicle_summary.get("drive")]))
- # Пробуем взять привод из двигателя.
- if not drive or drive == "NA":
- engine_text = self._as_str(first_non_empty([core.get("engine"), vehicle_summary.get("engine")]))
- if engine_text:
- inferred_drive = self._normalize_drive(engine_text)
- if inferred_drive and inferred_drive != "NA":
- drive = inferred_drive
- gearbox = self._normalize_gearbox(first_non_empty([core.get("gearbox"), vehicle_summary.get("gearbox")]))
- steering = self._normalize_steering(first_non_empty([core.get("steering_wheel"), vehicle_summary.get("steering_wheel")])) or "LEFT"
- body_type = self._normalize_body_type(first_non_empty([core.get("body_type"), vehicle_summary.get("body_type")]))
- engine_volume = self._to_engine_cc(first_non_empty([core.get("engine"), core.get("engine_volume"), vehicle_summary.get("engine"), vehicle_summary.get("engine_volume")]))
- title_text = self._as_str(first_non_empty([core.get("title"), vehicle_summary.get("title"), ""]))
- seller = self._as_str(first_non_empty([core.get("seller"), vehicle_summary.get("seller"), ""]))
- location = self._as_str(first_non_empty([core.get("location"), vehicle_summary.get("location"), auction.get("branch"), ""]))
- country = self._normalize_country(first_non_empty([core.get("country"), location, "US"]))
- is_damaged = self._bool_damage(damage, vehicle_summary)
- is_sold = self._bool_sold(auction)
- one_owner = self._boolish(first_non_empty([core.get("one_owner"), vehicle_summary.get("one_owner"), False]))
- new_car = self._boolish(first_non_empty([core.get("new_car"), vehicle_summary.get("new_car"), False]))
- rental = self._boolish(first_non_empty([core.get("rental"), vehicle_summary.get("rental"), False]))
- repair_history = self._boolish(first_non_empty([core.get("repair_history"), vehicle_summary.get("repair_history"), False]))
- non_smoking = self._boolish(first_non_empty([core.get("non_smoking"), vehicle_summary.get("non_smoking"), True]))
- evaluation = self._as_str(first_non_empty([core.get("grade"), core.get("evaluation"), vehicle_summary.get("evaluation")])) or None
- currency = self._normalize_currency(
- first_non_empty(
- [
- pricing.get("currency"),
- vehicle_summary.get("currency"),
- pricing.get("buy_now"),
- pricing.get("current_bid"),
- vehicle_summary.get("buy_now"),
- vehicle_summary.get("current_bid"),
- "USD",
- ]
- )
- )
- slug = self._slugify(" ".join(filter(None, [brand, model, str(year or "")])))
- images_records = self._build_images(images.get("urls") or vehicle_summary.get("image_urls") or [])
- origin = "MOBILEDE"
-
- return CarRecord(
- parser_id=parser_id, brand=brand, model=model, year=year, price=price, currency=currency,
- mileage=mileage, country=country, is_sold=is_sold, color=color, drive=drive, gearbox=gearbox,
- steering_wheel=steering, body_type=body_type, engine_volume=engine_volume,
- selling_type=self._normalize_selling_type("AUCTION"), one_owner=one_owner, new_car=new_car,
- is_hidden=False, origin=origin, origin_url=vehicle_url, origin_id=origin_id, is_damaged=is_damaged,
- evaluation=evaluation, non_smoking=non_smoking, rental=rental, repair_history=repair_history,
- slug=slug, last_seen_at=datetime.now(timezone.utc), images=images_records,
- )
-
- @staticmethod
- def _as_str(value: Any) -> str:
- return "" if value is None else str(value).strip()
-
- @staticmethod
- def _to_int(value: Any) -> int | None:
- if value is None:
- return None
- if isinstance(value, bool):
- return int(value)
- if isinstance(value, (int, float)):
- return int(value)
- digits = re.sub(r"[^\d]", "", str(value))
- return int(digits) if digits else None
-
- @classmethod
- def _to_money_int(cls, value: Any) -> int | None:
- # Нормализация цены.
- if value is None:
- return None
- if isinstance(value, bool):
- return None
- if isinstance(value, (int, float)):
- return int(value)
-
- text = str(value).strip()
- if not text:
- return None
-
- lowered = text.lower()
- if any(token in lowered for token in ["n/a", "na", "tbd", "unknown", "call", "contact"]):
- return None
-
- numbers = re.findall(r"\d[\d\s.,]*", text)
- if not numbers:
- return None
-
- best: int | None = None
- for number in numbers:
- clean = number.replace(" ", "")
- if "," in clean and "." in clean:
- # Поддержка двух форматов.
- if clean.rfind(",") > clean.rfind("."):
- clean = clean.replace(".", "").replace(",", ".")
- else:
- clean = clean.replace(",", "")
- elif "," in clean:
- parts = clean.split(",")
- # Десятичный или тысячный разделитель.
- if len(parts[-1]) in {1, 2} and len(parts) == 2:
- clean = clean.replace(",", ".")
- else:
- clean = clean.replace(",", "")
- elif "." in clean:
- parts = clean.split(".")
- if not (len(parts[-1]) in {1, 2} and len(parts) == 2):
- clean = clean.replace(".", "")
-
- try:
- parsed = int(float(clean))
- except ValueError:
- continue
-
- if parsed > 0 and (best is None or parsed > best):
- best = parsed
-
- return best
-
- @staticmethod
- def _first_parsed_int(values: list[Any], parser) -> int | None:
- for value in values:
- parsed = parser(value)
- if parsed is not None:
- return parsed
- return None
-
- @staticmethod
- def _to_year(value: Any) -> int | None:
- parsed = CarMapper._to_int(value)
- if parsed is None:
- return None
- if 1900 <= parsed <= 2100:
- return parsed
- return None
-
- @staticmethod
- def _parse_odometer(value: Any) -> int:
- # Разбор пробега.
- if value is None:
- return 0
- text = str(value).strip()
- if not text:
- return 0
- lowered = text.lower()
- if any(skip in lowered for skip in ["not required", "exempt", "n/a", "na", "unknown", "tbd"]):
- return 0
- # Ищем число.
- numbers = re.findall(r"[\d,]+", text)
- for num_str in numbers:
- clean = num_str.replace(",", "")
- if clean.isdigit() and int(clean) > 0:
- return int(clean)
- return 0
-
- def _to_engine_cc(self, value: Any) -> int | None:
- # Поддержка литров и cc.
- text = str(value).lower().strip() if value is not None else ""
- if not text:
- return None
- if liters_match := re.search(r"(\d+(?:\.\d+)?)\s*l", text):
- return int(float(liters_match.group(1)) * 1000)
- if cubic_match := re.search(r"(\d{3,5})\s*(?:cc|cm3|cubic)", text):
- return int(cubic_match.group(1))
- return int(text) if re.match(r"^\d+$", text) else None
-
- def _normalize_currency(self, value: Any) -> str:
- text = self._as_str(value)
- upper = text.upper() or "USD"
- if any(token in text for token in ["€", "EUR"]):
- return "EUR"
- if any(token in text for token in ["¥", "JPY"]):
- return "JPY"
- if any(token in text for token in ["₩", "KRW"]):
- return "KRW"
- if any(token in text for token in ["£", "GBP"]):
- return "GBP"
- if any(token in text for token in ["₽", "RUB"]):
- return "RUB"
- if any(token in text for token in ["AED", "د.إ"]):
- return "AED"
- if any(token in text for token in ["CA$", "CAD"]):
- return "CAD"
-
- text = upper
- if text in CURRENCY_ENUM_VALUES:
- return text
- return "USD" if "$" in str(value) else "USD"
-
- def _normalize_drive(self, value: Any) -> str | None:
- return self._map_value(value, self.DRIVE_MAP, DRIVE_ENUM_VALUES, empty_default=None, fallback="NA")
-
- def _normalize_gearbox(self, value: Any) -> str | None:
- return self._map_value(value, self.GEARBOX_MAP, GEARBOX_ENUM_VALUES, empty_default=None, fallback="NA")
-
- def _normalize_steering(self, value: Any) -> str | None:
- return self._map_value(value, self.STEERING_MAP, STEERING_WHEEL_ENUM_VALUES, empty_default=None, fallback=None)
-
- def _normalize_body_type(self, value: Any) -> str:
- return self._map_value(value, self.BODY_MAP, BODY_TYPE_ENUM_VALUES, empty_default="OTHER", fallback="OTHER") or "OTHER"
-
- def _normalize_country(self, value: Any) -> str:
- fallback = "US" if "US" in COUNTRY_ENUM_VALUES else "NA"
- return self._map_value(value, self.COUNTRY_MAP, COUNTRY_ENUM_VALUES, empty_default="US", fallback=fallback) or fallback
-
- def _normalize_selling_type(self, value: Any) -> str:
- text = self._as_str(value) or "AUCTION"
- return text if text in SELLING_TYPE_ENUM_VALUES else "AUCTION"
-
- def _map_value(
- self,
- value: Any,
- mapping: dict[str, str],
- allowed_values: tuple[str, ...],
- *,
- empty_default: str | None,
- fallback: str | None,
- ) -> str | None:
- # Общая нормализация enum.
- text = self._as_str(value).lower()
- if not text:
- return empty_default
- if (mapped := mapping.get(text)) and mapped in allowed_values:
- return mapped
- for marker, mapped in mapping.items():
- if marker in text and mapped in allowed_values:
- return mapped
- return fallback
-
- @staticmethod
- def _normalize_color(value: Any) -> str:
- text = str(value).strip() if value is not None else "other"
- if not text:
- return "other"
- if "/" in text:
- text = text.split("/")[0].strip()
- return text.lower() or "other"
-
- @staticmethod
- def _boolish(value: Any) -> bool:
- return value if isinstance(value, bool) else str(value).strip().lower() in {"1", "true", "yes", "y", "owner", "one owner", "new"}
-
- def _bool_damage(self, damage: dict[str, Any], vehicle_summary: dict[str, Any]) -> bool:
- for value in [damage.get("primary"), damage.get("secondary"), damage.get("description"), vehicle_summary.get("primary_damage")]:
- text = self._as_str(value).lower()
- if text and text not in self.NO_DAMAGE_MARKERS:
- return True
- return False
-
- def _bool_sold(self, auction: dict[str, Any]) -> bool:
- return any(token in self._as_str(auction.get("sale_status")).lower() for token in ["sold", "closed", "ended"])
-
- def _build_images(self, urls: list[Any]) -> list[ImageRecord]:
- # Для imageKeys берём самый большой размер.
- best_by_key: dict[str, str] = {}
- key_order: list[str] = []
- non_keyed: list[str] = []
- for item in urls:
- if not isinstance(item, str):
- continue
- url = item.strip()
- if not url:
- continue
- if m := re.search(r'imageKeys=([^&]+)', url):
- img_key = m.group(1)
- w = int(re.search(r'width=(\d+)', url).group(1)) if re.search(r'width=(\d+)', url) else 0
- existing = best_by_key.get(img_key)
- if existing is None:
- best_by_key[img_key] = url
- key_order.append(img_key)
- else:
- existing_w = int(re.search(r'width=(\d+)', existing).group(1)) if re.search(r'width=(\d+)', existing) else 0
- if w > existing_w:
- best_by_key[img_key] = url
- elif url not in non_keyed:
- non_keyed.append(url)
- deduped = [best_by_key[k] for k in key_order] + non_keyed
- return [ImageRecord(fullres_image=self._make_fullres_url(url), preview_image=self._make_preview_url(url), order_index=index) for index, url in enumerate(deduped)]
-
- @staticmethod
- def _make_fullres_url(url: str) -> str:
- if "vis.MOBILEDE.com" in url:
- return re.sub(r'height=\d+', 'height=633', re.sub(r'width=\d+', 'width=845', url))
- return url
-
- @staticmethod
- def _make_preview_url(url: str) -> str:
- if "vis.MOBILEDE.com" in url:
- preview = re.sub(r'height=\d+', 'height=300', re.sub(r'width=\d+', 'width=400', url))
- if preview != url:
- return preview
- return url
-
- # Формирование parser_id.
-
- _PARSER_ID_ALPHABET = ascii_letters + digits
-
- @classmethod
- def _generate_parser_id(cls, origin_id: str) -> str:
- # Стабильный parser_id.
- digest = hashlib.sha256(origin_id.encode()).digest()
- alphabet = cls._PARSER_ID_ALPHABET
- base = len(alphabet)
- num = int.from_bytes(digest[:17], "big") # Хватает на 22 символа.
- chars: list[str] = []
- for _ in range(22):
- num, idx = divmod(num, base)
- chars.append(alphabet[idx])
- return "car-" + "".join(chars)
-
- def _build_origin_id(self, vehicle_url: str, vehicle_summary: dict[str, Any], core: dict[str, Any]) -> str:
- # Формат: mobilede:{lot_number}.
- for value in [core.get("lot_number"), vehicle_summary.get("lot_number")]:
- text = self._as_str(value)
- if text:
- return f"mobilede:{text}"
- tail = urlparse(vehicle_url).path.rstrip("/").split("/")[-1]
- if "~" in tail:
- tail = tail.split("~")[0]
- raw = tail or self._slugify(vehicle_url)
- return f"mobilede:{raw}"
-
- @staticmethod
- def _slugify(value: str) -> str:
- return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "car"
-
-
diff --git a/mobilede_scraper/parsing/parser.py b/mobilede_scraper/parsing/parser.py
deleted file mode 100644
index 5691bd9..0000000
--- a/mobilede_scraper/parsing/parser.py
+++ /dev/null
@@ -1,408 +0,0 @@
-import html as html_module
-import json
-import logging
-import re
-from typing import Any
-
-from ..core.utils import LOT_RE, PRICE_RE, deep_find_all_keys, deep_find_key, first_non_empty
-
-logger = logging.getLogger("MOBILEDE_scraper.parsers")
-
-
-class VehicleParser:
- # Парсер страницы авто.
-
- # Регулярки парсинга и защиты.
- _BUY_NOW_RE = re.compile(r"Buy\s+Now[:\s]*\$\s*([\d,]+(?:\.\d{1,2})?)", re.IGNORECASE)
- _CAPTCHA_TOKENS = frozenset(["captcha", "verify you are human", "i am human", "recaptcha", "cloudflare"])
- _ANTIBOT_TOKENS = frozenset(["incapsula", "access denied", "request unsuccessful", "bot detection"])
- _CAPTCHA_RE = re.compile(r"captcha|recaptcha|robot|are you human|security check", re.IGNORECASE)
- _ANTIBOT_RE = re.compile(r"incapsula|imperva|ddos.guard|cloudflare|access denied|forbidden", re.IGNORECASE)
-
- SUMMARY_KEY_MAP = {
- "lot_number": {"lotnumber", "stockno", "itemid", "lotid", "itemnumber"},
- "year": {"year"},
- "make": {"make", "manufacturer", "brand"},
- "model": {"model"},
- "trim": {"trim", "series"},
- "odometer": {"odometer", "odometermiles", "mileage", "actualcashvalueodometer"},
- "primary_damage": {"primarydamage", "damage", "damagetype", "loss"},
- "secondary_damage": {"secondarydamage"},
- "run_and_drive": {"runanddrive", "canrunanddrive", "rundrive"},
- "buy_now": {"buynowprice", "buyitnowprice", "instantpurchaseprice"},
- "current_bid": {"currentbid", "highbid", "bidamount", "currenthighbid"},
- "actual_cash_value": {"actualcashvalue", "acv"},
- "estimated_repair_cost": {"estimatedrepaircost", "repaircost"},
- "keys": {"keys", "keystatus"},
- "title": {"titletype", "title", "documenttype"},
- "seller": {"seller", "sellername"},
- "location": {"location", "branchname", "auctionlocation", "branch"},
- "auction_date": {"auctiondate", "saledate", "liveauctiondate"},
- "body_type": {"bodytype", "bodystyle", "vehicletype", "bodyclass"},
- "drive": {"driveline", "drive", "drivelinetype", "drivetype", "drivetrain"},
- "gearbox": {"transmission", "gearbox", "transmissiontype"},
- "engine": {"engine", "enginevolume", "enginetype", "enginedescription"},
- "fuel_type": {"fueltype", "fuel"},
- "cylinders": {"cylinders", "cylindercount"},
- "color": {"color", "primarycolor", "exteriorcolor"},
- }
-
- DOM_LABEL_MAP: dict[str, str] = {
- "stock #": "lot_number",
- "stock": "lot_number",
- "primary damage": "primary_damage",
- "secondary damage": "secondary_damage",
- "odometer": "odometer",
- "odometer (miles)": "odometer",
- "mileage": "odometer",
- "body style": "body_type",
- "body type": "body_type",
- "vehicle type": "body_type",
- "engine": "engine",
- "engine type": "engine",
- "transmission": "gearbox",
- "drive line type": "drive",
- "driveline type": "drive",
- "drive line": "drive",
- "driveline": "drive",
- "drive type": "drive",
- "fuel type": "fuel_type",
- "fuel": "fuel_type",
- "cylinders": "cylinders",
- "exterior/interior": "color",
- "exterior color": "color",
- "color": "color",
- "model": "model",
- "series": "trim",
- "selling branch": "location",
- "vehicle location": "vehicle_location",
- "auction date and time": "auction_date",
- "sale date": "auction_date",
- "lane/run #": "lane",
- "actual cash value": "actual_cash_value",
- "estimated repair cost": "estimated_repair_cost",
- "seller": "seller",
- "title/sale doc": "title",
- "title/sale doc brand": "title_brand",
- "start code": "run_and_drive",
- "key": "keys",
- "keys": "keys",
- "manufactured in": "manufactured_in",
- "vehicle class": "vehicle_class",
- }
-
- def _parse_dom_key_value_pairs(self, dom_text: str) -> dict[str, str]:
- result: dict[str, str] = {}
- if not dom_text:
- return result
- lines = [line.strip() for line in dom_text.split("\n") if line.strip()]
- known_labels = set(self.DOM_LABEL_MAP.keys())
- skip_values = {"more actions", "view", "print", "share", "back to results", "all images", "view all images"}
- max_fields = len(set(self.DOM_LABEL_MAP.values()))
-
- for i, line in enumerate(lines):
- # Ранний выход.
- if len(result) >= max_fields:
- break
-
- # Метка и значение в одной строке.
- colon_pos = line.find(":")
- if colon_pos > 0:
- label_part = line[:colon_pos].strip().lower()
- value_part = line[colon_pos + 1:].strip()
- if label_part in known_labels and value_part and value_part.lower() not in skip_values:
- field_name = self.DOM_LABEL_MAP[label_part]
- if field_name not in result or not result[field_name]:
- result[field_name] = value_part
- continue
-
- # Метка и значение в соседних строках.
- clean = line.rstrip(":").strip().lower()
- clean_alt = clean.rstrip("#").strip()
- matched_label = None
- if clean in known_labels:
- matched_label = clean
- elif clean_alt in known_labels:
- matched_label = clean_alt
-
- if matched_label and i + 1 < len(lines):
- value = lines[i + 1].strip()
- if value.rstrip(":").lower().strip() in known_labels:
- continue
- if value.lower() in skip_values:
- continue
- field_name = self.DOM_LABEL_MAP[matched_label]
- if field_name not in result or not result[field_name]:
- result[field_name] = value
- return result
-
- def _parse_title_for_year_make_model(self, page_title: str, dom_text: str) -> dict[str, str | None]:
- result: dict[str, str | None] = {"year": None, "make": None, "model": None}
- title_match = re.match(r"(\d{4})\s+(\S+)\s+(.+?)(?:\s+for\s+)", page_title or "")
- if title_match:
- result["year"] = title_match.group(1)
- result["make"] = title_match.group(2)
- result["model"] = title_match.group(3)
- return result
- dom_match = re.search(r"(?:Search|Log In)\s*\n\s*(\d{4})\s+(\S+)\s+(.+?)(?:\n|$)", dom_text or "")
- if dom_match:
- result["year"] = dom_match.group(1)
- result["make"] = dom_match.group(2)
- result["model"] = dom_match.group(3).strip()
- return result
-
- def normalize(self, vehicle_url: str, page_html: str, dom_text: str, network_dump: dict[str, Any]) -> dict[str, Any]:
- page_html = page_html or ""
- dom_text = dom_text or ""
- network_dump = network_dump or {}
- responses = network_dump.get("json_responses", [])
- payloads = [item.get("payload") for item in responses if isinstance(item.get("payload"), (dict, list))]
- dom_kv = self._parse_dom_key_value_pairs(dom_text)
- page_title = ""
- title_match = re.search(r"]*>(.*?)", page_html or "", re.IGNORECASE | re.DOTALL)
- if title_match:
- page_title = title_match.group(1).strip()
- title_parsed = self._parse_title_for_year_make_model(page_title, dom_text)
-
- # Один проход по payload.
- all_found = deep_find_all_keys(payloads, self.SUMMARY_KEY_MAP)
-
- summary: dict[str, Any] = {"source_url": vehicle_url}
- for field, values in all_found.items():
- if field in dom_kv:
- values.append(dom_kv[field])
- summary[field] = first_non_empty(values)
-
- summary["year"] = summary.get("year") or title_parsed.get("year")
- summary["make"] = summary.get("make") or title_parsed.get("make")
- summary["model"] = summary.get("model") or title_parsed.get("model")
- summary["trim"] = summary.get("trim") or dom_kv.get("trim")
- summary["lot_number"] = summary.get("lot_number") or self._extract_lot_number(dom_text)
- summary["image_urls"] = self._extract_image_urls(payloads, page_html, vehicle_url)
- for dom_field, dom_value in dom_kv.items():
- if dom_field not in summary or not summary[dom_field]:
- summary[dom_field] = dom_value
- prices = self._extract_prices_from_text(dom_text)
- if not summary.get("actual_cash_value") and prices:
- summary["actual_cash_value"] = prices[0]
- if not summary.get("buy_now"):
- buy_now_match = self._BUY_NOW_RE.search(dom_text or "")
- if buy_now_match:
- summary["buy_now"] = buy_now_match.group(1)
- elif prices:
- summary["buy_now"] = prices[0]
- if not summary.get("current_bid") and len(prices) > 1:
- summary["current_bid"] = prices[1]
-
- embedded = self._extract_embedded_json(page_html)
- for item in embedded:
- p = item.get("payload")
- if isinstance(p, (dict, list)):
- payloads.append(p)
- # Доп. проход по JSON.
- extra = deep_find_all_keys([p], self.SUMMARY_KEY_MAP)
- for field, vals in extra.items():
- if not summary.get(field):
- v = first_non_empty(vals)
- if v:
- summary[field] = v
-
- # Передаём готовые image_urls.
- image_urls = summary.get("image_urls") or []
- return {
- "vehicle_summary": summary,
- "payload_insights": self._build_payload_insights(summary, responses, payloads, vehicle_url, image_urls=image_urls),
- "embedded_json": embedded,
- "dom_hints": self._dom_hints(dom_text),
- "access_notes": self._build_access_notes(summary, responses),
- }
-
- def _build_payload_insights(self, summary: dict[str, Any], responses: list[dict[str, Any]], payloads: list[Any], vehicle_url: str = "", image_urls: list[str] | None = None) -> dict[str, Any]:
- if image_urls is None:
- image_urls = self._extract_image_urls(payloads, "", vehicle_url)
- return {
- "vehicle_core": {
- "lot_number": summary.get("lot_number"), "year": summary.get("year"),
- "make": summary.get("make"), "model": summary.get("model"), "trim": summary.get("trim"),
- "odometer": summary.get("odometer"), "run_and_drive": summary.get("run_and_drive"),
- "seller": summary.get("seller"), "location": summary.get("location"), "title": summary.get("title"),
- "body_type": summary.get("body_type"), "drive": summary.get("drive"), "gearbox": summary.get("gearbox"),
- "engine": summary.get("engine"), "fuel_type": summary.get("fuel_type"), "cylinders": summary.get("cylinders"),
- "color": summary.get("color"), "keys": summary.get("keys"),
- },
- "pricing": {
- "buy_now": summary.get("buy_now"), "current_bid": summary.get("current_bid"),
- "actual_cash_value": summary.get("actual_cash_value"), "estimated_repair_cost": summary.get("estimated_repair_cost"),
- "currency": self._guess_currency(summary),
- },
- "bids": self._build_bid_insights(summary, payloads),
- "damage": {
- "primary": summary.get("primary_damage"),
- "secondary": summary.get("secondary_damage"),
- "description": first_non_empty(self._find_in_payloads(payloads, {"damageDescription", "damageDetails"})),
- },
- "auction": {
- "auction_date": summary.get("auction_date"),
- "lane": first_non_empty(self._find_in_payloads(payloads, {"lane", "lanename"})),
- "branch": first_non_empty([summary.get("location"), *self._find_in_payloads(payloads, {"branch", "branchname"})]),
- "sale_status": first_non_empty(self._find_in_payloads(payloads, {"salestatus", "auctionstatus", "status"})),
- "item_number": first_non_empty([summary.get("lot_number"), *self._find_in_payloads(payloads, {"itemnumber", "lotnumber", "lotid"})]),
- },
- "images": {"count": len(image_urls), "urls": image_urls},
- "source_endpoints": self._build_source_endpoints(responses),
- }
-
- def _build_bid_insights(self, summary: dict[str, Any], payloads: list[Any]) -> dict[str, Any]:
- return {
- "amount": summary.get("current_bid"),
- "currency": self._guess_currency(summary),
- "bid_count": first_non_empty(self._find_in_payloads(payloads, {"bidcount", "numberofbids"})),
- "status": first_non_empty(self._find_in_payloads(payloads, {"bidstatus", "biddingstatus"})),
- }
-
- def _build_source_endpoints(self, responses: list[dict[str, Any]]) -> dict[str, list[str]]:
- mapping = {"vehicle": [], "pricing": [], "bids": [], "damage": [], "auction": [], "images": []}
- for item in responses:
- url = item.get("url", "")
- category = item.get("category", "other")
- if category == "vehicle":
- mapping["vehicle"].append(url)
- lowered = url.lower()
- if any(token in lowered for token in ["bid", "offer"]):
- mapping["bids"].append(url)
- if any(token in lowered for token in ["damage", "report"]):
- mapping["damage"].append(url)
- if any(token in lowered for token in ["auction", "sale", "lane", "branch"]):
- mapping["auction"].append(url)
- elif category in mapping:
- mapping[category].append(url)
- return {key: list(dict.fromkeys(urls)) for key, urls in mapping.items()}
-
- def _build_access_notes(self, summary: dict[str, Any], responses: list[dict[str, Any]]) -> dict[str, Any]:
- endpoints = [item.get("url", "") for item in responses]
- dom_hints = self._dom_hints(" ".join(str(value) for value in summary.values() if value is not None))
- return {
- "images_visible": bool(summary.get("image_urls")),
- "network_json_count": len(responses),
- "possible_captcha": bool(dom_hints.get("has_captcha_text")),
- "possible_antibot": bool(dom_hints.get("has_antibot_text")),
- "observed_endpoints": endpoints[:20],
- }
-
- @staticmethod
- def _find_in_payloads(payloads: list[Any], keys: set[str]) -> list[Any]:
- lowered = {key.lower() for key in keys}
- values: list[Any] = []
- for payload in payloads:
- values.extend(deep_find_key(payload, lowered))
- return values
-
- @staticmethod
- def _guess_currency(summary: dict[str, Any]) -> str:
- for key in ["buy_now", "current_bid", "actual_cash_value", "estimated_repair_cost"]:
- value = str(summary.get(key) or "")
- if "$" in value:
- return "USD"
- if "€" in value:
- return "EUR"
- if "¥" in value:
- return "JPY"
- return "USD"
-
- @staticmethod
- def _extract_lot_number(text: str) -> str | None:
- match = LOT_RE.search(text or "")
- return match.group(1) if match else None
-
- @staticmethod
- def _extract_prices_from_text(text: str) -> list[str]:
- return [match.group(1) for match in PRICE_RE.finditer(text or "")]
-
- @staticmethod
- def _extract_embedded_json(html: str) -> list[dict[str, Any]]:
- scripts = re.findall(r"", html or "", flags=re.DOTALL | re.IGNORECASE)
- extracted: list[dict[str, Any]] = []
- for script_text in scripts:
- if "{" not in script_text and "[" not in script_text:
- continue
- # Пропускаем большие блоки.
- if len(script_text) > 51_200:
- continue
- try:
- parsed = json.loads(script_text.strip())
- except Exception:
- continue
- extracted.append({"type": "inline_json", "payload": parsed})
- return extracted
-
- @staticmethod
- def _extract_image_urls(payloads: list[Any], html: str, vehicle_url: str = "") -> list[str]:
- vehicle_key = ""
- key_match = re.search(r"VehicleDetail/(\d+)", vehicle_url or "")
- if key_match:
- vehicle_key = key_match.group(1)
- found: list[str] = []
- for payload in payloads:
- found.extend(deep_find_key(payload, {"imageurl", "imageurls", "url", "fullsizeurl", "thumbnailurl", "originalurl"}))
- flat: list[str] = []
- seen_flat: set[str] = set()
- for item in found:
- if isinstance(item, str) and item.startswith("http"):
- cleaned = html_module.unescape(item)
- lowered = cleaned.lower()
- if vehicle_key and "vis.MOBILEDE.com" in lowered and vehicle_key not in cleaned:
- continue
- if cleaned not in seen_flat:
- seen_flat.add(cleaned)
- flat.append(cleaned)
- elif isinstance(item, list):
- for child in item:
- if isinstance(child, str) and child.startswith("http"):
- cleaned = html_module.unescape(child)
- lowered = cleaned.lower()
- if vehicle_key and "vis.MOBILEDE.com" in lowered and vehicle_key not in cleaned:
- continue
- if cleaned not in seen_flat:
- seen_flat.add(cleaned)
- flat.append(cleaned)
- for pattern in [r'
]+(?:src|data-src)\s*=\s*["\']([^"\']+)["\']', r'data-src\s*=\s*["\']([^"\']+)["\']']:
- for match in re.finditer(pattern, html or "", re.IGNORECASE):
- url = html_module.unescape(match.group(1).strip())
- if not url.startswith("http") or url in seen_flat:
- continue
- lowered = url.lower()
- if vehicle_key and vehicle_key in url:
- seen_flat.add(url)
- flat.append(url)
- elif any(token in lowered for token in ["vis.MOBILEDE.com", "anvis", "vehicleimage"]):
- if vehicle_key and vehicle_key not in url:
- continue
- seen_flat.add(url)
- flat.append(url)
- if vehicle_key:
- for url in re.findall(r'https?://vis\.MOBILEDE\.com[^\s"\'<>]+', html or ""):
- cleaned = html_module.unescape(url)
- if cleaned not in seen_flat and vehicle_key in cleaned:
- seen_flat.add(cleaned)
- flat.append(cleaned)
- filtered: list[str] = []
- for url in flat:
- lowered = url.lower()
- if any(pat in lowered for pat in {"dimensions", "threesixty", "360view", ".js", ".css", ".svg", "/home/", "iframeview"}):
- continue
- if "vis.MOBILEDE.com" in lowered and "/resizer" not in lowered:
- continue
- filtered.append(url)
- return filtered
-
- @staticmethod
- def _dom_hints(text: str) -> dict[str, Any]:
- lowered = (text or "").lower()
- return {
- "has_buy_now_text": "buy now" in lowered,
- "has_run_and_drive_text": "run & drive" in lowered or "run and drive" in lowered,
- "has_damage_text": "damage" in lowered,
- "has_title_text": "title" in lowered,
- "has_captcha_text": any(token in lowered for token in VehicleParser._CAPTCHA_TOKENS),
- "has_antibot_text": any(token in lowered for token in VehicleParser._ANTIBOT_TOKENS),
- }
diff --git a/mobilede_scraper/scraper.py b/mobilede_scraper/scraper.py
deleted file mode 100644
index 5f14b0c..0000000
--- a/mobilede_scraper/scraper.py
+++ /dev/null
@@ -1,2660 +0,0 @@
-import json
-import logging
-import os
-import random
-import re
-import signal
-import time
-import uuid
-import html as html_module
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from concurrent.futures import TimeoutError as FuturesTimeoutError
-from datetime import datetime, timezone
-from pathlib import Path
-from threading import Event, Thread
-from typing import Any, Callable
-from urllib.parse import urlsplit, urlunsplit
-from urllib.request import Request, urlopen
-
-import urllib3
-
-from playwright.sync_api import Error as PlaywrightError
-from playwright.sync_api import BrowserContext, Page, sync_playwright
-from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
-
-from .browser.fast_client import DETAIL_MARKER, MobiledeFastClient, is_challenge_response, parse_product_details_vm
-from .browser import BrowserFactory, HumanPacer, NetworkCapture
-from .core.config import Settings, settings, parse_listing_segments
-from .core.exceptions import AntiBotDetectedError, ListingResumeError, SiteStructureChangedError
-from .core.logs import set_trace_id, setup_logging
-from .core.retry import retryable
-from .core.runtime_config import RuntimeConfig
-from .core.utils import save_to_json
-from .fast_sync import FastSyncEngine
-from .parsing.fast_mapper import FastCarMapper
-from .parsing.mapper import CarMapper
-from .parsing.parser import VehicleParser
-from .storage.db import PersistenceService
-from .browser.listing import ListingCollector, ListingPageResult
-from .storage.schemas import CarRecord
-
-logger = logging.getLogger("MOBILEDE_scraper.scraper")
-VEHICLE_ID_RE = re.compile(r"/VehicleDetail/(\d+)(?:~[A-Z]{2})?", re.IGNORECASE)
-HTML_TAG_RE = re.compile(r"<[^>]+>")
-SCRIPT_STYLE_RE = re.compile(r"<(script|style)[^>]*>.*?\1>", re.IGNORECASE | re.DOTALL)
-
-# Таймауты операций страницы.
-_PAGE_COLLECT_TIMEOUT_S = 90
-_PAGE_NEXT_TIMEOUT_S = 60
-# Ротация контекста выключена по умолчанию.
-_CONTEXT_ROTATE_EVERY_PAGES = int(os.environ.get("MOBILEDE_CONTEXT_ROTATE_EVERY_PAGES", "0") or 0)
-_MAX_LISTING_RECOVERY_ATTEMPTS_PER_SEGMENT = int(os.environ.get("MOBILEDE_MAX_LISTING_RECOVERY_ATTEMPTS_PER_SEGMENT", "3") or 3)
-_SMALL_SEGMENT_SUSPICIOUS_PAGINATION_MAX_LINKS = 120
-_SMALL_SEGMENT_SUSPICIOUS_PAGINATION_MAX_PAGE = 2
-
-
-class PageOperationTimeoutError(Exception):
- """Browser page operation exceeded per-op watchdog timeout."""
-
-
-class _PageOpWatchdog:
- """SIGALRM-based watchdog.
-
- Работает только в главном потоке процесса (Celery prefork child — это ок).
- В других контекстах — no-op.
- """
-
- def __init__(self, seconds: int, label: str) -> None:
- self.seconds = max(1, int(seconds))
- self.label = label
- self._old_handler = None
- self._active = False
-
- def _handler(self, signum, frame): # noqa: ARG002
- raise PageOperationTimeoutError(
- f"Page operation '{self.label}' exceeded {self.seconds}s watchdog"
- )
-
- def __enter__(self):
- import threading as _threading
- if _threading.current_thread() is not _threading.main_thread():
- return self
- if not hasattr(signal, "SIGALRM"):
- return self
- try:
- self._old_handler = signal.signal(signal.SIGALRM, self._handler)
- signal.alarm(self.seconds)
- self._active = True
- except (ValueError, OSError):
- # signal можно выставлять только из main thread; в остальном пропускаем.
- self._active = False
- return self
-
- def __exit__(self, exc_type, exc, tb):
- if not self._active:
- return False
- try:
- signal.alarm(0)
- if self._old_handler is not None:
- signal.signal(signal.SIGALRM, self._old_handler)
- except (ValueError, OSError):
- pass
- return False
-
-
-class _ProgressHeartbeat:
- """Периодически пульсует progress во время долгой навигации."""
-
- def __init__(self, report_progress: Callable[[str, Any], None], stage: str, interval_s: float = 15.0, **meta: Any) -> None:
- self._report_progress = report_progress
- self._stage = stage
- self._interval_s = max(5.0, float(interval_s))
- self._meta = meta
- self._stop = Event()
- self._thread: Thread | None = None
-
- def _run(self) -> None:
- while not self._stop.wait(self._interval_s):
- self._report_progress(self._stage, **self._meta)
-
- def __enter__(self):
- self._thread = Thread(target=self._run, name=f"progress-heartbeat-{self._stage}", daemon=True)
- self._thread.start()
- return self
-
- def __exit__(self, exc_type, exc, tb):
- self._stop.set()
- if self._thread is not None:
- self._thread.join(timeout=1.0)
- return False
-
-
-class MobiledeScraper:
-
- @staticmethod
- def _is_protection_or_network_error(exc: Exception) -> bool:
- message = str(exc).lower()
- signals = (
- "captcha",
- "antibot",
- "blocked",
- "challenge",
- "ns_error_net_interrupt",
- "navigation",
- "timeout",
- "403",
- "429",
- )
- return any(signal in message for signal in signals)
-
- @staticmethod
- def _html_to_text(html: str) -> str:
- if not html:
- return ""
- cleaned = SCRIPT_STYLE_RE.sub(" ", html)
- text = HTML_TAG_RE.sub(" ", cleaned)
- text = html_module.unescape(text)
- return re.sub(r"\s+", " ", text).strip()
-
- @staticmethod
- def _raise_if_blocked_or_incomplete(parsed: dict, vehicle_url: str) -> None:
- dom_hints = parsed.get("dom_hints", {}) or {}
- access_notes = parsed.get("access_notes", {}) or {}
- summary = parsed.get("vehicle_summary", {}) or {}
-
- has_identity = bool(summary.get("lot_number") or summary.get("make") or summary.get("model"))
-
- if (dom_hints.get("has_captcha_text") or dom_hints.get("has_antibot_text")) and not has_identity:
- raise AntiBotDetectedError(f"MOBILEDE anti-bot detected for {vehicle_url}")
-
- if (access_notes.get("possible_captcha") or access_notes.get("possible_antibot")) and not has_identity:
- raise AntiBotDetectedError(f"MOBILEDE blocked or challenged request for {vehicle_url}")
-
- if not has_identity:
- raise SiteStructureChangedError(f"Vehicle page returned no recognizable vehicle data: {vehicle_url}")
-
- @staticmethod
- def _extract_origin_id_from_url(vehicle_url: str) -> str | None:
- match = VEHICLE_ID_RE.search(vehicle_url)
- if not match:
- return None
- return match.group(1)
-
- @staticmethod
- def _extract_db_origin_id_from_url(vehicle_url: str) -> str | None:
- raw_id = MobiledeScraper._extract_origin_id_from_url(vehicle_url)
- if not raw_id:
- return None
- return f"mobilede:{raw_id}"
-
- @staticmethod
- def _normalize_vehicle_url(vehicle_url: str) -> str:
- try:
- parts = urlsplit(vehicle_url)
- return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
- except Exception:
- return vehicle_url
-
- def __init__(self, runtime_settings: Settings | None = None) -> None:
- self.settings = runtime_settings or settings
- setup_logging(self.settings.log_level, self.settings.log_file)
- self.runtime_config = RuntimeConfig.from_file(self.settings.runtime_config_file)
- self.trace_id = uuid.uuid4().hex[:12]
- set_trace_id(self.trace_id)
- self.playwright = None
- self.browser = None
- self.context: BrowserContext | None = None
- self.browser_factory = BrowserFactory(self.settings)
- self.pacer = HumanPacer(self.settings)
- self.listing_collector = ListingCollector(self.settings, self.pacer)
- self.vehicle_parser = VehicleParser()
- self.fast_client = MobiledeFastClient(self.settings)
- self.fast_mapper = FastCarMapper()
- self.car_mapper = CarMapper()
- self.persistence = PersistenceService(self.settings)
- self._shutdown_requested = False
- self._progress_callback: Callable[[str, dict], None] | None = None
- # HTTP pool: при parallel_tabs=24 и concurrency=4 пик ≈ 96 конкурентных сокетов;
- # даём запас до 256, чтобы не упираться в PoolError под всплесками PX/retry.
- proxy_url = self.settings.proxy.server
- if proxy_url:
- _proxy_kwargs: dict = {
- "num_pools": 4,
- "maxsize": 64,
- "block": False,
- "retries": False,
- "timeout": urllib3.Timeout(connect=5, read=20),
- }
- if self.settings.proxy.username:
- _proxy_kwargs["proxy_headers"] = urllib3.make_headers(
- proxy_basic_auth=f"{self.settings.proxy.username}:{self.settings.proxy.password or ''}"
- )
- self._http_pool: urllib3.PoolManager = urllib3.ProxyManager(proxy_url, **_proxy_kwargs)
- logger.info("HTTP fast-path using proxy: %s", proxy_url)
- else:
- self._http_pool = urllib3.PoolManager(
- num_pools=4,
- maxsize=64,
- block=False,
- retries=False,
- timeout=urllib3.Timeout(connect=5, read=20),
- )
-
- def _new_trace_id(self, prefix: str) -> str:
- trace_id = f"{prefix}-{uuid.uuid4().hex[:8]}"
- self.trace_id = trace_id
- set_trace_id(trace_id)
- return trace_id
-
- def _reload_runtime_config(self) -> None:
- """Reload runtime config from file to apply include/exclude changes without restart."""
- try:
- self.runtime_config = RuntimeConfig.from_file(self.settings.runtime_config_file)
- except Exception:
- logger.warning("Failed to reload runtime config, keeping previous values", exc_info=True)
-
- def set_progress_callback(self, callback: Callable[[str, dict], None]) -> None:
- self._progress_callback = callback
-
- def _report_progress(self, stage: str, **meta: Any) -> None:
- if self._progress_callback is not None:
- try:
- self._progress_callback(stage, meta)
- except Exception:
- pass
-
- def __enter__(self) -> "MobiledeScraper":
- if self.settings.scraping_profile.http_first and not self.settings.scraping_profile.browser_fallback_enabled:
- return self
- if self.playwright is None:
- self.playwright = sync_playwright().start()
- if self.browser is None:
- self.browser = self.browser_factory.create_browser(self.playwright)
- return self
-
- def __exit__(self, exc_type, exc, tb) -> None:
- self.close()
-
- def close(self) -> None:
- if self.context is not None:
- try:
- self.context.close()
- except PlaywrightError:
- pass
- finally:
- self.context = None
- if self.browser is not None:
- try:
- self.browser.close()
- except PlaywrightError:
- pass
- finally:
- self.browser = None
- if self.playwright is not None:
- try:
- self.playwright.stop()
- except PlaywrightError:
- pass
- finally:
- self.playwright = None
- if getattr(self, "_http_pool", None) is not None:
- try:
- self._http_pool.clear()
- except Exception:
- pass
- finally:
- self._http_pool = None
-
- def _new_context(self) -> BrowserContext:
- if self.browser is None:
- self.__enter__()
- if self.context:
- try:
- self.context.close()
- except PlaywrightError:
- pass
- self.context = self.browser_factory.create_context(self.browser)
- return self.context
-
- def init_db(self):
- self.persistence.create_tables()
- return {"status": "ok", "database_url": self.settings.database.url}
-
- def _warmup_visit(self, page: Page) -> None:
- try:
- logger.info("Warmup: visiting homepage to pass anti-bot challenge...")
- page.goto(self.settings.home_url, wait_until="commit", timeout=30_000)
- try:
- page.wait_for_load_state("domcontentloaded", timeout=6_000)
- except PlaywrightTimeoutError:
- pass
- try:
- page.wait_for_function("() => document.title && document.title.length > 3", timeout=4_000)
- except PlaywrightTimeoutError:
- pass
- time.sleep(0.3)
- logger.info("Warmup done: %s (title=%s)", page.url, page.title()[:50])
- except Exception as e:
- logger.warning("Warmup visit failed: %s — continuing anyway", e)
- time.sleep(1.5)
-
- def _get_page_with_warmup(self) -> Page:
- context = self._new_context()
- page = context.new_page()
- self._warmup_visit(page)
- return page
-
- def _dedupe_urls(self, raw_urls: list[str]) -> list[str]:
- vehicle_urls: list[str] = []
- seen: set[str] = set()
- for raw in raw_urls:
- normalized = self._normalize_vehicle_url(raw)
- if normalized not in seen:
- seen.add(normalized)
- vehicle_urls.append(normalized)
- return vehicle_urls
-
- def _filter_known_urls(self, vehicle_urls: list[str]) -> tuple[list[str], int]:
- url_to_origin_id = {
- url: self._extract_db_origin_id_from_url(url)
- for url in vehicle_urls
- }
- candidate_origin_ids = [oid for oid in url_to_origin_id.values() if oid]
- existing_urls, existing_ids = self.persistence.get_existing_urls_and_ids(
- vehicle_urls, candidate_origin_ids,
- )
- known_urls = {
- url for url in vehicle_urls
- if (url in existing_urls) or (url_to_origin_id.get(url) in existing_ids)
- }
- skipped = len(known_urls)
- if skipped:
- logger.info("Filtering already known vehicles: skipped %d", skipped)
- new_urls = [url for url in vehicle_urls if url not in known_urls]
- 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
-
- @staticmethod
- def _build_segment_listing_url(base_url: str, make: str | None) -> str:
- """Построить URL листинга для сегмента. Make передаётся через query-параметр."""
- if not make:
- return base_url
- sep = "&" if "?" in base_url else "?"
- return f"{base_url}{sep}Make={make.replace(' ', '%20')}"
-
- 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,
- listing_url: 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, url_override=listing_url)
- 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 _open_listing_page(
- self,
- *,
- make: str | None,
- model: str | None,
- listing_url: str | None = None,
- year_min: int | None = None,
- year_max: int | None = None,
- ) -> tuple[Page, dict[str, str | int | None]]:
- page = self._get_page_with_warmup()
- try:
- self.listing_collector.open_cars_listing(page, url_override=listing_url)
- applied_filters = self.listing_collector.apply_filters(
- page,
- make=make,
- model=model,
- year_min=year_min,
- year_max=year_max,
- )
- except Exception:
- page.close()
- raise
- return page, applied_filters
-
- def _reopen_listing_and_resume(
- self,
- *,
- target_page_number: int,
- make: str | None,
- model: str | None,
- listing_url: str | None = None,
- year_min: int | None = None,
- year_max: int | None = None,
- max_nav_pages: int = 10,
- ) -> tuple[Page, dict[str, str | int | None]]:
- # Ограничиваем глубину навигации: если до цели > max_nav_pages кликов — не пытаемся.
- if target_page_number > max_nav_pages + 1:
- raise ListingResumeError(
- f"Cannot resume at page {target_page_number}: "
- f"exceeds max navigation depth ({max_nav_pages} pages)"
- )
-
- self._report_progress(
- "listing_resume_started",
- target_page=target_page_number,
- max_nav_pages=max_nav_pages,
- )
-
- page, applied_filters = self._open_listing_page(
- make=make,
- model=model,
- listing_url=listing_url,
- year_min=year_min,
- year_max=year_max,
- )
-
- self._report_progress(
- "listing_resume_opened",
- target_page=target_page_number,
- )
-
- for expected_page in range(2, target_page_number + 1):
- self._report_progress(
- "listing_resume_progress",
- current_page=expected_page - 1,
- target_page=target_page_number,
- next_page_number=expected_page,
- )
-
- with _ProgressHeartbeat(
- self._report_progress,
- "listing_resume_progress",
- current_page=expected_page - 1,
- target_page=target_page_number,
- next_page_number=expected_page,
- ):
- next_ok = self.listing_collector.go_to_next_page(page, expected_page_number=expected_page)
-
- if not next_ok:
- self._report_progress(
- "listing_resume_failed",
- current_page=expected_page - 1,
- target_page=target_page_number,
- )
- page.close()
- raise ListingResumeError(f"Failed to resume listing at page {target_page_number}")
-
- self._report_progress(
- "listing_resume_progress",
- current_page=expected_page,
- target_page=target_page_number,
- next_page_number=min(target_page_number, expected_page + 1),
- )
-
- self._report_progress(
- "listing_resume_completed",
- current_page=target_page_number,
- target_page=target_page_number,
- )
- logger.warning("Listing resumed at page %d after recovery", target_page_number)
- return page, applied_filters
-
- def _open_listing_for_stream(
- self,
- *,
- make: str | None,
- model: str | None,
- listing_url: str | None = None,
- year_min: int | None = None,
- year_max: int | None = None,
- ) -> tuple[Page, dict[str, str | int | None]]:
- # Всегда открываем с page 1. Page-level resume убран: эфемерные URL и короткие
- # сегменты делали pagination-resume хрупким. Bootstrap прогресс сохраняется
- # на уровне сегментов в worker/tasks.py (_save_last_completed_segment).
- return self._open_listing_page(
- make=make,
- model=model,
- listing_url=listing_url,
- year_min=year_min,
- year_max=year_max,
- )
-
- def collect_listing(
- self,
- make: str | None = None,
- model: str | None = None,
- known_origin_ids: set[str] | None = None,
- max_duration_seconds: float | None = None,
- ):
- try:
- max_pages = self.settings.listing.max_pages_per_run
- vehicles = list(self.fast_client.iter_listing_vehicles(make=make, max_pages=max_pages))
- vehicle_urls = [f"https://www.MOBILEDE.com/VehicleDetail/{vehicle.inventory_id}" for vehicle in vehicles]
- if model:
- model_key = model.casefold()
- vehicle_urls = [url for url in vehicle_urls if model_key in url.casefold()]
- if known_origin_ids:
- filtered_urls = []
- for url in vehicle_urls:
- origin_id = self._extract_db_origin_id_from_url(url)
- if origin_id and origin_id in known_origin_ids:
- continue
- filtered_urls.append(url)
- vehicle_urls = filtered_urls
- return {
- "status": "ok",
- "mode": "fast_hidden_payload",
- "vehicle_urls": vehicle_urls,
- "vehicles_collected": len(vehicle_urls),
- "pages": [],
- "early_stopped": False,
- "truncated_by_time_budget": False,
- }
- except Exception as exc:
- logger.warning("Fast listing collection failed, falling back to browser listing: %s", exc)
-
- page = self._get_page_with_warmup()
-
- try:
- listing = self.listing_collector.collect_listing_links(
- page,
- make=make,
- model=model,
- known_origin_ids=known_origin_ids,
- max_duration_seconds=max_duration_seconds,
- )
- finally:
- page.close()
-
- return {
- "status": "ok",
- **listing,
- }
-
- def _sync_listing_streaming(
- self,
- *,
- make: str | None,
- model: str | None,
- lane: str,
- limit: int | None,
- effective_only_new: bool,
- started_at: float,
- listing_url: str | None = None,
- year_min: int | None = None,
- year_max: int | None = None,
- ) -> dict[str, Any]:
- batch_size = self.settings.celery.batch_size
- pending_urls: list[str] = []
- seen_urls: set[str] = set()
- all_raw_urls: list[str] = []
- all_listing_origin_urls: set[str] = set()
- pages_info: list[dict[str, Any]] = []
- skipped_existing = 0
- total = 0
- cars_upserted = 0
- cars_failed = 0
- protection_events = 0
- images_upserted = 0
- failures: list[dict[str, str]] = []
- early_stopped = False
- truncated_by_time_budget = False
- listing_recovery_attempts = 0
-
- known_origin_ids: set[str] | None = None
- threshold = self.settings.listing.early_stop_threshold
- if effective_only_new and threshold > 0.0:
- try:
- known_origin_ids = self.persistence.get_all_origin_ids_for_lane("mobilede:")
- logger.info(
- "Loaded %d known origin_ids for early-stop listing",
- len(known_origin_ids),
- )
- except Exception as exc:
- logger.warning("Could not load known origin_ids for early-stop: %s", exc)
-
- # Совместимость: если only_new без limit и без сегментного URL — старая схема.
- # При сегментированном скрапинге (listing_url/year фильтры) всегда streaming.
- if effective_only_new and (limit is None or limit <= 0) and not listing_url and year_min is None and year_max is None:
- listing_payload = self.collect_listing(
- make=make,
- model=model,
- known_origin_ids=known_origin_ids,
- max_duration_seconds=None,
- )
- raw_urls = list(listing_payload.get("vehicle_urls", []))
- deduped_urls = self._dedupe_urls(raw_urls)
- fresh_urls, page_skipped = self._filter_known_urls(deduped_urls)
- skipped_existing += page_skipped
- pending_urls.extend(fresh_urls)
- total = len(fresh_urls)
-
- for raw in raw_urls:
- normalized = self._normalize_vehicle_url(raw)
- if normalized:
- all_listing_origin_urls.add(normalized)
-
- logger.info(
- "Starting sync: %d vehicles to process (batch mode, only_new legacy path)",
- total,
- )
-
- while len(pending_urls) >= batch_size:
- batch_urls = pending_urls[:batch_size]
- try:
- batch_result = self.sync_batch(batch_urls, lane=lane)
- cars_upserted += batch_result.get("cars_upserted", 0)
- cars_failed += batch_result.get("cars_failed", 0)
- protection_events += int(batch_result.get("protection_events", 0))
- images_upserted += batch_result.get("images_upserted", 0)
- failures.extend(batch_result.get("failures", []))
- except Exception as batch_exc:
- logger.error("Legacy only_new batch failed: %s", batch_exc)
- cars_failed += len(batch_urls)
- failures.append({"vehicle_url": "legacy_only_new_batch", "error": str(batch_exc)})
- pending_urls = pending_urls[batch_size:]
-
- if pending_urls:
- try:
- batch_result = self.sync_batch(pending_urls, lane=lane)
- cars_upserted += batch_result.get("cars_upserted", 0)
- cars_failed += batch_result.get("cars_failed", 0)
- protection_events += int(batch_result.get("protection_events", 0))
- images_upserted += batch_result.get("images_upserted", 0)
- failures.extend(batch_result.get("failures", []))
- except Exception as batch_exc:
- logger.error("Legacy only_new final batch failed: %s", batch_exc)
- cars_failed += len(pending_urls)
- failures.append({"vehicle_url": "legacy_only_new_final_batch", "error": str(batch_exc)})
-
- return {
- "listing": listing_payload,
- "total": total,
- "skipped_existing": skipped_existing,
- "cars_upserted": cars_upserted,
- "cars_failed": cars_failed,
- "images_upserted": images_upserted,
- "protection_events": protection_events,
- "failures": failures,
- "all_listing_origin_urls": all_listing_origin_urls,
- }
-
- def _process_pending_batch(batch_urls: list[str], batch_start: int) -> bool:
- nonlocal cars_upserted, cars_failed, protection_events, images_upserted, failures
- if not batch_urls:
- return True
-
- logger.info(
- "Processing batch %d-%d of %d",
- batch_start + 1,
- batch_start + len(batch_urls),
- total,
- )
- try:
- batch_result = self.sync_batch(batch_urls, lane=lane)
- cars_upserted += batch_result.get("cars_upserted", 0)
- cars_failed += batch_result.get("cars_failed", 0)
- protection_events += int(batch_result.get("protection_events", 0))
- images_upserted += batch_result.get("images_upserted", 0)
- failures.extend(batch_result.get("failures", []))
- self._report_progress(
- "batch_upserted",
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- total_discovered=total,
- )
- return True
- except PlaywrightError as pw_exc:
- logger.warning(
- "Batch %d-%d browser crashed: %s. Reinitializing browser context.",
- batch_start + 1,
- batch_start + len(batch_urls),
- pw_exc,
- )
- cars_failed += len(batch_urls)
- failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(pw_exc)})
- try:
- self._new_context()
- logger.info("Browser context reinitialized successfully after crash")
- return True
- except Exception as reinit_exc:
- logger.error("Failed to reinitialize browser: %s. Aborting remaining batches.", reinit_exc)
- return False
- except Exception as batch_exc:
- logger.error(
- "Batch %d-%d failed: %s. Continuing with next batch.",
- batch_start + 1,
- batch_start + len(batch_urls),
- batch_exc,
- )
- cars_failed += len(batch_urls)
- failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(batch_exc)})
- return True
-
- def _consume_listing_recovery_attempt(*, page_number: int, reason: str) -> None:
- nonlocal listing_recovery_attempts
- listing_recovery_attempts += 1
- logger.warning(
- "Listing recovery attempt %d/%d at page %d (%s)",
- listing_recovery_attempts,
- _MAX_LISTING_RECOVERY_ATTEMPTS_PER_SEGMENT,
- page_number,
- reason,
- )
- if listing_recovery_attempts > _MAX_LISTING_RECOVERY_ATTEMPTS_PER_SEGMENT:
- raise ListingResumeError(
- f"Listing recovery exhausted at page {page_number}: {reason}"
- )
-
- page = None
- try:
- page, applied_filters = self._open_listing_for_stream(
- make=make,
- model=model,
- listing_url=listing_url,
- year_min=year_min,
- year_max=year_max,
- )
-
- pages_since_rotation = 0
- for page_number in range(1, self.settings.listing.max_pages_per_run + 1):
- # Heartbeat для worker stall-watchdog: при малом числе ссылок на странице
- # batch_upserted может долго не вызываться, поэтому пульсуем прогресс
- # на каждом шаге пагинации.
- self._report_progress(
- "listing_page_scan_started",
- page_number=page_number,
- total_discovered=total,
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
-
- # Проактивная ротация контекста (опционально, по умолчанию выключена).
- # Если включена — требует долистывания до page_number после reopen,
- # поэтому max_nav_pages поднят под реальную глубину.
- if _CONTEXT_ROTATE_EVERY_PAGES > 0 and pages_since_rotation >= _CONTEXT_ROTATE_EVERY_PAGES:
- logger.warning(
- "Rotating browser context at page %d (every %d pages) to reset anti-bot state",
- page_number, _CONTEXT_ROTATE_EVERY_PAGES,
- )
- try:
- page.close()
- except Exception:
- pass
- page = None
- try:
- _consume_listing_recovery_attempt(
- page_number=page_number,
- reason="context_rotation",
- )
- page, _ = self._reopen_listing_and_resume(
- target_page_number=page_number,
- make=make,
- model=model,
- listing_url=listing_url,
- year_min=year_min,
- year_max=year_max,
- max_nav_pages=300,
- )
- pages_since_rotation = 0
- except ListingResumeError as resume_exc:
- logger.warning(
- "Context rotation could not resume at page %d (%s) — stopping segment",
- page_number, resume_exc,
- )
- break
-
- try:
- with _PageOpWatchdog(_PAGE_COLLECT_TIMEOUT_S, f"collect page {page_number}"):
- page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
- except (PageOperationTimeoutError, PlaywrightError) as op_exc:
- logger.warning(
- "Page %d collect stalled/failed (%s) — reopening listing and resuming",
- page_number, op_exc,
- )
- try:
- page.close()
- except Exception:
- pass
- page = None
- try:
- _consume_listing_recovery_attempt(
- page_number=page_number,
- reason=f"collect_failed:{type(op_exc).__name__}",
- )
- page, _ = self._reopen_listing_and_resume(
- target_page_number=page_number,
- make=make,
- model=model,
- listing_url=listing_url,
- year_min=year_min,
- year_max=year_max,
- max_nav_pages=300,
- )
- pages_since_rotation = 0
- with _PageOpWatchdog(_PAGE_COLLECT_TIMEOUT_S, f"collect page {page_number} (after reopen)"):
- page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
- except (ListingResumeError, PageOperationTimeoutError, PlaywrightError) as resume_exc:
- logger.warning(
- "Cannot recover at page %d (%s) — stopping pagination for this segment",
- page_number, resume_exc,
- )
- break
-
- pages_info.append({
- "page_number": page_result.page_number,
- "links_found": len(page_result.vehicle_links),
- })
- pages_since_rotation += 1
-
- page_urls = self._extract_page_urls(
- page_result,
- all_raw_urls,
- seen_urls,
- all_listing_origin_urls,
- )
-
- if not page_urls:
- page_result, page_urls = self._recover_empty_listing_page(
- page,
- page_number=page_number,
- all_raw_urls=all_raw_urls,
- seen_urls=seen_urls,
- all_listing_origin_urls=all_listing_origin_urls,
- listing_url=listing_url,
- )
- if not page_urls and page_number > 1:
- logger.warning("Page %d still empty after retry — reopening listing and resuming", page_number)
- page.close()
- try:
- _consume_listing_recovery_attempt(
- page_number=page_number,
- reason="empty_page_after_retry",
- )
- page, _ = self._reopen_listing_and_resume(
- target_page_number=page_number,
- make=make,
- model=model,
- listing_url=listing_url,
- year_min=year_min,
- year_max=year_max,
- max_nav_pages=300,
- )
- page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
- page_urls = self._extract_page_urls(
- page_result,
- all_raw_urls,
- seen_urls,
- all_listing_origin_urls,
- )
- except ListingResumeError as resume_exc:
- logger.warning(
- "Cannot resume at page %d (%s) — stopping pagination for this segment",
- page_number, resume_exc,
- )
- page = None
- page_urls = []
- if not page_urls:
- logger.info("Page %d: 0 new links, stopping pagination", page_number)
- break
-
- if known_origin_ids is not None and threshold > 0.0 and page_result.vehicle_links:
- page_known = sum(
- 1
- for item in page_result.vehicle_links
- if item.lot_number and f"mobilede:{item.lot_number}" in known_origin_ids
- )
- page_new = len(page_result.vehicle_links) - page_known
- ratio = page_known / len(page_result.vehicle_links)
- if ratio >= threshold and page_new == 0:
- logger.info(
- "Early stop on page %d: %.0f%% known (%d/%d), 0 new >= threshold %.0f%%",
- page_number,
- ratio * 100,
- page_known,
- len(page_result.vehicle_links),
- threshold * 100,
- )
- early_stopped = True
- break
-
- fresh_urls = page_urls
- page_skipped = 0
- if effective_only_new:
- fresh_urls, page_skipped = self._filter_known_urls(page_urls)
- skipped_existing += page_skipped
-
- if limit is not None and limit > 0:
- remaining_limit = max(0, limit - total - len(pending_urls))
- if remaining_limit <= 0:
- break
- fresh_urls = fresh_urls[:remaining_limit]
-
- pending_urls.extend(fresh_urls)
- total += len(fresh_urls)
-
- logger.info(
- "Page %d: %d links, %d new, %d known (total new: %d/%s)",
- page_number,
- len(page_urls),
- len(fresh_urls),
- page_skipped,
- total,
- limit if limit is not None else "∞",
- )
- self._report_progress(
- "listing_page_scan_done",
- page_number=page_number,
- page_links=len(page_urls),
- page_new=len(fresh_urls),
- page_known=page_skipped,
- total_discovered=total,
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
-
- # Прогресс каждые 10 страниц на уровне WARNING.
- if page_number % 10 == 0:
- logger.warning(
- "sync_listing progress: pages=%d, discovered=%d, upserted=%d, failed=%d, pending=%d",
- page_number,
- total,
- cars_upserted,
- cars_failed,
- len(pending_urls),
- )
-
- while len(pending_urls) >= batch_size:
- batch_urls = pending_urls[:batch_size]
- self._report_progress(
- "listing_batch_dispatch_started",
- page_number=page_number,
- batch_size=len(batch_urls),
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- if not _process_pending_batch(batch_urls, cars_upserted + cars_failed):
- pending_urls = []
- break
- pending_urls = pending_urls[batch_size:]
- self._report_progress(
- "listing_batch_dispatch_done",
- page_number=page_number,
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- # Пауза между батчами: снижает нагрузку на MOBILEDE и риск бана.
- if pending_urls:
- time.sleep(random.uniform(0.5, 1.5))
-
- # Anti-stall: не держим хвост pending до следующей страницы/конца сегмента.
- # Если после scan остались URL меньше batch_size — отправляем их сразу.
- if pending_urls:
- self._report_progress(
- "listing_tail_batch_started",
- page_number=page_number,
- batch_size=len(pending_urls),
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- if not _process_pending_batch(pending_urls, cars_upserted + cars_failed):
- pending_urls = []
- break
- pending_urls = []
- self._report_progress(
- "listing_tail_batch_done",
- page_number=page_number,
- pending_urls=0,
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
-
- if limit is not None and limit > 0 and total >= limit:
- break
- if (
- self.settings.listing.collect_current_page_only
- or not self.settings.listing.include_pagination
- or not page_result.next_page_detected
- ):
- break
-
- if page_number == 1 and not self.listing_collector.has_page_number(page, 2):
- logger.info(
- "Page 2 not found on page 1 — treating segment as single-page",
- )
- self._report_progress(
- "listing_single_page_no_page2",
- page_number=page_number,
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- break
-
- suspicious_small_segment = (
- total <= _SMALL_SEGMENT_SUSPICIOUS_PAGINATION_MAX_LINKS
- and page_number >= _SMALL_SEGMENT_SUSPICIOUS_PAGINATION_MAX_PAGE
- )
- self._report_progress(
- "listing_next_page_started",
- page_number=page_number,
- next_page_number=page_number + 1,
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- try:
- with _ProgressHeartbeat(
- self._report_progress,
- "listing_next_page_started",
- page_number=page_number,
- next_page_number=page_number + 1,
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- ):
- with _PageOpWatchdog(_PAGE_NEXT_TIMEOUT_S, f"next page {page_number + 1}"):
- next_ok = self.listing_collector.go_to_next_page(page, expected_page_number=page_number + 1)
- except (PageOperationTimeoutError, PlaywrightError) as nav_exc:
- logger.warning(
- "go_to_next_page stalled/failed at page %d (%s) — will reopen",
- page_number + 1, nav_exc,
- )
- next_ok = False
- self._report_progress(
- "listing_next_page_done",
- page_number=page_number,
- next_page_number=page_number + 1,
- next_ok=bool(next_ok),
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- if not next_ok:
- if page_number == 1:
- logger.warning(
- "Page 2 is not reachable from page 1 — treating segment as single-page and moving on",
- )
- break
- if suspicious_small_segment:
- logger.warning(
- "Small segment pagination looks exhausted at page %d: total=%d, next page navigation failed — stopping segment",
- page_number,
- total,
- )
- break
- logger.warning("Failed to navigate to page %d — reopening listing and resuming", page_number + 1)
- self._report_progress(
- "listing_reopen_started",
- page_number=page_number,
- target_page_number=page_number + 1,
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- try:
- page.close()
- except Exception:
- pass
- page = None
- try:
- _consume_listing_recovery_attempt(
- page_number=page_number + 1,
- reason=f"next_page_failed:{type(nav_exc).__name__}",
- )
- page, _ = self._reopen_listing_and_resume(
- target_page_number=page_number + 1,
- make=make,
- model=model,
- listing_url=listing_url,
- year_min=year_min,
- year_max=year_max,
- max_nav_pages=300,
- )
- pages_since_rotation = 0
- self._report_progress(
- "listing_reopen_done",
- page_number=page_number,
- target_page_number=page_number + 1,
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- except ListingResumeError as resume_exc:
- logger.warning(
- "Cannot resume at page %d (%s) — treating pagination as exhausted",
- page_number + 1, resume_exc,
- )
- self._report_progress(
- "listing_reopen_failed",
- page_number=page_number,
- target_page_number=page_number + 1,
- error=str(resume_exc),
- pending_urls=len(pending_urls),
- cars_upserted=cars_upserted,
- cars_failed=cars_failed,
- )
- break
-
- if pending_urls:
- _process_pending_batch(pending_urls, cars_upserted + cars_failed)
-
- logger.warning(
- "sync_listing final progress: pages=%d, discovered=%d, upserted=%d, failed=%d",
- len(pages_info),
- total,
- cars_upserted,
- cars_failed,
- )
- finally:
- if page is not None:
- page.close()
-
- return {
- "listing": {
- "status": "ok",
- "listing_url": self.settings.listing.cars_url,
- "applied_filters": applied_filters,
- "pages_collected": len(pages_info),
- "vehicles_collected": len(all_raw_urls),
- "vehicle_urls": all_raw_urls,
- "early_stopped": early_stopped,
- "truncated_by_time_budget": truncated_by_time_budget,
- "pages": pages_info,
- },
- "total": total,
- "skipped_existing": skipped_existing,
- "cars_upserted": cars_upserted,
- "cars_failed": cars_failed,
- "images_upserted": images_upserted,
- "protection_events": protection_events,
- "failures": failures,
- "all_listing_origin_urls": all_listing_origin_urls,
- }
-
- def _get_page(self) -> Page:
- if not self.context:
- self._new_context()
- return self.context.new_page()
-
- @retryable(max_attempts=3, jitter_seconds=0.25)
- def scrape_vehicle_detail(self, vehicle_url: str):
- try:
- return self._scrape_vehicle_detail_fast(vehicle_url)
- except Exception as exc:
- logger.warning("Fast detail scrape failed for %s, falling back to browser: %s", vehicle_url, exc)
-
- page = self._get_page()
- try:
- return self._scrape_on_page(page, vehicle_url)
- finally:
- page.close()
-
- def _scrape_vehicle_detail_fast(self, vehicle_url: str) -> dict[str, Any]:
- trace_id = self._new_trace_id("scrape-fast")
- started_at = time.perf_counter()
- origin_id = self._extract_origin_id_from_url(vehicle_url)
- if not origin_id:
- raise RuntimeError(f"Could not extract MOBILEDE inventory id from URL: {vehicle_url}")
-
- detail_payload = self.fast_client.fetch_vehicle_detail_payload(origin_id)
- db_record = self.fast_mapper.map_payload_to_record(
- detail_payload=detail_payload,
- vehicle_url=self._normalize_vehicle_url(vehicle_url),
- )
- vehicle_summary = self.fast_mapper.payload_to_summary(detail_payload, vehicle_url)
- if not (vehicle_summary.get("make") or vehicle_summary.get("lot_number")):
- raise SiteStructureChangedError(f"ProductDetailsVM returned no vehicle identity for {vehicle_url}")
-
- return {
- "trace_id": trace_id,
- "source_url": vehicle_url,
- "fetched_at_epoch": int(time.time()),
- "elapsed_seconds": round(time.perf_counter() - started_at, 3),
- "network": {"requests": [], "json_responses": [], "capture_limits": {}},
- "vehicle_summary": vehicle_summary,
- "payload_insights": {"source": "ProductDetailsVM"},
- "embedded_json": [detail_payload],
- "dom_hints": {"has_captcha_text": False, "has_antibot_text": False},
- "access_notes": {"mode": "fast_hidden_payload"},
- "db_record": db_record.model_dump(mode="json"),
- }
-
- _JS_EXTRACT = """
- () => {
- try {
- const scripts = document.querySelectorAll('script:not([src])');
- for (const s of scripts) {
- const t = s.textContent || '';
- if (!t.includes('inventoryView') || !t.includes('attributes')) continue;
- const start = t.indexOf('{');
- if (start < 0) continue;
- let depth = 0, end = -1;
- for (let i = start; i < t.length; i++) {
- if (t[i] === '{') depth++;
- else if (t[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
- }
- if (end < 0) continue;
- try {
- const obj = JSON.parse(t.slice(start, end + 1));
- const iv = obj.inventoryView;
- if (!iv || !iv.attributes) continue;
- const attr = iv.attributes;
- const imgs = (iv.imageDimensions && iv.imageDimensions.keys && iv.imageDimensions.keys.$values)
- ? iv.imageDimensions.keys.$values : [];
- const bid = (obj.auctionInformation && obj.auctionInformation.biddingInformation)
- ? obj.auctionInformation.biddingInformation : {};
- const prebid = (obj.auctionInformation && obj.auctionInformation.prebidInformation)
- ? obj.auctionInformation.prebidInformation : {};
- return {
- ok: true,
- SalvageId: attr.SalvageId || '',
- StockNumber: attr.StockNumber || '',
- Year: attr.Year || '',
- Make: attr.Make || '',
- Model: attr.Model || '',
- Series: attr.Series || '',
- BodyStyleName: attr.BodyStyleName || '',
- Cylinders: attr.Cylinders || '',
- DriveLineTypeDesc: attr.DriveLineTypeDesc || '',
- EngineSize: (attr.EngineInformation || attr.EngineSize || '').trim(),
- FuelTypeCode: attr.FuelTypeCode || '',
- Transmission: attr.Transmission || '',
- ExteriorColor: attr.ExteriorColor || '',
- PrimaryDamageDesc: attr.PrimaryDamageDesc || '',
- SecondaryDamageDesc: attr.SecondaryDamageDesc || '',
- ODOValue: attr.ODOValue || '',
- ODOBrand: attr.ODOBrand || '',
- RunAndDrive: attr.RunAndDrive || '',
- Keys: attr.Keys || '',
- BranchName: attr.BranchName || '',
- AuctionDateTime: attr.AuctionDateTime || '',
- Title: attr.Title || '',
- TitleBrand: attr.TitleBrand || '',
- TitleCode: attr.TitleCode || '',
- EstRepairCost: attr.EstRepairCost || '',
- VehicleGrade: attr.VehicleGrade || '',
- highBidAmount: prebid.highBidAmount || bid.highBidAmount || '',
- buyNowPrice: prebid.buyNowPrice || bid.buyNowPrice || '',
- acv: attr.ProviderACV || '',
- BranchNumber: attr.BranchNumber || '',
- imageKeys: imgs.map(i => i.k || '').filter(Boolean),
- };
- } catch (_) { continue; }
- }
- } catch (_) {}
- return { ok: false };
- }
- """
-
- @staticmethod
- def _build_car_from_js(js_data: dict, vehicle_url: str) -> dict:
- if not js_data or not js_data.get("ok"):
- return {}
-
- brnch = str(js_data.get("BranchNumber", "") or "").strip()
- img_keys = js_data.get("imageKeys") or []
- image_urls = [
- f"https://vis.MOBILEDE.com/resizer?imageKeys={k}&width=845&height=633"
- for k in img_keys
- ]
-
- return {
- "source_url": vehicle_url,
- "lot_number": js_data.get("StockNumber") or js_data.get("SalvageId"),
- "year": js_data.get("Year"),
- "make": js_data.get("Make"),
- "model": js_data.get("Model"),
- "trim": js_data.get("Series"),
- "body_type": js_data.get("BodyStyleName"),
- "cylinders": js_data.get("Cylinders"),
- "drive": js_data.get("DriveLineTypeDesc"),
- "engine": js_data.get("EngineSize"),
- "fuel_type": js_data.get("FuelTypeCode"),
- "gearbox": js_data.get("Transmission"),
- "color": js_data.get("ExteriorColor"),
- "primary_damage": js_data.get("PrimaryDamageDesc"),
- "secondary_damage": js_data.get("SecondaryDamageDesc"),
- "odometer": js_data.get("ODOValue"),
- "run_and_drive": js_data.get("RunAndDrive"),
- "keys": js_data.get("Keys"),
- "location": js_data.get("BranchName"),
- "auction_date": js_data.get("AuctionDateTime"),
- "title": js_data.get("Title"),
- "current_bid": js_data.get("highBidAmount"),
- "buy_now": js_data.get("buyNowPrice"),
- "actual_cash_value": js_data.get("acv"),
- "estimated_repair_cost": js_data.get("EstRepairCost"),
- "image_urls": image_urls,
- }
-
- @staticmethod
- def _build_payload_insights(vehicle_summary: dict) -> dict:
- return {
- "vehicle_core": vehicle_summary,
- "pricing": {
- "buy_now": vehicle_summary.get("buy_now"),
- "current_bid": vehicle_summary.get("current_bid"),
- "actual_cash_value": vehicle_summary.get("actual_cash_value"),
- "estimated_repair_cost": vehicle_summary.get("estimated_repair_cost"),
- "currency": "USD",
- },
- "bids": {"amount": vehicle_summary.get("current_bid"), "currency": "USD"},
- "damage": {"primary": vehicle_summary.get("primary_damage"), "secondary": vehicle_summary.get("secondary_damage")},
- "auction": {"auction_date": vehicle_summary.get("auction_date"), "branch": vehicle_summary.get("location")},
- "images": {"count": len(vehicle_summary.get("image_urls") or []), "urls": vehicle_summary.get("image_urls") or []},
- }
-
- def _scrape_on_page(self, page: Page, vehicle_url: str):
- trace_id = self._new_trace_id("scrape")
- started_at = time.perf_counter()
-
- if self.settings.block_resources:
- BrowserFactory.enable_resource_blocking(page)
-
- capture = NetworkCapture(self.settings)
- capture.attach(page, origin_url=vehicle_url)
-
- page.goto(vehicle_url, wait_until="commit", timeout=60_000)
-
- try:
- page.wait_for_load_state("domcontentloaded", timeout=5_000)
- except PlaywrightTimeoutError:
- pass
-
- js_data: dict = {}
- try:
- js_data = page.evaluate(self._JS_EXTRACT) or {}
- except Exception:
- pass
-
- if js_data.get("ok"):
- vehicle_summary = self._build_car_from_js(js_data, vehicle_url)
- has_identity = bool(vehicle_summary.get("make") or vehicle_summary.get("lot_number"))
- if not has_identity:
- raise SiteStructureChangedError(f"JS extraction returned no vehicle identity for {vehicle_url}")
-
- db_record = self.car_mapper.map_to_car_record(
- vehicle_url=vehicle_url,
- vehicle_summary=vehicle_summary,
- payload_insights=self._build_payload_insights(vehicle_summary),
- )
- network_dump = capture.export()
- result = {
- "trace_id": trace_id,
- "source_url": vehicle_url,
- "fetched_at_epoch": int(time.time()),
- "elapsed_seconds": round(time.perf_counter() - started_at, 3),
- "network": network_dump,
- "vehicle_summary": vehicle_summary,
- "payload_insights": {},
- "embedded_json": [],
- "dom_hints": {"has_captcha_text": False, "has_antibot_text": False},
- "access_notes": {},
- "db_record": db_record.model_dump(mode="json"),
- }
- if self.settings.raw_output_json:
- save_to_json(network_dump, self.settings.raw_output_json)
- return result
-
- # Резервный путь: полный HTML-парсинг.
- try:
- page.wait_for_selector("#VehicleDetailViewModel, .veh-details, .vehicle-details, [data-uname='vehicleDetailPage']", timeout=400)
- except PlaywrightTimeoutError:
- pass
-
- html = page.content()
- try:
- dom_text = page.evaluate("() => document.body?.textContent || ''")
- except Exception:
- dom_text = ""
- network_dump = capture.export()
- parsed = self.vehicle_parser.normalize(vehicle_url, html, dom_text, network_dump)
- self._raise_if_blocked_or_incomplete(parsed, vehicle_url)
-
- db_record = self.car_mapper.map_to_car_record(
- vehicle_url=vehicle_url,
- vehicle_summary=parsed.get("vehicle_summary", {}),
- payload_insights=parsed.get("payload_insights", {}),
- )
-
- result = {
- "trace_id": trace_id,
- "source_url": vehicle_url,
- "fetched_at_epoch": int(time.time()),
- "elapsed_seconds": round(time.perf_counter() - started_at, 3),
- "network": network_dump,
- **parsed,
- "db_record": db_record.model_dump(mode="json"),
- }
-
- if self.settings.raw_output_json:
- save_to_json(network_dump, self.settings.raw_output_json)
- return result
-
- @staticmethod
- def _extract_MOBILEDE_json_from_html(html: str, vehicle_url: str) -> dict | None:
- """Извлекает MOBILEDE inventoryView.attributes из HTML без браузера.
-
- Использует json.JSONDecoder.raw_decode для быстрого поиска JSON
- вместо посимвольного сканирования скобок.
- """
- try:
- payload = parse_product_details_vm(html)
- db_record = MobiledeScraper._fast_payload_to_js_data(payload)
- if db_record:
- return db_record
- except Exception:
- pass
-
- search = "inventoryView"
- pos = html.find(search)
- if pos < 0:
- return None
-
- script_start = html.rfind("'
-
- parsed = parse_product_details_vm(html)
- record = FastCarMapper().map_payload_to_record(
- detail_payload=parsed,
- vehicle_url="https://www.MOBILEDE.com/VehicleDetail/45078011~US",
- )
-
- assert record.origin_id == "mobilede:45078011"
- assert record.brand == "ACURA"
- assert record.model == "RSX BASE"
- assert record.year == 2002
- assert record.price == 600
- assert record.drive == "FWD"
- assert record.gearbox == "AT"
- assert record.body_type == "COUPE"
- assert record.engine_volume == 2000
- assert record.is_damaged is False
- assert len(record.images) == 1
-
-
-def test_fast_mapper_uses_fallback_keys_and_normalization() -> None:
- payload = {
- "inventoryView": {
- "attributes": {
- "Id": "454502861",
- "FirstRegistration": "05/2012",
- "Make": "BMW",
- "Model": "X1",
- "Variant": "xDrive 20d",
- "Currency": "EUR",
- "Mileage": "50.100 km",
- "ColorDesc": "silber metallic",
- "Category": "Kombi",
- "TransmissionType": "Schaltgetriebe",
- "EngineInformation": "1995 ccm, Diesel",
- },
- "imageDimensions": {
- "keys": {
- "$values": [
- {"k": "454502861~I1", "w": 1600, "h": 1200, "i": 0},
- ]
- }
- },
- },
- "auctionInformation": {
- "biddingInformation": {"buyNowPrice": "9.900 €"},
- },
- }
-
- record = FastCarMapper().map_payload_to_record(
- detail_payload=payload,
- vehicle_url="https://www.mobile.de/ru/транспортные-средства/подробности.html?id=454502861&vc=Car&s=Car",
- )
-
- assert record.model == "X1 xDrive 20d"
- assert record.year == 2012
- assert record.price == 9900
- assert record.mileage == 50100
- assert record.color == "silver"
- assert record.drive == "4WD"
- assert record.gearbox == "MT"
- assert record.body_type == "STATION_WAGON"
- assert record.engine_volume == 1995
-
-
-def test_build_resizer_images_from_keys_deduplicates_and_orders() -> None:
- keys = [
- {"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
- {"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
- {"k": "abc~2", "w": 1800, "h": 1200, "i": 1},
- ]
- images = build_resizer_images_from_keys(keys)
-
- assert len(images) == 2
- assert images[0]["order_index"] == 1
- assert "imageKeys=abc~2" in str(images[0]["fullres_image"])
-
-
-def test_is_challenge_response_marker_rules() -> None:
- assert is_challenge_response(status_code=403, body_text="", expected_marker=None) is True
- assert is_challenge_response(status_code=200, body_text="blocked", expected_marker=LISTING_MARKER) is True
- assert is_challenge_response(
- status_code=200,
- body_text=f'{DETAIL_MARKER}',
- expected_marker=DETAIL_MARKER,
- ) is False
diff --git a/tests/test_fast_sync.py b/tests/test_fast_sync.py
deleted file mode 100644
index 6718015..0000000
--- a/tests/test_fast_sync.py
+++ /dev/null
@@ -1,197 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from types import SimpleNamespace
-
-from mobilede_scraper.browser.fast_client import FastListingVehicle
-from mobilede_scraper.core.config import Settings
-from mobilede_scraper.core.runtime_config import RuntimeConfig, RuntimeFiltersConfig
-from mobilede_scraper.fast_sync import FastSyncEngine
-from mobilede_scraper.parsing.fast_mapper import FastCarMapper
-
-
-def _listing_vehicle(inventory_id: str, *, status: str = "RS") -> FastListingVehicle:
- return FastListingVehicle(
- inventory_id=inventory_id,
- tenant="US",
- auction_id="1_1",
- auction_date="2026-04-08T08:30:00+00:00",
- inventory_status=status,
- currency="USD",
- timed_auction_closed=False,
- timed_auction_indicator=False,
- prebid_indicator=True,
- buynow_indicator=False,
- )
-
-
-def _detail_payload(inventory_id: str, *, make: str = "ACURA", model: str = "RSX", bid: int = 500) -> dict:
- return {
- "inventoryView": {
- "attributes": {
- "Id": inventory_id,
- "Year": "2002",
- "Make": make,
- "Model": model,
- "Series": "BASE",
- "Currency": "USD",
- "ODOValue": "219187",
- "ExteriorColor": "GRAY",
- "DriveLineTypeDesc": "FWD",
- "Transmission": "Automatic",
- "BodyStyleName": "COUPE",
- "EngineSize": "2.0L I-4",
- "VehicleGrade": "50",
- "PrimaryDamageDesc": "NORMAL WEAR & TEAR",
- },
- "imageDimensions": {
- "keys": {"$values": [{"k": f"{inventory_id}~I1", "w": 1600, "h": 1200, "i": 0}]}
- },
- },
- "auctionInformation": {
- "prebidInformation": {"decimalHighBidAmount": str(bid), "highBidAmount": f"${bid}"},
- "biddingInformation": {"buyNowPrice": "$0"},
- },
- }
-
-
-def _detail_payload_with_fallbacks(inventory_id: str) -> dict:
- return {
- "inventoryView": {
- "attributes": {
- "Id": inventory_id,
- "FirstRegistration": "2011-03",
- "Make": "BMW",
- "Model": "120",
- "Variant": "Cabrio",
- "Currency": "EUR",
- "Kilometerstand": "95 000",
- "Color": "weiss",
- "DriveType": "Front wheel drive",
- "Gearbox": "Automatik",
- "VehicleClass": "Cabrio",
- "EngineInformation": "2,0 l",
- "VehicleGrade": "A",
- },
- "imageDimensions": {
- "keys": {"$values": [{"k": f"{inventory_id}~I1", "w": 1600, "h": 1200, "i": 0}]}
- },
- },
- "auctionInformation": {
- "biddingInformation": {"buyNowPrice": "8.899 €"},
- },
- }
-
-
-class FakeFastClient:
- def __init__(self, listing: list[FastListingVehicle], details: dict[str, dict]) -> None:
- self.listing = listing
- self.details = details
- self.detail_calls = 0
- self.persist_calls = 0
- self._settings = SimpleNamespace(
- scraping_profile=SimpleNamespace(
- verbose_progress_logs=False,
- request_jitter_max_s=0.0,
- )
- )
-
- def iter_listing_vehicles(self, *, listing_start_url=None, make=None, max_pages=None): # noqa: ANN001, ANN202
- del listing_start_url, make, max_pages
- return iter(self.listing)
-
- def fetch_vehicle_detail_payload(self, inventory_id: str) -> dict:
- self.detail_calls += 1
- return self.details[inventory_id]
-
- def persist_session_state(self) -> None:
- self.persist_calls += 1
-
-
-@dataclass
-class FakePersistence:
- inserted: int = 0
- images: int = 0
-
- def get_existing_urls_and_ids(self, origin_urls, origin_ids): # noqa: ANN001, ANN202
- del origin_urls, origin_ids
- return set(), set()
-
- def upsert_cars_batch(self, records): # noqa: ANN001, ANN202
- self.inserted += len(records)
- self.images += sum(len(record.images) for record in records)
- return {"inserted": len(records), "updated": 0, "images_upserted": sum(len(record.images) for record in records)}
-
- def mark_sold_not_in_listing_by_urls(self, active_origin_urls, lane="MOBILEDE"): # noqa: ANN001, ANN202
- del active_origin_urls, lane
- return 0
-
-
-def test_fast_sync_engine_collects_details_and_bulk_upserts() -> None:
- listing = [_listing_vehicle("45078011~US"), _listing_vehicle("45268167~US")]
- details = {
- "45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500),
- "45268167~US": _detail_payload("45268167~US", make="BMW", model="X5", bid=900),
- }
- client = FakeFastClient(listing, details)
- persistence = FakePersistence()
- engine = FastSyncEngine(
- client=client, # type: ignore[arg-type]
- mapper=FastCarMapper(),
- persistence=persistence, # type: ignore[arg-type]
- batch_size=10,
- fetch_concurrency=2,
- )
-
- result = engine.sync_listing(runtime_config=RuntimeConfig(), only_new=False)
-
- assert result["status"] == "success"
- assert result["cars_upserted"] == 2
- assert result["images_upserted"] == 2
- assert result["listing"]["mode"] == "fast_hidden_payload"
- assert client.detail_calls == 2
- assert client.persist_calls == 1
-
-
-def test_fast_sync_engine_applies_runtime_filters_before_db() -> None:
- listing = [_listing_vehicle("45078011~US"), _listing_vehicle("45268167~US")]
- details = {
- "45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500),
- "45268167~US": _detail_payload("45268167~US", make="BMW", model="X5", bid=900),
- }
- runtime = RuntimeConfig(filters=RuntimeFiltersConfig.from_dict({"brands": ["BMW"]}))
- persistence = FakePersistence()
- engine = FastSyncEngine(
- client=FakeFastClient(listing, details), # type: ignore[arg-type]
- mapper=FastCarMapper(),
- persistence=persistence, # type: ignore[arg-type]
- batch_size=10,
- fetch_concurrency=2,
- )
-
- result = engine.sync_listing(runtime_config=runtime, only_new=False)
-
- assert result["cars_upserted"] == 1
- assert result["cars_filtered"] == 1
- assert persistence.inserted == 1
-
-
-def test_fast_sync_engine_maps_fallback_mobilede_fields() -> None:
- listing = [_listing_vehicle("449252166")]
- details = {
- "449252166": _detail_payload_with_fallbacks("449252166"),
- }
- persistence = FakePersistence()
- engine = FastSyncEngine(
- client=FakeFastClient(listing, details), # type: ignore[arg-type]
- mapper=FastCarMapper(),
- persistence=persistence, # type: ignore[arg-type]
- batch_size=10,
- fetch_concurrency=1,
- )
-
- result = engine.sync_listing(runtime_config=RuntimeConfig(), only_new=False)
-
- assert result["cars_upserted"] == 1
- assert persistence.inserted == 1
- assert persistence.images == 1
diff --git a/tests/test_listing.py b/tests/test_listing.py
deleted file mode 100644
index 8331127..0000000
--- a/tests/test_listing.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from __future__ import annotations
-
-import unittest
-
-from mobilede_scraper.browser.pace import HumanPacer
-from mobilede_scraper.core.config import Settings
-from mobilede_scraper.browser.listing import ListingCollector
-
-
-class _FakePage:
- def __init__(self, counts: dict[str, int]) -> None:
- self._counts = counts
- self._evaluate_result = False
- self._evaluate_values: list[object] = []
-
- class _Locator:
- def __init__(self, count_value: int) -> None:
- self._count_value = count_value
-
- def count(self) -> int:
- return self._count_value
-
- def locator(self, selector: str) -> "_FakePage._Locator":
- return _FakePage._Locator(self._counts.get(selector, 0))
-
- def evaluate(self, _script: str):
- if self._evaluate_values:
- return self._evaluate_values.pop(0)
- return self._evaluate_result
-
-
-class TestListingUnit(unittest.TestCase):
- def test_pagination_detection_and_page_number(self) -> None:
- # Есть кнопка Next → True.
- self.assertTrue(ListingCollector._has_next_page(_FakePage({"a[aria-label*='Next']": 1})))
- # Нет селекторов → False.
- self.assertFalse(ListingCollector._has_next_page(_FakePage({})))
- # Числовая пагинация через JS → True только если evaluate явно нашёл next.
- page = _FakePage({})
- page._evaluate_result = True
- self.assertTrue(ListingCollector._has_next_page(page))
- # Номер текущей страницы.
- page2 = _FakePage({})
- page2._evaluate_values = [5]
- self.assertEqual(ListingCollector._get_current_page_number(page2), 5)
-
- def test_extract_vehicle_links_from_html_finds_detail_urls(self) -> None:
- collector = ListingCollector(Settings(), HumanPacer(Settings()))
- html = """
-
- """
-
- links = collector._extract_vehicle_links_from_html(html)
-
- self.assertEqual(
- links,
- [
- ("https://www.MOBILEDE.com/VehicleDetail/45184893~US", "45184893"),
- ("https://www.MOBILEDE.com/VehicleDetail/45171480~US", "45171480"),
- ],
- )
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_mappers.py b/tests/test_mappers.py
index 50011b9..5c0652d 100644
--- a/tests/test_mappers.py
+++ b/tests/test_mappers.py
@@ -2,92 +2,87 @@ from __future__ import annotations
import unittest
-from mobilede_scraper.parsing.mapper import CarMapper
+from mobilede_scraper.mobile_de.mapper import MobileDeMapper
+from mobilede_scraper.mobile_de.models import MobileDeListing
-class TestCarMapper(unittest.TestCase):
+class TestMobileDeMapper(unittest.TestCase):
def setUp(self) -> None:
- self.mapper = CarMapper()
+ self.mapper = MobileDeMapper()
- def test_deduplicates_images_by_image_key(self) -> None:
+ def test_deduplicates_images(self) -> None:
urls = [
- "https://vis.MOBILEDE.com/resizer?imageKeys=1&width=200&height=150",
- "https://vis.MOBILEDE.com/resizer?imageKeys=1&width=845&height=633",
- "https://vis.MOBILEDE.com/resizer?imageKeys=2&width=400&height=300",
+ "https://img.classistatic.de/api/v1/mo-prod/images/1",
+ "https://img.classistatic.de/api/v1/mo-prod/images/1",
+ "//img.classistatic.de/api/v1/mo-prod/images/2",
]
- record = self.mapper.map_to_car_record(
- vehicle_url="https://www.MOBILEDE.com/VehicleDetail/123~US",
- vehicle_summary={"make": "Honda", "model": "Civic", "image_urls": urls},
- payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
+ record = self.mapper.listing_to_car_record(
+ MobileDeListing(
+ id="123",
+ url="https://suchen.mobile.de/fahrzeuge/details.html?id=123",
+ title="Honda Civic",
+ raw={"images": urls},
+ )
)
self.assertEqual(len(record.images), 2)
- self.assertIn("width=845", record.images[0].fullres_image)
- self.assertIn("height=633", record.images[0].fullres_image)
+ self.assertEqual(record.images[0].fullres_image, urls[0])
+ self.assertEqual(record.images[1].fullres_image, "https://img.classistatic.de/api/v1/mo-prod/images/2")
- def test_no_damage_marker_is_not_damaged(self) -> None:
- record = self.mapper.map_to_car_record(
- vehicle_url="https://www.MOBILEDE.com/VehicleDetail/123~US",
- vehicle_summary={"make": "Ford", "model": "Focus"},
- payload_insights={
- "vehicle_core": {},
- "pricing": {},
- "damage": {"primary": "normal wear"},
- "auction": {},
- "images": {},
- },
+ def test_origin_id_uses_canonical_prefix(self) -> None:
+ record = self.mapper.listing_to_car_record(
+ MobileDeListing(
+ id="456",
+ url="https://suchen.mobile.de/fahrzeuge/details.html?id=456",
+ title="Ford Focus",
+ )
)
- self.assertFalse(record.is_damaged)
+ self.assertEqual(record.origin_id, "mobile.de:456")
+ self.assertEqual(record.origin, "MOBILE_DE")
def test_unknown_empty_values_and_normalization(self) -> None:
- record = self.mapper.map_to_car_record(
- vehicle_url="https://www.MOBILEDE.com/VehicleDetail/999~US",
- vehicle_summary={"make": " ", "model": None, "drive": "???", "gearbox": "unknown"},
- payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
+ record = self.mapper.listing_to_car_record(
+ MobileDeListing(
+ id="999",
+ url="https://suchen.mobile.de/fahrzeuge/details.html?id=999",
+ title="",
+ subtitle="",
+ raw={"make": {"localized": " "}, "model": {"localized": ""}, "attr": {"tr": "unknown"}},
+ )
)
self.assertEqual(record.brand, "UNKNOWN")
self.assertEqual(record.model, "UNKNOWN")
- self.assertEqual(record.drive, "NA")
- self.assertEqual(record.gearbox, "NA")
+ self.assertIsNone(record.drive)
+ self.assertIsNone(record.gearbox)
# Нормализация регистра и пробелов.
- record2 = self.mapper.map_to_car_record(
- vehicle_url="https://www.MOBILEDE.com/VehicleDetail/888~US",
- vehicle_summary={"make": "Honda", "model": "Civic", "drive": " Front Wheel Drive ", "gearbox": " AUTOMATIC "},
- payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
+ record2 = self.mapper.listing_to_car_record(
+ MobileDeListing(
+ id="888",
+ url="https://suchen.mobile.de/fahrzeuge/details.html?id=888",
+ title="Honda Civic",
+ transmission=" Automatik ",
+ )
)
- self.assertEqual(record2.drive, "FWD")
self.assertEqual(record2.gearbox, "AT")
def test_price_and_currency_parsing(self) -> None:
- record = self.mapper.map_to_car_record(
- vehicle_url="https://www.MOBILEDE.com/VehicleDetail/777~US",
- vehicle_summary={"make": "Toyota", "model": "Corolla"},
- payload_insights={
- "vehicle_core": {},
- "pricing": {"buy_now": "USD 4,500 - 5,200"},
- "damage": {},
- "auction": {},
- "images": {},
- },
+ record = self.mapper.listing_to_car_record(
+ MobileDeListing(
+ id="777",
+ url="https://suchen.mobile.de/fahrzeuge/details.html?id=777",
+ title="Toyota Corolla",
+ price="€ 5.200",
+ first_registration="05/2019",
+ mileage="50.100 km",
+ )
)
self.assertEqual(record.price, 5200)
-
- record2 = self.mapper.map_to_car_record(
- vehicle_url="https://www.MOBILEDE.com/VehicleDetail/778~US",
- vehicle_summary={"make": "Toyota", "model": "Corolla"},
- payload_insights={
- "vehicle_core": {},
- "pricing": {"buy_now": "€4.500,00"},
- "damage": {},
- "auction": {},
- "images": {},
- },
- )
- self.assertEqual(record2.currency, "EUR")
- self.assertEqual(record2.price, 4500)
+ self.assertEqual(record.currency, "EUR")
+ self.assertEqual(record.year, 2019)
+ self.assertEqual(record.mileage, 50100)
if __name__ == "__main__":
diff --git a/tests/test_mobilede_scraper.py b/tests/test_mobilede_scraper.py
new file mode 100644
index 0000000..c780110
--- /dev/null
+++ b/tests/test_mobilede_scraper.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import unittest
+
+from mobilede_scraper.mobile_de.models import MobileDeListing, MobileDeSearchPage
+from mobilede_scraper.mobile_de.scraper import MobileDeScraper
+
+
+class _FakePersistence:
+ def __init__(self) -> None:
+ self.upsert_calls: list[list[str]] = []
+ self.finish_payload: dict[str, object] | None = None
+
+ def create_tables(self) -> None:
+ return None
+
+ def start_sync_run(self, lane: str) -> int:
+ return 1
+
+ def get_existing_origin_ids(self, origin_ids: list[str]) -> set[str]:
+ return set()
+
+ def upsert_cars_batch(self, records):
+ self.upsert_calls.append([record.origin_id for record in records])
+ return {
+ "inserted": len(records),
+ "updated": 0,
+ "images_upserted": len(records),
+ }
+
+ def finish_sync_run(self, run_id: int, **kwargs) -> None:
+ self.finish_payload = {"run_id": run_id, **kwargs}
+
+
+class _FakeClient:
+ def __init__(self, pages: list[MobileDeSearchPage]) -> None:
+ self.pages = pages
+
+ def build_make_model_param(self, make_id: str, model_id: str | None) -> str:
+ return make_id if not model_id else f"{make_id};{model_id}"
+
+ def iter_search_pages(self, **kwargs):
+ del kwargs
+ yield from self.pages
+
+
+class TestMobileDeScraperStreamingSync(unittest.TestCase):
+ def test_sync_search_upserts_each_page_during_run(self) -> None:
+ pages = [
+ MobileDeSearchPage(
+ url="https://example.test/page-1",
+ page_number=1,
+ total_results=3,
+ listings=[
+ MobileDeListing(id="1", url="https://example.test/1", title="Car 1"),
+ MobileDeListing(id="2", url="https://example.test/2", title="Car 2"),
+ ],
+ ),
+ MobileDeSearchPage(
+ url="https://example.test/page-2",
+ page_number=2,
+ total_results=3,
+ listings=[
+ MobileDeListing(id="3", url="https://example.test/3", title="Car 3"),
+ ],
+ ),
+ ]
+ persistence = _FakePersistence()
+ scraper = MobileDeScraper(
+ client=_FakeClient(pages),
+ persistence=persistence,
+ )
+
+ result = scraper.sync_search(max_pages=2)
+
+ self.assertEqual(len(persistence.upsert_calls), 2)
+ self.assertEqual(persistence.upsert_calls[0], ["mobile.de:1", "mobile.de:2"])
+ self.assertEqual(persistence.upsert_calls[1], ["mobile.de:3"])
+ self.assertEqual(int(result["upsert"]["inserted"]), 3)
+ self.assertEqual(int(result["listing_count"]), 3)
+ self.assertEqual(int(result["unique_listing_count"]), 3)
+ self.assertIsNotNone(persistence.finish_payload)
+ self.assertEqual(int(persistence.finish_payload["cars_upserted"]), 3)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_parser.py b/tests/test_parser.py
deleted file mode 100644
index cc666c4..0000000
--- a/tests/test_parser.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from __future__ import annotations
-
-import unittest
-
-from mobilede_scraper.parsing.parser import VehicleParser
-
-
-class TestVehicleParserUnit(unittest.TestCase):
- def setUp(self) -> None:
- self.parser = VehicleParser()
-
- def test_parse_dom_pairs_and_title(self) -> None:
- dom_text = """
- Stock #:
- 45089484
- Primary Damage:
- Front End
- Odometer:
- 50,123 mi (Actual)
- """
- result = self.parser._parse_dom_key_value_pairs(dom_text)
- self.assertEqual(result.get("lot_number"), "45089484")
- self.assertEqual(result.get("primary_damage"), "Front End")
- self.assertEqual(result.get("odometer"), "50,123 mi (Actual)")
-
- parsed = self.parser._parse_title_for_year_make_model("2014 TOYOTA CAMRY for sale", "")
- self.assertEqual(parsed["year"], "2014")
- self.assertEqual(parsed["make"], "TOYOTA")
- self.assertEqual(parsed["model"], "CAMRY")
-
- def test_extract_image_urls_filters_and_deduplicates(self) -> None:
- vehicle_url = "https://www.MOBILEDE.com/VehicleDetail/45089484~US"
- payloads = [{"imageUrls": [
- "https://vis.MOBILEDE.com/resizer?imageKeys=45089484~SID1&width=845&height=633",
- "https://vis.MOBILEDE.com/resizer?imageKeys=45089484~SID1&width=845&height=633",
- "https://vis.MOBILEDE.com/resizer?imageKeys=99999999~SID2&width=845&height=633",
- ]}]
- urls = self.parser._extract_image_urls(payloads, "", vehicle_url)
- self.assertEqual(len(urls), 1)
- self.assertIn("45089484", urls[0])
-
- def test_dom_hints_and_access_notes_detect_antibot(self) -> None:
- hints = self.parser._dom_hints("Please verify you are human. CAPTCHA. Incapsula access denied.")
- self.assertTrue(hints["has_captcha_text"])
- self.assertTrue(hints["has_antibot_text"])
-
- summary = {"vin": "", "image_urls": [], "note": "Incapsula access denied. Verify you are human."}
- notes = self.parser._build_access_notes(summary, [])
- self.assertTrue(notes["possible_captcha"])
- self.assertTrue(notes["possible_antibot"])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_resilience.py b/tests/test_resilience.py
deleted file mode 100644
index f7da815..0000000
--- a/tests/test_resilience.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from __future__ import annotations
-
-import unittest
-from types import SimpleNamespace
-from unittest.mock import MagicMock, patch
-
-from mobilede_scraper.scraper import MobiledeScraper
-from mobilede_scraper.core.config import Settings
-from mobilede_scraper.worker import tasks
-
-
-class TestResilience(unittest.TestCase):
- def _make_scraper(self) -> MobiledeScraper:
- s = Settings()
- s.log_level = "CRITICAL"
- s.database.url = "sqlite://"
- return MobiledeScraper(s)
-
- def test_update_task_progress_updates_global_marker(self) -> None:
- redis_client = MagicMock()
- pipe = MagicMock()
- redis_client.pipeline.return_value = pipe
-
- tasks._update_task_progress(
- redis_client,
- task_id="task-abc",
- stage="batch_upserted",
- ttl_seconds=180,
- cars_upserted=10,
- )
-
- redis_client.pipeline.assert_called_once()
- self.assertEqual(pipe.set.call_count, 2)
- first_call = pipe.set.call_args_list[0]
- second_call = pipe.set.call_args_list[1]
-
- self.assertEqual(first_call.args[0], tasks._task_progress_key("task-abc"))
- self.assertEqual(second_call.args[0], tasks.GLOBAL_PROGRESS_TS_KEY)
- pipe.execute.assert_called_once()
-
- def test_streaming_sync_stops_cleanly_when_page_stays_empty(self) -> None:
- scraper = self._make_scraper()
- scraper.settings.celery.batch_size = 50
-
- page = MagicMock()
- empty_page_result = SimpleNamespace(
- page_number=1,
- vehicle_links=[],
- next_page_detected=True,
- )
-
- scraper._open_listing_for_stream = MagicMock(return_value=(
- page,
- {"make": None, "model": None, "year_min": None, "year_max": None},
- ))
- scraper.listing_collector.collect_current_page = MagicMock(return_value=empty_page_result)
- scraper._recover_empty_listing_page = MagicMock(return_value=(empty_page_result, []))
- scraper.sync_batch = MagicMock()
-
- result = scraper._sync_listing_streaming(
- make=None,
- model=None,
- lane="MOBILEDE_cars",
- limit=None,
- effective_only_new=False,
- started_at=0.0,
- listing_url="https://www.MOBILEDE.com/Vehiclelisting/Cars?Make=TEST",
- )
-
- self.assertEqual(result["total"], 0)
- self.assertEqual(result["cars_upserted"], 0)
- self.assertEqual(result["cars_failed"], 0)
- self.assertEqual(result["listing"]["pages_collected"], 1)
- scraper._recover_empty_listing_page.assert_called_once()
- scraper.sync_batch.assert_not_called()
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_scraper.py b/tests/test_scraper.py
deleted file mode 100644
index 3755f82..0000000
--- a/tests/test_scraper.py
+++ /dev/null
@@ -1,319 +0,0 @@
-from __future__ import annotations
-
-import unittest
-from concurrent.futures import TimeoutError as FuturesTimeoutError
-from types import SimpleNamespace
-from unittest.mock import MagicMock, patch
-
-from mobilede_scraper.core.config import Settings
-from mobilede_scraper.core.exceptions import AntiBotDetectedError, SiteStructureChangedError
-from mobilede_scraper.scraper import MobiledeScraper
-from mobilede_scraper.storage.schemas import CarRecord
-
-
-def make_db_record(origin_id: str) -> dict[str, object]:
- return CarRecord(
- parser_id=f"mobilede:{origin_id}",
- brand="Toyota",
- model="Camry",
- origin_url=f"https://www.MOBILEDE.com/VehicleDetail/{origin_id}~US",
- origin_id=origin_id,
- slug=f"toyota-camry-{origin_id}",
- ).model_dump(mode="json")
-
-
-class TestScraperSync(unittest.TestCase):
- def _make_scraper(self) -> MobiledeScraper:
- s = Settings()
- s.log_level = "CRITICAL"
- s.database.url = "sqlite://"
- return MobiledeScraper(s)
-
- def test_sync_vehicle_uses_db_record(self) -> None:
- scraper = self._make_scraper()
- scraper.persistence.create_tables = MagicMock()
- scraper.persistence.start_sync_run = MagicMock(return_value=1)
- scraper.persistence.finish_sync_run = MagicMock()
- scraper.persistence.upsert_car = MagicMock(return_value={"action": "inserted", "images_upserted": 0})
- scraper.scrape_vehicle_detail = MagicMock(return_value={"db_record": make_db_record("111")})
-
- result = scraper.sync_vehicle("https://www.MOBILEDE.com/VehicleDetail/111~US")
-
- self.assertEqual(result["status"], "success")
- self.assertIn("trace_id", result)
- scraper.persistence.upsert_car.assert_called_once()
-
- def test_only_new_routing_legacy_and_streaming(self) -> None:
- # Legacy path: only_new без listing_url.
- scraper = self._make_scraper()
- scraper.persistence.create_tables = MagicMock()
- scraper.persistence.start_sync_run = MagicMock(return_value=2)
- scraper.persistence.finish_sync_run = MagicMock()
- scraper.persistence.get_existing_urls_and_ids = MagicMock(return_value=(
- {"https://www.MOBILEDE.com/VehicleDetail/111~US"}, {"mobilede:222"},
- ))
- scraper.collect_listing = MagicMock(return_value={
- "vehicle_urls": [
- "https://www.MOBILEDE.com/VehicleDetail/111~US",
- "https://www.MOBILEDE.com/VehicleDetail/222~US",
- "https://www.MOBILEDE.com/VehicleDetail/333~US",
- ]
- })
- scraper.sync_batch = MagicMock(return_value={
- "cars_upserted": 1, "cars_failed": 0, "images_upserted": 0, "failures": [],
- })
- result = scraper.sync_listing(only_new=True)
- self.assertEqual(result["skipped_existing"], 2)
- self.assertEqual(result["cars_upserted"], 1)
-
- # Streaming path: only_new + listing_url.
- scraper2 = self._make_scraper()
- scraper2.persistence.create_tables = MagicMock()
- scraper2.persistence.start_sync_run = MagicMock(return_value=6)
- scraper2.persistence.finish_sync_run = MagicMock()
- scraper2.collect_listing = MagicMock(side_effect=AssertionError("legacy path should not be used"))
- scraper2._sync_listing_streaming = MagicMock(return_value={
- "listing": {"vehicles_collected": 10, "early_stopped": False, "truncated_by_time_budget": False},
- "total": 10, "skipped_existing": 0, "cars_upserted": 10, "cars_failed": 0,
- "images_upserted": 20, "failures": [], "all_listing_origin_urls": set(),
- })
- result2 = scraper2.sync_listing(
- only_new=True,
- listing_url="https://www.MOBILEDE.com/Vehiclelisting/Cars?Make=TOYOTA",
- year_min=2020, year_max=2027,
- )
- self.assertEqual(result2["cars_upserted"], 10)
- scraper2._sync_listing_streaming.assert_called_once()
- scraper2.collect_listing.assert_not_called()
-
- def test_segmented_sync_calls_per_segment_and_resumes(self) -> None:
- scraper = self._make_scraper()
- scraper.persistence.create_tables = MagicMock()
- scraper.persistence.start_sync_run = MagicMock(return_value=3)
- scraper.persistence.finish_sync_run = MagicMock()
-
- call_args_log: list[dict] = []
- def _fake_sync_listing(**kwargs):
- call_args_log.append(kwargs)
- return {
- "status": "success", "cars_upserted": 5, "cars_failed": 0,
- "images_upserted": 10, "skipped_existing": 0,
- "listing": {"vehicles_collected": 50}, "failures": [],
- }
-
- scraper.sync_listing = MagicMock(side_effect=_fake_sync_listing)
- segments = [
- {"make": "TOYOTA", "year_min": 2020, "year_max": 2027},
- {"make": "FORD", "year_min": None, "year_max": None},
- {"make": "HONDA", "year_min": None, "year_max": None},
- ]
-
- # Resume: пропускаем TOYOTA, начинаем сразу с FORD.
- result = scraper.sync_listing_segmented(
- segments=segments, start_segment=1,
- )
-
- self.assertEqual(result["segments_completed"], 2) # FORD + HONDA
- self.assertEqual(result["cars_upserted"], 10)
- # URL содержит бренд.
- self.assertIn("FORD", call_args_log[0]["listing_url"])
- self.assertIn("HONDA", call_args_log[1]["listing_url"])
-
- def test_segmented_sync_does_not_mark_full_scan_completed_when_segment_failed(self) -> None:
- scraper = self._make_scraper()
- scraper.sync_listing = MagicMock(side_effect=[
- {
- "status": "failed",
- "full_scan_completed": False,
- "cars_upserted": 0,
- "cars_failed": 0,
- "images_upserted": 0,
- "skipped_existing": 0,
- "listing": {"vehicles_collected": 0},
- "failures": [{"vehicle_url": "segment", "error": "resume failed"}],
- },
- {
- "status": "success",
- "full_scan_completed": True,
- "cars_upserted": 1,
- "cars_failed": 0,
- "images_upserted": 0,
- "skipped_existing": 0,
- "listing": {"vehicles_collected": 10},
- "failures": [],
- },
- ])
-
- result = scraper.sync_listing_segmented(
- segments=[
- {"make": "EAGLE", "year_min": None, "year_max": None},
- {"make": "FORD", "year_min": None, "year_max": None},
- ]
- )
-
- self.assertFalse(result["full_scan_completed"])
- self.assertEqual(result["status"], "partial_success")
-
- def test_build_segment_listing_url(self) -> None:
- base = "https://www.MOBILEDE.com/Vehiclelisting/Cars"
- self.assertEqual(
- MobiledeScraper._build_segment_listing_url(base, "TOYOTA"),
- "https://www.MOBILEDE.com/Vehiclelisting/Cars?Make=TOYOTA",
- )
- self.assertEqual(
- MobiledeScraper._build_segment_listing_url(base, "LAND ROVER"),
- "https://www.MOBILEDE.com/Vehiclelisting/Cars?Make=LAND%20ROVER",
- )
- self.assertEqual(MobiledeScraper._build_segment_listing_url(base, None), base)
- self.assertEqual(MobiledeScraper._build_segment_listing_url(base, ""), base)
-
- def test_guard_and_protection_detection(self) -> None:
- with self.assertRaises(AntiBotDetectedError):
- MobiledeScraper._raise_if_blocked_or_incomplete(
- {"dom_hints": {"has_captcha_text": True, "has_antibot_text": False},
- "access_notes": {}, "vehicle_summary": {}},
- "https://www.MOBILEDE.com/VehicleDetail/999~US",
- )
- with self.assertRaises(SiteStructureChangedError):
- MobiledeScraper._raise_if_blocked_or_incomplete(
- {"dom_hints": {"has_captcha_text": False, "has_antibot_text": False},
- "access_notes": {"possible_captcha": False, "possible_antibot": False},
- "vehicle_summary": {}},
- "https://www.MOBILEDE.com/VehicleDetail/999~US",
- )
- self.assertTrue(MobiledeScraper._is_protection_or_network_error(RuntimeError("NS_ERROR_NET_INTERRUPT")))
- self.assertTrue(MobiledeScraper._is_protection_or_network_error(RuntimeError("captcha challenge")))
- self.assertFalse(MobiledeScraper._is_protection_or_network_error(RuntimeError("plain validation error")))
-
- def test_close_resets_browser_state(self) -> None:
- scraper = self._make_scraper()
- http_pool = MagicMock()
- scraper._http_pool = http_pool
- scraper.context = MagicMock()
- scraper.browser = MagicMock()
- scraper.playwright = MagicMock()
- scraper.close()
- http_pool.clear.assert_called_once()
- self.assertIsNone(scraper.context)
- self.assertIsNone(scraper.browser)
-
- def test_recover_empty_listing_page(self) -> None:
- scraper = self._make_scraper()
- page = MagicMock()
- page_result = SimpleNamespace(
- vehicle_links=[SimpleNamespace(href="https://www.MOBILEDE.com/VehicleDetail/123~US")],
- )
- scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result)
- scraper.listing_collector.open_cars_listing = MagicMock()
-
- # Страница > 1: reload.
- _, urls = scraper._recover_empty_listing_page(
- page, page_number=5, all_raw_urls=[], seen_urls=set(),
- )
- page.reload.assert_called_once()
- self.assertEqual(len(urls), 1)
-
- # Страница 1: переоткрытие листинга.
- page.reset_mock()
- _, 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()
- page.reload.assert_not_called()
-
- def test_sync_listing_always_starts_from_page_one(self) -> None:
- scraper = self._make_scraper()
- scraper.settings.celery.batch_size = 1
-
- page = MagicMock()
- page_result = SimpleNamespace(
- page_number=1,
- vehicle_links=[SimpleNamespace(href="https://www.MOBILEDE.com/VehicleDetail/999~US", lot_number="999")],
- next_page_detected=False,
- )
-
- scraper._get_page_with_warmup = MagicMock(return_value=page)
- # Pagination-resume убран: _reopen_listing_and_resume не должен вызываться.
- scraper._reopen_listing_and_resume = MagicMock(
- side_effect=AssertionError("pagination resume must not be used")
- )
- scraper.listing_collector.open_cars_listing = MagicMock()
- scraper.listing_collector.apply_filters = MagicMock(return_value={
- "make": None,
- "model": None,
- "year_min": None,
- "year_max": None,
- })
- scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result)
- scraper._extract_page_urls = MagicMock(return_value=["https://www.MOBILEDE.com/VehicleDetail/999~US"])
- scraper.sync_batch = MagicMock(return_value={
- "cars_upserted": 1,
- "cars_failed": 0,
- "images_upserted": 0,
- "failures": [],
- })
-
- result = scraper._sync_listing_streaming(
- make=None,
- model=None,
- lane="MOBILEDE_cars",
- limit=None,
- effective_only_new=False,
- started_at=0.0,
- listing_url="https://www.MOBILEDE.com/Vehiclelisting/Cars?Make=EAGLE",
- )
-
- scraper.listing_collector.open_cars_listing.assert_called_once()
- scraper._reopen_listing_and_resume.assert_not_called()
- scraper.sync_batch.assert_called_once()
- self.assertEqual(result["cars_upserted"], 1)
- self.assertEqual(result["total"], 1)
-
- def test_sync_batch_timeout_does_not_duplicate_already_processed_urls(self) -> None:
- scraper = self._make_scraper()
- scraper.persistence.upsert_cars_batch = MagicMock(return_value={
- "inserted": 1,
- "updated": 0,
- "images_upserted": 0,
- })
-
- urls = [
- "https://www.MOBILEDE.com/VehicleDetail/111~US",
- "https://www.MOBILEDE.com/VehicleDetail/222~US",
- "https://www.MOBILEDE.com/VehicleDetail/333~US",
- ]
- first_record = CarRecord.model_validate(make_db_record("111"))
-
- class _FakeExecutor:
- def __init__(self, *args, **kwargs):
- pass
-
- def __enter__(self):
- return self
-
- def __exit__(self, exc_type, exc, tb):
- return False
-
- def map(self, fn, iterable, timeout=None): # noqa: ARG002
- yield 0, first_record
- raise FuturesTimeoutError()
-
- with patch("mobilede_scraper.core.runtime_config.RuntimeFiltersConfig.is_empty", return_value=True), \
- patch("mobilede_scraper.scraper.ThreadPoolExecutor", _FakeExecutor), \
- patch.object(scraper, "_browser_fallback_parallel", return_value={
- "records": [],
- "failures": [],
- "cars_failed": 0,
- "protection_events": 0,
- }) as fallback_mock:
- result = scraper.sync_batch(urls)
-
- self.assertEqual(result["cars_upserted"], 1)
- fallback_urls = fallback_mock.call_args.args[0]
- self.assertEqual(len(fallback_urls), 2)
- self.assertEqual({u for u, _ in fallback_urls}, {urls[1], urls[2]})
- self.assertNotIn(urls[0], {u for u, _ in fallback_urls})
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_self_heal.py b/tests/test_self_heal.py
index fd09e7a..d0d19ca 100644
--- a/tests/test_self_heal.py
+++ b/tests/test_self_heal.py
@@ -50,6 +50,16 @@ class TestSelfHeal(unittest.TestCase):
self.assertIsNone(ts)
+ def test_has_inflight_work_uses_canonical_segment_locks(self) -> None:
+ redis_client = MagicMock()
+ redis_client.llen.return_value = 0
+ redis_client.scan_iter.side_effect = [iter(["mobilede:locks:segment:abc"]), iter([])]
+
+ has_inflight, flags = self_heal._has_inflight_work(redis_client, "mobilede_sync")
+
+ self.assertTrue(has_inflight)
+ self.assertEqual(flags["has_segment_lock"], 1)
+
@patch("mobilede_scraper.worker.self_heal.time.sleep", return_value=None)
@patch("mobilede_scraper.worker.self_heal.os.kill")
@patch("builtins.open")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 1cfdcbe..1bfedb5 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,12 +4,7 @@ import unittest
from mobilede_scraper.core.utils import deep_find_key
from mobilede_scraper.core.config import (
- MOBILEDE_DEFAULT_MAKES,
- _LARGE_MAKES,
- _YEAR_SPLITS,
ProxyConfig,
- build_listing_segments_for_makes,
- parse_listing_segments,
)
@@ -28,66 +23,6 @@ class TestDeepFindKey(unittest.TestCase):
self.assertEqual(deep_find_key(deep_payload, {"target"}, max_depth=8), ["value"])
-class TestParseListingSegments(unittest.TestCase):
- def test_empty_and_invalid_return_empty(self) -> None:
- self.assertEqual(parse_listing_segments(""), [])
- self.assertEqual(parse_listing_segments(" "), [])
- self.assertEqual(parse_listing_segments("invalid"), [])
-
- def test_auto_segments_complete_coverage(self) -> None:
- """auto: все бренды покрыты, крупные разбиты по годам, годы без дыр."""
- segs = parse_listing_segments("auto")
-
- # Точное число сегментов.
- expected = len(_LARGE_MAKES) * len(_YEAR_SPLITS) + (len(MOBILEDE_DEFAULT_MAKES) - len(_LARGE_MAKES))
- self.assertEqual(len(segs), expected)
-
- # Все бренды из списка присутствуют.
- makes_in_segments = {s["make"] for s in segs}
- for make in MOBILEDE_DEFAULT_MAKES:
- self.assertIn(make, makes_in_segments)
-
- # Крупные бренды разбиты на 3 сегмента с годами.
- toyota = [s for s in segs if s["make"] == "TOYOTA"]
- self.assertEqual(len(toyota), 3)
- for s in toyota:
- self.assertIsNotNone(s["year_min"])
-
- # Мелкие бренды без годов.
- lexus = [s for s in segs if s["make"] == "LEXUS"]
- self.assertEqual(len(lexus), 1)
- self.assertIsNone(lexus[0]["year_min"])
-
- # Годовые диапазоны покрывают 1950-2027.
- years = set()
- for yr_min, yr_max in _YEAR_SPLITS:
- years.update(range(yr_min, yr_max + 1))
- for year in range(1950, 2027):
- self.assertIn(year, years)
-
- def test_json_input_formats(self) -> None:
- # Массив строк.
- segs = parse_listing_segments('["toyota", "ford"]')
- self.assertEqual(len(segs), 2)
- self.assertEqual(segs[0]["make"], "TOYOTA")
-
- # Массив объектов с годами.
- segs = parse_listing_segments('[{"make":"BMW","year_min":2020,"year_max":2025}]')
- self.assertEqual(segs[0]["make"], "BMW")
- self.assertEqual(segs[0]["year_min"], 2020)
- self.assertEqual(segs[0]["year_max"], 2025)
-
- def test_build_listing_segments_for_makes(self) -> None:
- segs = build_listing_segments_for_makes(["toyota", "Lexus", "TOYOTA", " "])
-
- toyota = [s for s in segs if s["make"] == "TOYOTA"]
- lexus = [s for s in segs if s["make"] == "LEXUS"]
-
- self.assertEqual(len(toyota), len(_YEAR_SPLITS))
- self.assertEqual(len(lexus), 1)
- self.assertIsNone(lexus[0]["year_min"])
-
-
class TestProxyConfig(unittest.TestCase):
def test_requests_proxy_url_includes_encoded_credentials(self) -> None:
cfg = ProxyConfig(
diff --git a/tests/test_worker_runtime_tasks.py b/tests/test_worker_runtime_tasks.py
new file mode 100644
index 0000000..2d7d719
--- /dev/null
+++ b/tests/test_worker_runtime_tasks.py
@@ -0,0 +1,471 @@
+from __future__ import annotations
+
+import unittest
+from unittest.mock import MagicMock
+
+from mobilede_scraper.worker import tasks
+
+
+class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
+ class _FakeRedis:
+ def __init__(self) -> None:
+ self.store: dict[str, str] = {}
+ self.sets: dict[str, set[str]] = {}
+
+ def get(self, key: str):
+ return self.store.get(key)
+
+ def set(self, key: str, value: str, nx: bool = False, ex: int | None = None): # noqa: ARG002
+ if nx and key in self.store:
+ return False
+ self.store[key] = str(value)
+ return True
+
+ def incr(self, key: str):
+ current = int(self.store.get(key, "0"))
+ current += 1
+ self.store[key] = str(current)
+ return current
+
+ def sadd(self, key: str, member: str):
+ bucket = self.sets.setdefault(key, set())
+ before = len(bucket)
+ bucket.add(member)
+ return 1 if len(bucket) > before else 0
+
+ def expire(self, key: str, ttl: int): # noqa: ARG002
+ return True
+
+ def test_lock_acquire_refresh_release(self) -> None:
+ redis_client = MagicMock()
+
+ redis_client.set.return_value = True
+ self.assertTrue(tasks._acquire_lock(redis_client, "lock:key", "owner-token", 120))
+ redis_client.set.assert_called_once_with("lock:key", "owner-token", nx=True, ex=120)
+
+ redis_client.eval.return_value = 1
+ self.assertTrue(tasks._refresh_lock_if_owner(redis_client, "lock:key", "owner-token", 120))
+
+ redis_client.eval.reset_mock()
+ tasks._release_lock_if_owner(redis_client, "lock:key", "owner-token")
+ args = redis_client.eval.call_args[0]
+ self.assertEqual(args[2], "lock:key")
+ self.assertEqual(args[3], "owner-token")
+
+ def test_mobilede_segment_key_is_stable(self) -> None:
+ segment = {"make_id": "BMW", "price_min": 1000, "price_max": 5000}
+
+ key1 = tasks._mobilede_task_segment_key(
+ segment=segment,
+ search_url=None,
+ make_id=None,
+ model_id=None,
+ price_min=None,
+ price_max=None,
+ year_min=None,
+ year_max=None,
+ mileage_min=None,
+ mileage_max=None,
+ )
+ key2 = tasks._mobilede_task_segment_key(
+ segment={"price_max": 5000, "make_id": "BMW", "price_min": 1000},
+ search_url=None,
+ make_id=None,
+ model_id=None,
+ price_min=None,
+ price_max=None,
+ year_min=None,
+ year_max=None,
+ mileage_min=None,
+ mileage_max=None,
+ )
+
+ self.assertEqual(key1, key2)
+ self.assertEqual(len(key1), 16)
+
+ def test_runtime_segment_reservation_uses_pending_cache(self) -> None:
+ redis_client = MagicMock()
+ settings = MagicMock()
+ redis_client.get.side_effect = lambda key: {
+ tasks.MOBILEDE_RUNTIME_SEGMENTS_CACHE_KEY: b'[{"make_id":"BMW"},{"make_id":"AUDI"}]',
+ tasks.MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY: b"2",
+ tasks.MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY: b"2",
+ }.get(key)
+ redis_client.incr.return_value = 2
+ redis_client.ttl.return_value = -2
+
+ segment = tasks._reserve_mobilede_runtime_segment(
+ redis_client,
+ settings,
+ only_new=False,
+ )
+
+ self.assertEqual(segment, (1, {"make_id": "AUDI"}))
+
+ def test_preplan_splits_dense_segment_before_queueing(self) -> None:
+ original_probe = tasks._mobilede_probe_segment_total
+ original_children = tasks._mobilede_build_overflow_child_segments
+ original_preplan_enabled = tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES
+ original_max_segments = tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS
+ original_max_probes = tasks.MOBILEDE_PREPLAN_MAX_PROBES
+ original_threshold_ratio = tasks.MOBILEDE_PREPLAN_SPLIT_THRESHOLD_RATIO
+ original_max_preplan_depth = tasks.MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH
+
+ parent = {
+ "label": "Cars",
+ "search_url": "https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true",
+ "max_pages": tasks.MOBILEDE_MAX_PAGE_NUMBER,
+ }
+ child_a = {**parent, "search_url": parent["search_url"] + "&ml=:100000", "mileage_max": "100000", "overflow_depth": 1}
+ child_b = {**parent, "search_url": parent["search_url"] + "&ml=100001:", "mileage_min": "100001", "overflow_depth": 1}
+
+ try:
+ tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES = True
+ tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS = 450
+ tasks.MOBILEDE_PREPLAN_MAX_PROBES = 700
+ tasks.MOBILEDE_PREPLAN_SPLIT_THRESHOLD_RATIO = 1.0
+ tasks.MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH = 2
+ tasks._mobilede_probe_segment_total = lambda segment: 2000 if segment is parent or segment.get("overflow_depth") is None else 100
+ tasks._mobilede_build_overflow_child_segments = lambda *, segment, max_pages: [child_a, child_b]
+
+ planned = tasks._mobilede_preplan_runtime_segments([parent])
+ finally:
+ tasks._mobilede_probe_segment_total = original_probe
+ tasks._mobilede_build_overflow_child_segments = original_children
+ tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES = original_preplan_enabled
+ tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS = original_max_segments
+ tasks.MOBILEDE_PREPLAN_MAX_PROBES = original_max_probes
+ tasks.MOBILEDE_PREPLAN_SPLIT_THRESHOLD_RATIO = original_threshold_ratio
+ tasks.MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH = original_max_preplan_depth
+
+ self.assertEqual(len(planned), 2)
+ self.assertTrue(all(item.get("total_results") == 100 for item in planned))
+ self.assertTrue(all(int(item.get("max_pages") or 0) <= tasks.MOBILEDE_MAX_PAGE_NUMBER for item in planned))
+
+ def test_preplan_has_hard_segment_limit(self) -> None:
+ original_probe = tasks._mobilede_probe_segment_total
+ original_children = tasks._mobilede_build_overflow_child_segments
+ original_preplan_enabled = tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES
+ original_max_segments = tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS
+ original_max_probes = tasks.MOBILEDE_PREPLAN_MAX_PROBES
+ original_threshold_ratio = tasks.MOBILEDE_PREPLAN_SPLIT_THRESHOLD_RATIO
+ original_max_preplan_depth = tasks.MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH
+
+ parent = {
+ "label": "Cars",
+ "search_url": "https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true",
+ "max_pages": tasks.MOBILEDE_MAX_PAGE_NUMBER,
+ }
+
+ try:
+ tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES = True
+ tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS = 3
+ tasks.MOBILEDE_PREPLAN_MAX_PROBES = 10
+ tasks.MOBILEDE_PREPLAN_SPLIT_THRESHOLD_RATIO = 1.0
+ tasks.MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH = 2
+ tasks._mobilede_probe_segment_total = lambda segment: 5000
+ tasks._mobilede_build_overflow_child_segments = lambda *, segment, max_pages: [
+ {**segment, "search_url": str(segment["search_url"]) + "&a=1", "overflow_depth": int(segment.get("overflow_depth") or 0) + 1},
+ {**segment, "search_url": str(segment["search_url"]) + "&a=2", "overflow_depth": int(segment.get("overflow_depth") or 0) + 1},
+ ]
+
+ planned = tasks._mobilede_preplan_runtime_segments([parent])
+ finally:
+ tasks._mobilede_probe_segment_total = original_probe
+ tasks._mobilede_build_overflow_child_segments = original_children
+ tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES = original_preplan_enabled
+ tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS = original_max_segments
+ tasks.MOBILEDE_PREPLAN_MAX_PROBES = original_max_probes
+ tasks.MOBILEDE_PREPLAN_SPLIT_THRESHOLD_RATIO = original_threshold_ratio
+ tasks.MOBILEDE_PREPLAN_MAX_SPLIT_DEPTH = original_max_preplan_depth
+
+ self.assertGreaterEqual(len(planned), 1)
+ self.assertLessEqual(len(planned), 3)
+
+ def test_preplan_probe_budget_is_global(self) -> None:
+ original_probe = tasks._mobilede_probe_segment_total
+ original_preplan_enabled = tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES
+ original_max_segments = tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS
+ original_max_probes = tasks.MOBILEDE_PREPLAN_MAX_PROBES
+
+ calls = {"count": 0}
+ segments = [
+ {"label": f"Cars {index}", "search_url": f"https://suchen.mobile.de/fahrzeuge/search.html?x={index}"}
+ for index in range(5)
+ ]
+
+ try:
+ tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES = True
+ tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS = 50
+ tasks.MOBILEDE_PREPLAN_MAX_PROBES = 2
+
+ def _probe(_segment):
+ calls["count"] += 1
+ return 100
+
+ tasks._mobilede_probe_segment_total = _probe
+
+ planned = tasks._mobilede_preplan_runtime_segments(segments)
+ finally:
+ tasks._mobilede_probe_segment_total = original_probe
+ tasks.MOBILEDE_PREPLAN_SEGMENT_PROBES = original_preplan_enabled
+ tasks.MOBILEDE_PREPLAN_MAX_SEGMENTS = original_max_segments
+ tasks.MOBILEDE_PREPLAN_MAX_PROBES = original_max_probes
+
+ self.assertEqual(calls["count"], 2)
+ self.assertEqual(len(planned), 5)
+
+ def test_overflow_root_mileage_split_keeps_high_mileage_tail_with_two_children(self) -> None:
+ original_max_children = tasks.MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS
+ try:
+ tasks.MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS = 2
+ children = tasks._mobilede_build_overflow_child_segments(
+ segment={
+ "label": "Cars",
+ "search_url": "https://www.mobile.de/ru/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&p=5001:10000&fr=2005:2009",
+ "price_min": "5001",
+ "price_max": "10000",
+ "year_min": "2005",
+ "year_max": "2009",
+ "max_pages": tasks.MOBILEDE_MAX_PAGE_NUMBER,
+ },
+ max_pages=tasks.MOBILEDE_MAX_PAGE_NUMBER,
+ )
+ finally:
+ tasks.MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS = original_max_children
+
+ self.assertEqual(len(children), 2)
+ self.assertEqual(children[0].get("mileage_max"), "150000")
+ self.assertEqual(children[1].get("mileage_min"), "150001")
+ self.assertIsNone(children[1].get("mileage_max"))
+
+ def test_bootstrap_finalization_waits_for_dispatched_segments(self) -> None:
+ redis_client = MagicMock()
+ redis_client.get.side_effect = lambda key: {
+ tasks.MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY: b"10",
+ tasks.MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY: b"10",
+ tasks.MOBILEDE_BOOTSTRAP_DONE_KEY: None,
+ tasks.MOBILEDE_BOOTSTRAP_LISTINGS_TOTAL_KEY: b"1000",
+ tasks.MOBILEDE_BOOTSTRAP_UNIQUE_TOTAL_KEY: b"995",
+ tasks.MOBILEDE_BOOTSTRAP_INSERTED_TOTAL_KEY: b"990",
+ tasks.MOBILEDE_BOOTSTRAP_UPDATED_TOTAL_KEY: b"5",
+ tasks.MOBILEDE_BOOTSTRAP_IMAGES_TOTAL_KEY: b"7000",
+ }.get(key)
+ redis_client.scard.return_value = 1
+
+ finalized = tasks._mobilede_try_finalize_bootstrap(redis_client)
+
+ self.assertFalse(finalized)
+ redis_client.set.assert_not_called()
+
+ def test_bootstrap_finalization_marks_done_when_dispatched_is_empty(self) -> None:
+ redis_client = MagicMock()
+ redis_client.get.side_effect = lambda key: {
+ tasks.MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY: b"10",
+ tasks.MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY: b"10",
+ tasks.MOBILEDE_BOOTSTRAP_DONE_KEY: None,
+ tasks.MOBILEDE_BOOTSTRAP_LISTINGS_TOTAL_KEY: b"1000",
+ tasks.MOBILEDE_BOOTSTRAP_UNIQUE_TOTAL_KEY: b"995",
+ tasks.MOBILEDE_BOOTSTRAP_INSERTED_TOTAL_KEY: b"990",
+ tasks.MOBILEDE_BOOTSTRAP_UPDATED_TOTAL_KEY: b"5",
+ tasks.MOBILEDE_BOOTSTRAP_IMAGES_TOTAL_KEY: b"7000",
+ }.get(key)
+ redis_client.scard.return_value = 0
+
+ finalized = tasks._mobilede_try_finalize_bootstrap(redis_client)
+
+ self.assertTrue(finalized)
+ redis_client.set.assert_any_call(tasks.MOBILEDE_BOOTSTRAP_DONE_KEY, "1")
+ redis_client.set.assert_any_call(tasks.MOBILEDE_RUNTIME_SEGMENTS_PLAN_FINALIZED_KEY, "1", ex=30 * 24 * 60 * 60)
+
+ def test_completed_bootstrap_segment_is_skipped_during_active_full_pass(self) -> None:
+ redis_client = MagicMock()
+ redis_client.get.side_effect = lambda key: "1" if str(key).startswith("mobilede:state:bootstrap_segment_done:") else None
+
+ should_skip = tasks._mobilede_should_skip_completed_bootstrap_segment(
+ redis_client,
+ segment={"make_id": "3500", "price_min": "1", "price_max": "5000"},
+ only_new=False,
+ bootstrap_run_active=True,
+ )
+
+ self.assertTrue(should_skip)
+
+ def test_exhausted_non_cursor_segment_window_is_skipped(self) -> None:
+ exhausted = tasks._mobilede_segment_window_is_exhausted(
+ {"make_id": "3500", "max_pages": 50},
+ start_page=51,
+ max_pages=50,
+ use_cursor=False,
+ )
+ not_exhausted = tasks._mobilede_segment_window_is_exhausted(
+ {"make_id": "3500", "max_pages": 50},
+ start_page=1,
+ max_pages=50,
+ use_cursor=False,
+ )
+ cursor_window = tasks._mobilede_segment_window_is_exhausted(
+ {"make_id": "3500", "max_pages": 50},
+ start_page=51,
+ max_pages=50,
+ use_cursor=True,
+ )
+
+ self.assertTrue(exhausted)
+ self.assertFalse(not_exhausted)
+ self.assertFalse(cursor_window)
+
+ def test_explicit_start_page_is_not_overwritten_by_segment_default(self) -> None:
+ segment = {"make_id": "3500", "start_page": 1}
+
+ requested_start_page = 51
+ if segment.get("start_page") is not None and requested_start_page <= 1:
+ effective_start_page = int(segment.get("start_page") or requested_start_page)
+ else:
+ effective_start_page = requested_start_page
+
+ self.assertEqual(effective_start_page, 51)
+
+ def test_refresh_cycle_tracking_finalizes_on_last_segment(self) -> None:
+ redis_client = self._FakeRedis()
+ redis_client.store[tasks.MOBILEDE_REFRESH_CYCLE_ID_KEY] = "cycle-a"
+ redis_client.store[tasks.MOBILEDE_REFRESH_CYCLE_TOTAL_KEY] = "2"
+ redis_client.store[tasks.MOBILEDE_REFRESH_CYCLE_DONE_KEY] = "1"
+
+ done, total, should_finalize = tasks._mobilede_track_refresh_cycle_segment(
+ redis_client,
+ cycle_id="cycle-a",
+ segment={"make_id": "3500", "price_min": "1", "price_max": "5000"},
+ total_segments_hint=2,
+ )
+
+ self.assertEqual(done, 2)
+ self.assertEqual(total, 2)
+ self.assertTrue(should_finalize)
+
+ def test_refresh_cycle_tracking_ignores_stale_cycle_id(self) -> None:
+ redis_client = self._FakeRedis()
+ redis_client.store[tasks.MOBILEDE_REFRESH_CYCLE_ID_KEY] = "cycle-active"
+ redis_client.store[tasks.MOBILEDE_REFRESH_CYCLE_TOTAL_KEY] = "2"
+ redis_client.store[tasks.MOBILEDE_REFRESH_CYCLE_DONE_KEY] = "0"
+
+ done, total, should_finalize = tasks._mobilede_track_refresh_cycle_segment(
+ redis_client,
+ cycle_id="cycle-old",
+ segment={"make_id": "3500", "price_min": "1", "price_max": "5000"},
+ total_segments_hint=2,
+ )
+
+ self.assertEqual(done, 0)
+ self.assertEqual(total, 2)
+ self.assertFalse(should_finalize)
+
+ def test_pre_split_mileage_requires_explicit_flag(self) -> None:
+ original_split = tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE
+ try:
+ tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = False
+ self.assertFalse(
+ tasks._mobilede_should_pre_split_mileage(
+ price_min=30001,
+ price_max=50000,
+ year_min=2010,
+ year_max=2014,
+ )
+ )
+ finally:
+ tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = original_split
+
+ def test_pre_split_mileage_respects_explicit_override(self) -> None:
+ original_split = tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE
+ try:
+ tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = True
+ self.assertTrue(
+ tasks._mobilede_should_pre_split_mileage(
+ price_min=30001,
+ price_max=50000,
+ year_min=2010,
+ year_max=2017,
+ )
+ )
+ finally:
+ tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = original_split
+
+ def test_filtered_multi_make_url_expands_to_make_price_year_segments(self) -> None:
+ search_url = (
+ "https://www.mobile.de/ru/????????????????????????-????????????????/??????????.html"
+ "?isSearchRequest=true&s=Car&vc=Car&ms=3500&ms=11000&od=up&sb=rel&ref=dsp"
+ )
+
+ original_split = tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE
+ original_dynamic = tasks.MOBILEDE_DYNAMIC_SEGMENT_PROBES
+ try:
+ tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = False
+ tasks.MOBILEDE_DYNAMIC_SEGMENT_PROBES = False
+ segments = tasks._expand_mobilede_search_url_segment(
+ {
+ "label": "Cars",
+ "search_url": search_url,
+ "start_page": 1,
+ "max_pages": tasks.MOBILEDE_MAX_PAGE_NUMBER,
+ }
+ )
+ finally:
+ tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = original_split
+ tasks.MOBILEDE_DYNAMIC_SEGMENT_PROBES = original_dynamic
+
+ make_counts: dict[str, int] = {}
+ for segment in segments:
+ make_id = str(segment.get("make_id") or "")
+ make_counts[make_id] = make_counts.get(make_id, 0) + 1
+ self.assertIsNone(segment.get("mileage_min"))
+ self.assertIsNone(segment.get("mileage_max"))
+
+ self.assertEqual(make_counts, {"3500": 59, "11000": 59})
+ self.assertEqual(len(segments), 118)
+ self.assertTrue(all("p=" in str(segment.get("search_url")) for segment in segments))
+ self.assertTrue(all("fr=" in str(segment.get("search_url")) for segment in segments))
+ self.assertTrue(all("ml=" not in str(segment.get("search_url")) for segment in segments))
+ self.assertTrue(all(str(segment.get("max_pages")) == str(tasks.MOBILEDE_MAX_PAGE_NUMBER) for segment in segments))
+
+
+ def test_build_runtime_segments_expands_filtered_search_urls_by_default(self) -> None:
+ settings = MagicMock()
+ settings.listing.filtered_search_urls = [
+ "https://www.mobile.de/ru/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&ms=11000"
+ ]
+
+ segments = tasks._build_mobilede_runtime_segments(settings)
+
+ self.assertEqual(len(segments), 118)
+ self.assertEqual({str(segment.get("make_id")) for segment in segments}, {"3500", "11000"})
+ self.assertTrue(all("p=" in str(segment.get("search_url")) for segment in segments))
+ self.assertTrue(all("fr=" in str(segment.get("search_url")) for segment in segments))
+ self.assertTrue(all("ml=" not in str(segment.get("search_url")) for segment in segments))
+
+ def test_build_runtime_segments_can_fast_start_filtered_search_urls_when_enabled(self) -> None:
+ import os
+
+ settings = MagicMock()
+ settings.listing.filtered_search_urls = [
+ "https://www.mobile.de/ru/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&ms=11000"
+ ]
+
+ previous = os.environ.get("MOBILEDE_FILTERED_URL_FAST_START")
+ try:
+ os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = "true"
+ segments = tasks._build_mobilede_runtime_segments(settings)
+ finally:
+ if previous is None:
+ os.environ.pop("MOBILEDE_FILTERED_URL_FAST_START", None)
+ else:
+ os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = previous
+
+ self.assertEqual(len(segments), 2)
+ self.assertEqual({str(segment.get("make_id")) for segment in segments}, {"3500", "11000"})
+ self.assertTrue(all("p=" not in str(segment.get("search_url")) for segment in segments))
+ self.assertTrue(all("fr=" not in str(segment.get("search_url")) for segment in segments))
+ self.assertTrue(all("ml=" not in str(segment.get("search_url")) for segment in segments))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_worker_tasks.py b/tests/test_worker_tasks.py
deleted file mode 100644
index 664f29c..0000000
--- a/tests/test_worker_tasks.py
+++ /dev/null
@@ -1,615 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-from dataclasses import replace
-import tempfile
-import unittest
-from unittest.mock import MagicMock, patch
-
-from mobilede_scraper.worker import tasks
-from mobilede_scraper.core.config import Settings, settings as base_settings
-
-
-class TestWorkerTaskLockHelpers(unittest.TestCase):
- def test_build_listing_segments_from_runtime_config_brands(self) -> None:
- with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as fh:
- json.dump({"filters": {"brands": ["Toyota", "Lexus", "Toyota"]}}, fh)
- runtime_config_file = fh.name
-
- settings = Settings(runtime_config_file=runtime_config_file)
- settings.listing.listing_segments_json = "runtime"
-
- segs = tasks._build_listing_segments(settings)
-
- toyota = [s for s in segs if s["make"] == "TOYOTA"]
- lexus = [s for s in segs if s["make"] == "LEXUS"]
- self.assertEqual(len(toyota), 3)
- self.assertEqual(len(lexus), 1)
- self.assertIsNone(lexus[0]["year_min"])
- os.unlink(runtime_config_file)
-
- def test_lock_acquire_refresh_release(self) -> None:
- redis_client = MagicMock()
-
- # Acquire.
- redis_client.set.return_value = True
- self.assertTrue(tasks._acquire_lock(redis_client, "lock:key", "owner-token", 120))
- redis_client.set.assert_called_once_with("lock:key", "owner-token", nx=True, ex=120)
-
- # Refresh.
- redis_client.eval.return_value = 1
- self.assertTrue(tasks._refresh_lock_if_owner(redis_client, "lock:key", "owner-token", 120))
-
- # Release.
- redis_client.eval.reset_mock()
- tasks._release_lock_if_owner(redis_client, "lock:key", "owner-token")
- args = redis_client.eval.call_args[0]
- self.assertEqual(args[2], "lock:key")
- self.assertEqual(args[3], "owner-token")
-
- def test_sync_listing_task_skips_when_lock_not_acquired(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=False):
- persistence = MagicMock()
- get_persistence.return_value = persistence
- get_redis.return_value = MagicMock()
-
- tasks.sync_listing_task.push_request(id="task-123")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- persistence.create_tables.assert_called_once()
- self.assertEqual(result["status"], "skipped")
- self.assertEqual(result["reason"], "sync_already_running")
-
- def test_sync_listing_task_releases_owned_lock(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.sync_listing_task, "update_state"), \
- patch.object(tasks, "_run_browser_job", return_value={
- "run_id": 7,
- "cars_upserted": 2,
- "cars_failed": 0,
- "images_upserted": 4,
- "skipped_existing": 1,
- "elapsed_seconds": 1.25,
- }):
- persistence = MagicMock()
- get_persistence.return_value = persistence
- redis_client = MagicMock()
- redis_client.get.return_value = None
- get_redis.return_value = redis_client
- stop_event = MagicMock()
- heartbeat_thread = MagicMock()
- start_heartbeat.return_value = (stop_event, heartbeat_thread)
-
- tasks.sync_listing_task.push_request(id="task-123")
- try:
- result = tasks.sync_listing_task.run(make="Toyota")
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "success")
- stop_event.set.assert_called_once()
- heartbeat_thread.join.assert_called_once()
- release_lock.assert_called_once()
-
- def test_clear_orphan_sync_listing_lock(self) -> None:
- # Нет запущенных задач → удаляет.
- redis_client = MagicMock()
- redis_client.get.return_value = "owner-token"
- redis_client.ttl.return_value = 120
- celery_app = MagicMock()
- inspector = MagicMock()
- inspector.active.return_value = {"worker@node": []}
- inspector.reserved.return_value = {"worker@node": []}
- inspector.scheduled.return_value = {"worker@node": []}
- celery_app.control.inspect.return_value = inspector
-
- self.assertTrue(tasks._clear_orphan_sync_listing_lock(redis_client, celery_app))
- redis_client.delete.assert_called_once_with(tasks.SYNC_LISTING_LOCK_KEY)
-
- # Задача активна → не удаляет.
- redis_client2 = MagicMock()
- redis_client2.get.return_value = "owner-token"
- inspector2 = MagicMock()
- inspector2.active.return_value = {"worker@node": [{"name": tasks.SYNC_LISTING_TASK_NAME}]}
- inspector2.reserved.return_value = {"worker@node": []}
- inspector2.scheduled.return_value = {"worker@node": []}
- celery_app2 = MagicMock()
- celery_app2.control.inspect.return_value = inspector2
-
- self.assertFalse(tasks._clear_orphan_sync_listing_lock(redis_client2, celery_app2))
- redis_client2.delete.assert_not_called()
-
- def test_sync_listing_task_recovers_orphan_lock_and_runs(self) -> None:
- with patch.object(tasks, "_get_persistence") as get_persistence, \
- patch.object(tasks, "_get_redis") as get_redis, \
- patch.object(tasks, "_acquire_lock", side_effect=[False, True]) as acquire_lock, \
- patch.object(tasks, "_clear_orphan_sync_listing_lock", return_value=True) as clear_orphan, \
- 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.sync_listing_task, "update_state"), \
- patch.object(tasks, "_run_browser_job", return_value={
- "run_id": 9,
- "cars_upserted": 3,
- "cars_failed": 0,
- "images_upserted": 5,
- "skipped_existing": 0,
- "elapsed_seconds": 2.0,
- }):
- persistence = MagicMock()
- get_persistence.return_value = persistence
- redis_client = MagicMock()
- redis_client.get.return_value = None
- get_redis.return_value = redis_client
- stop_event = MagicMock()
- heartbeat_thread = MagicMock()
- start_heartbeat.return_value = (stop_event, heartbeat_thread)
-
- tasks.sync_listing_task.push_request(id="task-456")
- try:
- result = tasks.sync_listing_task.run(make="Honda")
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "success")
- clear_orphan.assert_called_once()
- self.assertEqual(acquire_lock.call_count, 2)
- release_lock.assert_called_once()
-
- def test_checkpoint_save_load_roundtrip(self) -> None:
- storage: dict[str, str] = {}
-
- def _fake_set(key, value, ex=None):
- storage[key] = value
- return True
-
- redis_client = MagicMock()
- redis_client.set.side_effect = _fake_set
- redis_client.get.side_effect = lambda key: storage.get(key)
-
- tasks._save_last_completed_segment(redis_client, 12)
-
- self.assertEqual(tasks._load_last_completed_segment(redis_client), 12)
- self.assertEqual(redis_client.set.call_args.kwargs["ex"], tasks.SYNC_LISTING_CHECKPOINT_TTL_SECONDS)
-
- def test_load_checkpoint_clears_invalid_payload(self) -> None:
- redis_client = MagicMock()
- redis_client.get.return_value = "{not-a-number"
-
- self.assertIsNone(tasks._load_last_completed_segment(redis_client))
- redis_client.delete.assert_called_once_with(tasks.SYNC_LISTING_CHECKPOINT_KEY)
-
- def test_load_checkpoint_empty_when_missing(self) -> None:
- redis_client = MagicMock()
- redis_client.get.return_value = None
-
- self.assertIsNone(tasks._load_last_completed_segment(redis_client))
- redis_client.delete.assert_not_called()
-
- def test_sync_listing_resumes_from_next_segment_during_bootstrap(self) -> None:
- segments = [
- {"make": "ACURA"},
- {"make": "AUDI"},
- {"make": "BMW"},
- {"make": "EAGLE"},
- ]
- 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=False), \
- patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
- patch.object(tasks, "_release_lock_if_owner") as release_lock, \
- patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
- patch.object(tasks.sync_listing_task, "update_state"), \
- patch("mobilede_scraper.worker.tasks.parse_listing_segments", return_value=segments):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.side_effect = lambda key: (
- "1" if key == tasks.SYNC_LISTING_CHECKPOINT_KEY else None
- )
- get_redis.return_value = redis_client
- start_heartbeat.return_value = (MagicMock(), MagicMock())
-
- sync_segmented_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_segmented = sync_segmented_mock
- scraper_ctx.__exit__.return_value = None
-
- with patch.object(tasks, "MobiledeScraper", return_value=scraper_ctx):
- tasks.sync_listing_task.push_request(id="task-resume-seg")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "success")
- # last_completed=1 → start_segment=2 (AUDI завершён, возобновляем с BMW).
- self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 2)
- self.assertEqual(sync_segmented_mock.call_args.kwargs["start_page"], 1)
- release_lock.assert_called_once()
-
- def test_sync_listing_ignores_checkpoint_after_full_scan_completed(self) -> None:
- segments = [{"make": "ACURA"}, {"make": "AUDI"}]
- 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()), \
- patch.object(tasks.sync_listing_task, "update_state"), \
- patch("mobilede_scraper.worker.tasks.parse_listing_segments", return_value=segments):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- # Оставшийся чекпоинт не должен использоваться.
- redis_client.get.side_effect = lambda key: (
- "0" if key == tasks.SYNC_LISTING_CHECKPOINT_KEY else None
- )
- get_redis.return_value = redis_client
- start_heartbeat.return_value = (MagicMock(), MagicMock())
-
- sync_segmented_mock = MagicMock(return_value={
- "run_id": 14, "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_segmented = sync_segmented_mock
- scraper_ctx.__exit__.return_value = None
-
- with patch.object(tasks, "MobiledeScraper", return_value=scraper_ctx):
- tasks.sync_listing_task.push_request(id="task-792")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "success")
- self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 0)
- self.assertIsNone(sync_segmented_mock.call_args.kwargs["progress_callback"])
- clear_checkpoint.assert_called()
- release_lock.assert_called_once()
-
- def test_sync_listing_checkpoint_beyond_segments_restarts_from_zero(self) -> None:
- segments = [{"make": "ACURA"}, {"make": "AUDI"}]
- 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=False), \
- patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
- patch.object(tasks, "_release_lock_if_owner") as release_lock, \
- patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
- patch.object(tasks.sync_listing_task, "update_state"), \
- patch("mobilede_scraper.worker.tasks.parse_listing_segments", return_value=segments):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.side_effect = lambda key: (
- "99" if key == tasks.SYNC_LISTING_CHECKPOINT_KEY else None
- )
- get_redis.return_value = redis_client
- start_heartbeat.return_value = (MagicMock(), MagicMock())
-
- sync_segmented_mock = MagicMock(return_value={
- "run_id": 15, "status": "success", "full_scan_completed": True,
- "cars_upserted": 0, "cars_failed": 0, "images_upserted": 0,
- "skipped_existing": 0, "elapsed_seconds": 1.0, "failures": [],
- })
- scraper_ctx = MagicMock()
- scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
- scraper_ctx.__exit__.return_value = None
-
- with patch.object(tasks, "MobiledeScraper", return_value=scraper_ctx):
- tasks.sync_listing_task.push_request(id="task-beyond")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "success")
- self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 0)
- release_lock.assert_called_once()
-
- def test_sync_listing_task_clears_checkpoint_on_hourly_run(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", return_value={
- "run_id": 13,
- "status": "partial_success",
- "full_scan_completed": True,
- "cars_upserted": 2,
- "cars_failed": 1,
- "images_upserted": 3,
- "skipped_existing": 0,
- "elapsed_seconds": 4.0,
- "failures": [{"vehicle_url": "v", "error": "e"}],
- }), \
- patch.object(tasks.sync_listing_task, "update_state"):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.return_value = None
- get_redis.return_value = redis_client
- start_heartbeat.return_value = (MagicMock(), MagicMock())
-
- tasks.sync_listing_task.push_request(id="task-791")
- try:
- result = tasks.sync_listing_task.run(make="Toyota")
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "partial_success")
- # Hourly-ветка (full_scan_done=True) всегда удаляет оставшийся чекпоинт
- # до браузерного job + после. Главное — вызов произошёл.
- clear_checkpoint.assert_called()
- release_lock.assert_called_once()
-
- def test_try_set_followup_pending_deduplicates(self) -> None:
- redis_client = MagicMock()
- redis_client.set.side_effect = [True, False]
-
- self.assertTrue(tasks._try_set_followup_pending(redis_client, ttl_seconds=120))
- self.assertFalse(tasks._try_set_followup_pending(redis_client, ttl_seconds=120))
-
- def test_bump_bootstrap_failure_streak_opens_circuit_breaker(self) -> None:
- redis_client = MagicMock()
- redis_client.incr.return_value = tasks.SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT
-
- streak, should_enqueue = tasks._bump_bootstrap_failure_streak(
- redis_client,
- reason="max_retries_exceeded",
- )
-
- self.assertEqual(streak, tasks.SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT)
- self.assertFalse(should_enqueue)
- redis_client.expire.assert_called_once_with(
- tasks.SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY,
- tasks.SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_TTL_SECONDS,
- )
-
- def test_bump_bootstrap_failure_streak_allows_retry_before_limit(self) -> None:
- redis_client = MagicMock()
- redis_client.incr.return_value = 1
-
- streak, should_enqueue = tasks._bump_bootstrap_failure_streak(
- redis_client,
- reason="bootstrap_not_completed",
- )
-
- self.assertEqual(streak, 1)
- self.assertTrue(should_enqueue)
-
- def test_sync_listing_task_does_not_enqueue_followup_after_bootstrap_error_limit(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=False), \
- patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
- patch.object(tasks, "_release_lock_if_owner") as release_lock, \
- patch.object(tasks, "_try_set_followup_pending", return_value=True), \
- patch.object(tasks, "_bump_bootstrap_failure_streak", return_value=(tasks.SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT, False)) as bump_streak, \
- patch.object(tasks, "_clear_followup_pending") as clear_pending, \
- patch.object(tasks.sync_listing_task, "update_state"), \
- patch.object(tasks, "_run_browser_job", return_value={
- "run_id": 99,
- "status": "failed",
- "full_scan_completed": False,
- "cars_upserted": 0,
- "cars_failed": 0,
- "images_upserted": 0,
- "skipped_existing": 0,
- "elapsed_seconds": 1.0,
- "failures": [{"vehicle_url": "listing", "error": "bad resume"}],
- "listing": {"vehicles_collected": 0},
- }):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.return_value = None
- get_redis.return_value = redis_client
- start_heartbeat.return_value = (MagicMock(), MagicMock())
-
- with patch.object(tasks.sync_listing_task, "app", new=MagicMock()) as task_app:
- tasks.sync_listing_task.push_request(id="task-900")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "failed")
- bump_streak.assert_called_once()
- clear_pending.assert_called()
- task_app.send_task.assert_not_called()
-
- def test_sync_listing_task_soft_timeout_deduplicates_continuation(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", return_value=(MagicMock(), MagicMock())), \
- patch.object(tasks, "_release_lock_if_owner"), \
- patch.object(tasks, "_run_browser_job", side_effect=tasks.SoftTimeLimitExceeded()), \
- patch.object(tasks, "_try_set_followup_pending", return_value=False) as set_pending, \
- patch.object(tasks.sync_listing_task, "update_state"):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.return_value = None
- get_redis.return_value = redis_client
-
- with patch.object(tasks.sync_listing_task, "app", new=MagicMock()) as task_app:
- tasks.sync_listing_task.push_request(id="task-soft-timeout")
- try:
- result = tasks.sync_listing_task.run(make="Toyota")
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "timed_out")
- set_pending.assert_called_once()
- task_app.send_task.assert_not_called()
-
- def test_sync_listing_task_stops_immediate_bootstrap_continuation_after_limit(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=False), \
- patch.object(tasks, "_start_lock_heartbeat", return_value=(MagicMock(), MagicMock())), \
- patch.object(tasks, "_release_lock_if_owner"), \
- patch.object(tasks, "_try_set_followup_pending", return_value=True), \
- patch.object(tasks, "_bump_bootstrap_continuation_streak", return_value=(999, False)), \
- patch.object(tasks, "_set_full_scan_done") as set_full_scan_done, \
- patch.object(tasks, "_clear_sync_checkpoint") as clear_checkpoint, \
- patch.object(tasks.sync_listing_task, "update_state"), \
- patch.object(tasks, "_run_browser_job", return_value={
- "run_id": 101,
- "status": "partial_success",
- "full_scan_completed": False,
- "cars_upserted": 2,
- "cars_failed": 0,
- "images_upserted": 1,
- "skipped_existing": 0,
- "elapsed_seconds": 1.0,
- "failures": [],
- "listing": {"vehicles_collected": 2},
- }):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.return_value = None
- get_redis.return_value = redis_client
-
- with patch.object(tasks.sync_listing_task, "app", new=MagicMock()) as task_app:
- tasks.sync_listing_task.push_request(id="task-breaker-stop")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "partial_success")
- # Сначала bootstrap помечается незавершённым, затем breaker переключает на hourly.
- self.assertTrue(any(call.args == (redis_client, False) for call in set_full_scan_done.call_args_list))
- self.assertTrue(any(call.args == (redis_client, True) for call in set_full_scan_done.call_args_list))
- clear_checkpoint.assert_called_once()
- task_app.send_task.assert_not_called()
-
- def test_sync_listing_task_always_full_scan_forces_bootstrap_even_after_done(self) -> None:
- with patch.object(tasks, "_get_persistence") as get_persistence, \
- patch.object(tasks, "_get_redis") as get_redis, \
- patch.object(
- tasks,
- "Settings",
- return_value=replace(
- base_settings,
- discovery=replace(base_settings.discovery, always_full_scan=True),
- ),
- ), \
- 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, "_set_full_scan_done") as set_full_scan_done, \
- patch.object(tasks.sync_listing_task, "update_state"), \
- patch.object(tasks, "_run_browser_job") as run_job, \
- patch("mobilede_scraper.worker.tasks.parse_listing_segments", return_value=[]):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.return_value = None
- get_redis.return_value = redis_client
- start_heartbeat.return_value = (MagicMock(), MagicMock())
-
- run_job.return_value = {
- "run_id": 17,
- "status": "success",
- "full_scan_completed": True,
- "cars_upserted": 1,
- "cars_failed": 0,
- "images_upserted": 0,
- "skipped_existing": 0,
- "elapsed_seconds": 1.0,
- "failures": [],
- }
-
- tasks.sync_listing_task.push_request(id="task-always-full")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "success")
- set_full_scan_done.assert_called_with(redis_client, False)
- release_lock.assert_called_once()
-
- def test_sync_listing_task_always_full_scan_uses_segmented_resume_path(self) -> None:
- segments = [{"make": "TOYOTA"}, {"make": "FORD"}, {"make": "HONDA"}]
- with patch.object(tasks, "_get_persistence") as get_persistence, \
- patch.object(tasks, "_get_redis") as get_redis, \
- patch.object(
- tasks,
- "Settings",
- return_value=replace(
- base_settings,
- discovery=replace(base_settings.discovery, always_full_scan=True),
- ),
- ), \
- 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, "_run_browser_job", side_effect=lambda fn: fn()), \
- patch.object(tasks.sync_listing_task, "update_state"), \
- patch("mobilede_scraper.worker.tasks.parse_listing_segments", return_value=segments):
- get_persistence.return_value = MagicMock()
- redis_client = MagicMock()
- redis_client.get.side_effect = lambda key: (
- "0" if key == tasks.SYNC_LISTING_CHECKPOINT_KEY else None
- )
- get_redis.return_value = redis_client
- start_heartbeat.return_value = (MagicMock(), MagicMock())
-
- sync_segmented_mock = MagicMock(return_value={
- "run_id": 18,
- "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_segmented = sync_segmented_mock
- scraper_ctx.__enter__.return_value.sync_listing = MagicMock()
- scraper_ctx.__exit__.return_value = None
-
- with patch.object(tasks, "MobiledeScraper", return_value=scraper_ctx):
- tasks.sync_listing_task.push_request(id="task-always-full-resume")
- try:
- result = tasks.sync_listing_task.run()
- finally:
- tasks.sync_listing_task.pop_request()
-
- self.assertEqual(result["status"], "success")
- self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 1)
- self.assertEqual(sync_segmented_mock.call_args.kwargs["start_page"], 1)
- release_lock.assert_called_once()
-
-
-if __name__ == "__main__":
- unittest.main()
\ No newline at end of file