update scraper package

This commit is contained in:
qananasikq
2026-04-24 20:46:58 +03:00
parent 6aba4f0dbd
commit d484088e18
59 changed files with 2548 additions and 1272 deletions

View File

@@ -1,51 +1,51 @@
IAAI_HEADLESS=true DUBIZZLE_HEADLESS=true
IAAI_LOG_LEVEL=INFO DUBIZZLE_LOG_LEVEL=INFO
IAAI_CAPTURE_SAME_ORIGIN_ONLY=true DUBIZZLE_CAPTURE_SAME_ORIGIN_ONLY=true
IAAI_MAX_CAPTURED_REQUESTS=40 DUBIZZLE_MAX_CAPTURED_REQUESTS=40
IAAI_MAX_CAPTURED_JSON_RESPONSES=20 DUBIZZLE_MAX_CAPTURED_JSON_RESPONSES=20
IAAI_CARS_LISTING_URL=https://www.iaai.com/Vehiclelisting/Cars DUBIZZLE_CARS_LISTING_URL=https://uae.dubizzle.com/motors/used-cars/
IAAI_LISTING_SEGMENTS=auto DUBIZZLE_LISTING_SEGMENTS=auto
IAAI_MAX_PAGES_PER_RUN=999999 DUBIZZLE_MAX_PAGES_PER_RUN=999999
IAAI_MAX_VEHICLES_PER_RUN=999999 DUBIZZLE_MAX_VEHICLES_PER_RUN=999999
IAAI_PAGE_LINK_LIMIT=999999 DUBIZZLE_PAGE_LINK_LIMIT=999999
IAAI_INCLUDE_PAGINATION=true DUBIZZLE_INCLUDE_PAGINATION=true
IAAI_COLLECT_CURRENT_PAGE_ONLY=false DUBIZZLE_COLLECT_CURRENT_PAGE_ONLY=false
IAAI_HUMAN_PACE_ENABLED=true DUBIZZLE_HUMAN_PACE_ENABLED=true
IAAI_PARALLEL_TABS=10 DUBIZZLE_PARALLEL_TABS=10
CELERY_BATCH_SIZE=100 CELERY_BATCH_SIZE=100
IAAI_AFTER_LISTING_OPEN_MIN_S=0.2 DUBIZZLE_AFTER_LISTING_OPEN_MIN_S=0.2
IAAI_AFTER_LISTING_OPEN_MAX_S=0.5 DUBIZZLE_AFTER_LISTING_OPEN_MAX_S=0.5
IAAI_AFTER_FILTER_ACTION_MIN_S=0.2 DUBIZZLE_AFTER_FILTER_ACTION_MIN_S=0.2
IAAI_AFTER_FILTER_ACTION_MAX_S=0.5 DUBIZZLE_AFTER_FILTER_ACTION_MAX_S=0.5
IAAI_BEFORE_VEHICLE_OPEN_MIN_S=0.02 DUBIZZLE_BEFORE_VEHICLE_OPEN_MIN_S=0.02
IAAI_BEFORE_VEHICLE_OPEN_MAX_S=0.08 DUBIZZLE_BEFORE_VEHICLE_OPEN_MAX_S=0.08
IAAI_AFTER_VEHICLE_OPEN_MIN_S=0.02 DUBIZZLE_AFTER_VEHICLE_OPEN_MIN_S=0.02
IAAI_AFTER_VEHICLE_OPEN_MAX_S=0.08 DUBIZZLE_AFTER_VEHICLE_OPEN_MAX_S=0.08
IAAI_BETWEEN_VEHICLES_MIN_S=0.02 DUBIZZLE_BETWEEN_VEHICLES_MIN_S=0.02
IAAI_BETWEEN_VEHICLES_MAX_S=0.08 DUBIZZLE_BETWEEN_VEHICLES_MAX_S=0.08
IAAI_AFTER_PAGE_CHANGE_MIN_S=0.2 DUBIZZLE_AFTER_PAGE_CHANGE_MIN_S=0.2
IAAI_AFTER_PAGE_CHANGE_MAX_S=0.5 DUBIZZLE_AFTER_PAGE_CHANGE_MAX_S=0.5
IAAI_SYNC_ONLY_NEW=false DUBIZZLE_SYNC_ONLY_NEW=false
IAAI_TOKENS_FILE=/data/tokens.json DUBIZZLE_TOKENS_FILE=/data/tokens.json
IAAI_RUNTIME_CONFIG_FILE=/app/runtime_config.json DUBIZZLE_RUNTIME_CONFIG_FILE=/app/runtime_config.json
IAAI_MAX_RETRIES=5 DUBIZZLE_MAX_RETRIES=5
IAAI_RETRY_DELAY_SECONDS=4 DUBIZZLE_RETRY_DELAY_SECONDS=4
IAAI_RETRY_BACKOFF_MULTIPLIER=2.0 DUBIZZLE_RETRY_BACKOFF_MULTIPLIER=2.0
IAAI_RETRY_JITTER_SECONDS=0.5 DUBIZZLE_RETRY_JITTER_SECONDS=0.5
IAAI_TIMEOUT_MS=90000 DUBIZZLE_TIMEOUT_MS=90000
IAAI_FALLBACK_NAV_TIMEOUT_MS=30000 DUBIZZLE_FALLBACK_NAV_TIMEOUT_MS=30000
IAAI_DATABASE_URL=postgresql+psycopg2://iaai:iaai@postgres:5432/iaai_scraper DUBIZZLE_DATABASE_URL=postgresql+psycopg2://dubizzle:dubizzle@postgres:5432/dubizzle_scraper
IAAI_DATABASE_ECHO=false DUBIZZLE_DATABASE_ECHO=false
IAAI_DATABASE_POOL_SIZE=5 DUBIZZLE_DATABASE_POOL_SIZE=5
IAAI_DATABASE_MAX_OVERFLOW=5 DUBIZZLE_DATABASE_MAX_OVERFLOW=5
IAAI_REDIS_URL=redis://redis:6379/0 DUBIZZLE_REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/0 CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0
@@ -55,6 +55,13 @@ CELERY_BROKER_VISIBILITY_TIMEOUT=90000
CELERY_WORKER_CONCURRENCY=1 CELERY_WORKER_CONCURRENCY=1
CELERY_BEAT_SYNC_INTERVAL_MINUTES=60 CELERY_BEAT_SYNC_INTERVAL_MINUTES=60
CELERY_BEAT_SYNC_LIMIT=0 CELERY_BEAT_SYNC_LIMIT=0
IAAI_ALWAYS_FULL_SCAN=true DUBIZZLE_ALWAYS_FULL_SCAN=true
CELERY_PARALLEL_SEGMENTS=true
DUBIZZLE_DISCOVERY_MODE=algolia
DUBIZZLE_ALGOLIA_APPLICATION_ID=WD0PTZ13ZS
DUBIZZLE_ALGOLIA_API_KEY=cef139620248f1bc328a00fddc7107a6
DUBIZZLE_ALGOLIA_INDEX_NAME=motors.com
DUBIZZLE_ALGOLIA_BASE_URL=https://WD0PTZ13ZS-dsn.algolia.net
DUBIZZLE_SITEMAP_FALLBACK_ON_EMPTY=false
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT=6 SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT=6
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS=21600 SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS=21600

View File

@@ -1,8 +1,43 @@
FROM mcr.microsoft.com/playwright/python:v1.58.0-noble FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
bash \
build-essential \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
wget \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system app && useradd --system --gid app --create-home app RUN groupadd --system app && useradd --system --gid app --create-home app
WORKDIR /app WORKDIR /app
@@ -19,7 +54,8 @@ RUN python -m playwright install chromium firefox
COPY . . COPY . .
RUN pip install --no-cache-dir -e . RUN pip install --no-cache-dir -e .
RUN python -m compileall -q iaai_scraper RUN python -m compileall -q dubizzle_scraper
RUN sed -i 's/\r$//' /app/entrypoint.sh
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
RUN chown -R app:app /app RUN chown -R app:app /app
@@ -29,4 +65,4 @@ USER app
# По умолчанию запускаем API. # По умолчанию запускаем API.
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
CMD ["uvicorn", "iaai_scraper.api.app:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "dubizzle_scraper.api.app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -3,7 +3,7 @@
[alembic] [alembic]
script_location = alembic script_location = alembic
prepend_sys_path = . prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg2://iaai:iaai@localhost:5432/iaai_scraper sqlalchemy.url = postgresql+psycopg2://dubizzle:dubizzle@localhost:5432/dubizzle_scraper
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic

View File

@@ -10,8 +10,8 @@ from sqlalchemy import engine_from_config, pool
# Добавляем корень проекта в sys.path # Добавляем корень проекта в sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from iaai_scraper.core.config import Settings from dubizzle_scraper.core.config import Settings
from iaai_scraper.storage.models import Base from dubizzle_scraper.storage.models import Base
config = context.config config = context.config

View File

@@ -11,7 +11,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Таблица cars — аукционные машины с IAAI # Таблица cars — аукционные машины с DUBIZZLE
op.create_table( op.create_table(
"cars", "cars",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),

View File

@@ -10,12 +10,12 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Partial index для активных машин IAAI (is_sold = FALSE) # Partial index для активных машин DUBIZZLE (is_sold = FALSE)
# Ускоряет поиск при mark_sold операциях # Ускоряет поиск при mark_sold операциях
op.execute( op.execute(
"CREATE INDEX IF NOT EXISTS ix_cars_active_iaai " "CREATE INDEX IF NOT EXISTS ix_cars_active_dubizzle "
"ON cars (origin_url) " "ON cars (origin_url) "
"WHERE is_sold = FALSE AND origin_id LIKE 'iaai:%'" "WHERE is_sold = FALSE AND origin_id LIKE 'dubizzle:%'"
) )
# Composite index для проверки дубликатов изображений # Composite index для проверки дубликатов изображений
@@ -35,4 +35,4 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_cars_origin_url_id") op.execute("DROP INDEX IF EXISTS ix_cars_origin_url_id")
op.drop_index("ix_images_car_id_fullres", table_name="images") op.drop_index("ix_images_car_id_fullres", table_name="images")
op.execute("DROP INDEX IF EXISTS ix_cars_active_iaai") op.execute("DROP INDEX IF EXISTS ix_cars_active_dubizzle")

View File

@@ -1,35 +1,40 @@
x-app-env: &app-env x-app-env: &app-env
# Всегда используем Postgres. # Всегда используем Postgres.
IAAI_DATABASE_URL: postgresql+psycopg2://iaai:iaai@postgres:5432/iaai_scraper DUBIZZLE_DATABASE_URL: postgresql+psycopg2://dubizzle:dubizzle@postgres:5432/dubizzle_scraper
IAAI_REDIS_URL: ${IAAI_REDIS_URL:-redis://redis:6379/0} DUBIZZLE_REDIS_URL: ${DUBIZZLE_REDIS_URL:-redis://redis:6379/0}
CELERY_BROKER_URL: ${CELERY_BROKER_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} CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
IAAI_DATABASE_POOL_RECYCLE_SECONDS: ${IAAI_DATABASE_POOL_RECYCLE_SECONDS:-1800} DUBIZZLE_DATABASE_POOL_RECYCLE_SECONDS: ${DUBIZZLE_DATABASE_POOL_RECYCLE_SECONDS:-1800}
CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-900} CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-900}
CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-1200} CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-1200}
CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-2400} CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-2400}
# 0 = без лимита. # 0 = без лимита.
CELERY_BEAT_SYNC_LIMIT: ${CELERY_BEAT_SYNC_LIMIT:-0} CELERY_BEAT_SYNC_LIMIT: ${CELERY_BEAT_SYNC_LIMIT:-0}
CELERY_PARALLEL_SEGMENTS: ${CELERY_PARALLEL_SEGMENTS:-true}
CELERY_WORKER_MAX_TASKS_PER_CHILD: ${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5} CELERY_WORKER_MAX_TASKS_PER_CHILD: ${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5}
CELERY_BATCH_SIZE: ${CELERY_BATCH_SIZE:-200} CELERY_BATCH_SIZE: ${CELERY_BATCH_SIZE:-200}
IAAI_INTER_BATCH_DELAY_SECONDS: ${IAAI_INTER_BATCH_DELAY_SECONDS:-0.3} DUBIZZLE_INTER_BATCH_DELAY_SECONDS: ${DUBIZZLE_INTER_BATCH_DELAY_SECONDS:-0.3}
IAAI_FAIL_RATE_THRESHOLD: ${IAAI_FAIL_RATE_THRESHOLD:-0.9} DUBIZZLE_FAIL_RATE_THRESHOLD: ${DUBIZZLE_FAIL_RATE_THRESHOLD:-0.9}
IAAI_PARALLEL_TABS: ${IAAI_PARALLEL_TABS:-8} DUBIZZLE_PARALLEL_TABS: ${DUBIZZLE_PARALLEL_TABS:-8}
IAAI_BLOCK_RESOURCES: ${IAAI_BLOCK_RESOURCES:-true} DUBIZZLE_BLOCK_RESOURCES: ${DUBIZZLE_BLOCK_RESOURCES:-true}
IAAI_LISTING_SEGMENTS: ${IAAI_LISTING_SEGMENTS:-auto} DUBIZZLE_DISCOVERY_MODE: ${DUBIZZLE_DISCOVERY_MODE:-algolia}
IAAI_MAX_PAGES_PER_RUN: ${IAAI_MAX_PAGES_PER_RUN:-9999} DUBIZZLE_LISTING_SEGMENTS: ${DUBIZZLE_LISTING_SEGMENTS:-auto}
IAAI_MAX_VEHICLES_PER_RUN: ${IAAI_MAX_VEHICLES_PER_RUN:-50000} DUBIZZLE_CARS_LISTING_URL: ${DUBIZZLE_CARS_LISTING_URL:-https://dubai.dubizzle.com/motors/used-cars/}
IAAI_ALWAYS_FULL_SCAN: ${IAAI_ALWAYS_FULL_SCAN:-true} DUBIZZLE_MAX_PAGES_PER_RUN: ${DUBIZZLE_MAX_PAGES_PER_RUN:-9999}
IAAI_HUMAN_PACE_ENABLED: ${IAAI_HUMAN_PACE_ENABLED:-true} DUBIZZLE_MAX_VEHICLES_PER_RUN: ${DUBIZZLE_MAX_VEHICLES_PER_RUN:-50000}
IAAI_TOKENS_FILE: ${IAAI_TOKENS_FILE:-/data/tokens.json} DUBIZZLE_ALWAYS_FULL_SCAN: ${DUBIZZLE_ALWAYS_FULL_SCAN:-true}
IAAI_RUNTIME_CONFIG_FILE: ${IAAI_RUNTIME_CONFIG_FILE:-/app/runtime_config.json} # Работаем через Algolia; fallback в sitemap по умолчанию отключён.
IAAI_BROWSER_ENGINE: chromium DUBIZZLE_SITEMAP_FALLBACK_ON_EMPTY: ${DUBIZZLE_SITEMAP_FALLBACK_ON_EMPTY:-false}
DUBIZZLE_HUMAN_PACE_ENABLED: ${DUBIZZLE_HUMAN_PACE_ENABLED:-true}
DUBIZZLE_TOKENS_FILE: ${DUBIZZLE_TOKENS_FILE:-/data/tokens.json}
DUBIZZLE_RUNTIME_CONFIG_FILE: ${DUBIZZLE_RUNTIME_CONFIG_FILE:-/app/runtime_config.json}
DUBIZZLE_BROWSER_ENGINE: chromium
# Self-heal для worker. # Self-heal для worker.
IAAI_SELF_HEAL_ENABLED: ${IAAI_SELF_HEAL_ENABLED:-true} DUBIZZLE_SELF_HEAL_ENABLED: ${DUBIZZLE_SELF_HEAL_ENABLED:-true}
IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS: ${IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS:-30} DUBIZZLE_SELF_HEAL_CHECK_INTERVAL_SECONDS: ${DUBIZZLE_SELF_HEAL_CHECK_INTERVAL_SECONDS:-30}
IAAI_SELF_HEAL_STALL_SECONDS: ${IAAI_SELF_HEAL_STALL_SECONDS:-900} DUBIZZLE_SELF_HEAL_STALL_SECONDS: ${DUBIZZLE_SELF_HEAL_STALL_SECONDS:-900}
IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS: ${IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS:-300} DUBIZZLE_SELF_HEAL_STARTUP_GRACE_SECONDS: ${DUBIZZLE_SELF_HEAL_STARTUP_GRACE_SECONDS:-300}
IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS: ${IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS:-300} DUBIZZLE_SELF_HEAL_RESTART_COOLDOWN_SECONDS: ${DUBIZZLE_SELF_HEAL_RESTART_COOLDOWN_SECONDS:-300}
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
x-env-file: &env-file x-env-file: &env-file
@@ -42,29 +47,32 @@ x-app-service: &app-service
environment: *app-env environment: *app-env
volumes: volumes:
- ./runtime_config.json:/app/runtime_config.json:ro - ./runtime_config.json:/app/runtime_config.json:ro
- ./artifacts:/app/artifacts
x-worker-service: &worker-service x-worker-service: &worker-service
<<: *app-service <<: *app-service
volumes: volumes:
- ./runtime_config.json:/app/runtime_config.json:ro - ./runtime_config.json:/app/runtime_config.json:ro
- ./artifacts:/app/artifacts
- tokens_data:/data - tokens_data:/data
services: services:
# PostgreSQL. # PostgreSQL.
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: iaai-postgres container_name: dubizzle-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: iaai POSTGRES_USER: dubizzle
POSTGRES_PASSWORD: iaai POSTGRES_PASSWORD: dubizzle
POSTGRES_DB: iaai_scraper POSTGRES_DB: dubizzle_scraper
ports: ports:
- "127.0.0.1:5432:5432" # Хост-порт вынесен в env, чтобы избежать конфликтов с другими проектами.
- "127.0.0.1:${DUBIZZLE_POSTGRES_HOST_PORT:-15432}:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U iaai -d iaai_scraper"] test: ["CMD-SHELL", "pg_isready -U dubizzle -d dubizzle_scraper"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
@@ -77,10 +85,11 @@ services:
# Redis. # Redis.
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: iaai-redis container_name: dubizzle-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:6379:6379" # Хост-порт вынесен в env, чтобы избежать конфликтов с другими проектами.
- "127.0.0.1:${DUBIZZLE_REDIS_HOST_PORT:-16379}:6379"
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
@@ -101,7 +110,7 @@ services:
# Миграции. # Миграции.
migrate: migrate:
<<: *app-service <<: *app-service
container_name: iaai-migrate container_name: dubizzle-migrate
restart: "no" restart: "no"
depends_on: depends_on:
postgres: postgres:
@@ -111,17 +120,18 @@ services:
# API. # API.
api: api:
<<: *app-service <<: *app-service
container_name: iaai-api container_name: dubizzle-api
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:8000:8000" # Хост-порт вынесен в env, чтобы избежать конфликтов с другими проектами.
- "127.0.0.1:${DUBIZZLE_API_HOST_PORT:-18000}:8000"
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
redis: redis:
condition: service_healthy condition: service_healthy
command: > command: >
uvicorn iaai_scraper.api.app:app uvicorn dubizzle_scraper.api.app:app
--host 0.0.0.0 --port 8000 --workers 1 --host 0.0.0.0 --port 8000 --workers 1
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"]
@@ -148,13 +158,13 @@ services:
condition: service_healthy condition: service_healthy
stop_grace_period: 60s stop_grace_period: 60s
command: > command: >
celery -A iaai_scraper.worker.celery_app worker celery -A dubizzle_scraper.worker.celery_app worker
--loglevel=info --concurrency=1 --pool=solo --loglevel=info --concurrency=1 --pool=solo
--pidfile=/tmp/celery-worker.pid --pidfile=/tmp/celery-worker.pid
-Q scraping --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5} -Q scraping --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5}
healthcheck: healthcheck:
# Проверка worker. # Проверка worker.
test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid) && pgrep -f 'iaai_scraper.worker.self_heal' >/dev/null"] test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid) && pgrep -f 'dubizzle_scraper.worker.self_heal' >/dev/null"]
interval: 60s interval: 60s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -168,7 +178,7 @@ services:
# Beat. # Beat.
beat: beat:
<<: *worker-service <<: *worker-service
container_name: iaai-beat container_name: dubizzle-beat
restart: unless-stopped restart: unless-stopped
init: true init: true
depends_on: depends_on:
@@ -177,7 +187,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
command: > command: >
celery -A iaai_scraper.worker.celery_app beat celery -A dubizzle_scraper.worker.celery_app beat
--loglevel=info --loglevel=info
--pidfile=/tmp/celery-beat.pid --pidfile=/tmp/celery-beat.pid
--schedule=/tmp/celerybeat-schedule --schedule=/tmp/celerybeat-schedule
@@ -193,6 +203,25 @@ services:
max-size: "10m" max-size: "10m"
max-file: "5" max-file: "5"
# Разовый прогон sync-listing через Docker CLI.
sync:
<<: *worker-service
container_name: dubizzle-sync
restart: "no"
profiles: ["manual"]
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
command: >
bash -lc "dubizzle sync-listing --limit ${DUBIZZLE_SYNC_LISTING_LIMIT:-100} --output /app/artifacts/json/docker_sync_listing.json"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes: volumes:
pgdata: pgdata:
redisdata: redisdata:

View File

@@ -20,8 +20,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
_settings = settings or Settings() _settings = settings or Settings()
app = FastAPI( app = FastAPI(
title="IAAI Scraper API", title="Dubizzle Scraper API",
description="REST API для управления задачами скрапинга IAAI и просмотра данных", description="REST API для управления задачами скрапинга Dubizzle и просмотра данных",
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
) )

View File

@@ -9,7 +9,7 @@ from ..deps import get_persistence
from ...storage.db import PersistenceService from ...storage.db import PersistenceService
router = APIRouter() router = APIRouter()
logger = logging.getLogger("iaai_scraper.api.health") logger = logging.getLogger("dubizzle_scraper.api.health")
@router.get("/health") @router.get("/health")
@@ -25,6 +25,6 @@ def health_check(persistence: PersistenceService = Depends(get_persistence)):
return { return {
"status": "ok" if db_ok else "degraded", "status": "ok" if db_ok else "degraded",
"service": "iaai-scraper-api", "service": "dubizzle-scraper-api",
"database": "connected" if db_ok else "unavailable", "database": "connected" if db_ok else "unavailable",
} }

View File

@@ -15,13 +15,13 @@ router = APIRouter()
class SyncVehicleRequest(BaseModel): class SyncVehicleRequest(BaseModel):
vehicle_url: str vehicle_url: str
lane: str = "iaai" lane: str = "dubizzle"
class SyncListingRequest(BaseModel): class SyncListingRequest(BaseModel):
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
lane: str = "iaai_cars" lane: str = "dubizzle_cars"
limit: int | None = None limit: int | None = None
only_new: bool | None = None only_new: bool | None = None

View File

@@ -11,7 +11,7 @@ except ImportError:
from ..core.config import Settings from ..core.config import Settings
logger = logging.getLogger("iaai_scraper.browser") logger = logging.getLogger("dubizzle_scraper.browser")
def _build_init_script() -> str: def _build_init_script() -> str:
@@ -72,11 +72,11 @@ class BrowserFactory:
# Выбор движка. # Выбор движка.
engine = self.settings.browser_engine.strip().lower() engine = self.settings.browser_engine.strip().lower()
if engine == "auto": if engine == "auto":
# Для IAAI стабильнее Chromium. # Для DUBIZZLE стабильнее Chromium.
return "chromium" return "chromium"
if engine in ("firefox", "chromium"): if engine in ("firefox", "chromium"):
return engine return engine
logger.warning("Unknown IAAI_BROWSER_ENGINE=%r, falling back to auto", engine) logger.warning("Unknown DUBIZZLE_BROWSER_ENGINE=%r, falling back to auto", engine)
return "chromium" return "chromium"
def create_browser(self, playwright: Playwright) -> Browser: def create_browser(self, playwright: Playwright) -> Browser:

View File

@@ -11,9 +11,15 @@ from .pace import HumanPacer
from ..core.config import Settings from ..core.config import Settings
from ..core.utils import first_non_empty from ..core.utils import first_non_empty
logger = logging.getLogger("iaai_scraper.listing") logger = logging.getLogger("dubizzle_scraper.listing")
VEHICLE_HREF_RE = re.compile(r"/VehicleDetail/(\d+)(?:~[A-Z]{2})?", re.IGNORECASE) VEHICLE_HREF_RE = re.compile(
VEHICLE_LINK_SELECTOR = "a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']" r'(?:/VehicleDetail/(?P<veh_id>\d+)(?:~[A-Z]{2})?)|(?:/motors/used-cars/[^"\s]+?(?:/ad-(?P<ad_id>\d+)/?|---(?P<slug_id>[a-f0-9]{32})/?))|(?:/s/(?P<short_id>[A-Za-z0-9]+))',
re.IGNORECASE,
)
VEHICLE_LINK_SELECTOR = (
"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail'], "
"a[href*='/motors/used-cars/'], a[href*='/s/']"
)
COOKIE_ACCEPT_SELECTORS: tuple[str, ...] = ( COOKIE_ACCEPT_SELECTORS: tuple[str, ...] = (
"button:has-text('Accept All')", "button:has-text('Accept All')",
"button:has-text('Accept all')", "button:has-text('Accept all')",
@@ -108,7 +114,7 @@ class ListingCollector:
try: try:
page.wait_for_function( page.wait_for_function(
f"""() => {{ f"""() => {{
const a = document.querySelector(\"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']\"); const a = document.querySelector(\"{VEHICLE_LINK_SELECTOR}\");
return a && a.getAttribute('href') !== '{old_first_href}'; return a && a.getAttribute('href') !== '{old_first_href}';
}}""", }}""",
timeout=12000, timeout=12000,
@@ -331,7 +337,10 @@ class ListingCollector:
match = VEHICLE_HREF_RE.search(href) match = VEHICLE_HREF_RE.search(href)
if not match: if not match:
continue continue
lot_number = match.group(1) lot_number = match.group("veh_id") or match.group("ad_id") or match.group("slug_id") or None
if lot_number is None:
short = match.group("short_id")
lot_number = short if short else None
absolute = urljoin(self.settings.home_url, match.group(0)) absolute = urljoin(self.settings.home_url, match.group(0))
if absolute in seen: if absolute in seen:
continue continue
@@ -341,7 +350,7 @@ class ListingCollector:
if len(links) >= self.settings.listing.max_vehicles_per_run: if len(links) >= self.settings.listing.max_vehicles_per_run:
break break
# Fallback: на IAAI ссылки иногда не рендерятся как <a>, # Fallback: на DUBIZZLE ссылки иногда не рендерятся как <a>,
# но присутствуют в hydration/inline JSON внутри HTML (часто как \/VehicleDetail\/"). # но присутствуют в hydration/inline JSON внутри HTML (часто как \/VehicleDetail\/").
if not links: if not links:
try: try:
@@ -381,7 +390,7 @@ class ListingCollector:
seen: set[str] = set() seen: set[str] = set()
for match in VEHICLE_HREF_RE.finditer(normalized): for match in VEHICLE_HREF_RE.finditer(normalized):
lot_number = match.group(1) lot_number = match.group("veh_id") or match.group("ad_id") or match.group("slug_id") or match.group("short_id") or ""
absolute = urljoin(self.settings.home_url, match.group(0)) absolute = urljoin(self.settings.home_url, match.group(0))
if absolute in seen: if absolute in seen:
continue continue
@@ -424,7 +433,7 @@ class ListingCollector:
self.pacer.after_page_change() self.pacer.after_page_change()
return True return True
# Fallback для IAAI: пагинация часто рендерится как набор номеров страниц # Fallback для DUBIZZLE: пагинация часто рендерится как набор номеров страниц
# + стрелка с иконкой, без явного текста Next. # + стрелка с иконкой, без явного текста Next.
try: try:
clicked = bool(page.evaluate( clicked = bool(page.evaluate(
@@ -573,7 +582,7 @@ class ListingCollector:
): ):
page_known = sum( page_known = sum(
1 for item in page_result.vehicle_links 1 for item in page_result.vehicle_links
if item.lot_number and f"iaai:{item.lot_number}" in known_origin_ids if item.lot_number and f"dubizzle:{item.lot_number}" in known_origin_ids
) )
page_new = len(page_result.vehicle_links) - page_known page_new = len(page_result.vehicle_links) - page_known
ratio = page_known / len(page_result.vehicle_links) ratio = page_known / len(page_result.vehicle_links)
@@ -651,6 +660,10 @@ class ListingCollector:
count = locator.count() count = locator.count()
if count == 0: if count == 0:
continue continue
# Тестовые/fake локаторы могут не поддерживать nth/get_attribute.
# В таком случае считаем наличие селектора достаточным признаком next.
if not hasattr(locator, "nth"):
return True
# Проверяем, что хотя бы один элемент не disabled. # Проверяем, что хотя бы один элемент не disabled.
# Disabled "Next" на последней странице не означает наличия следующей. # Disabled "Next" на последней странице не означает наличия следующей.
for i in range(min(count, 3)): for i in range(min(count, 3)):

View File

@@ -8,7 +8,7 @@ from playwright.sync_api import Page, Request, Response
from ..core.config import Settings from ..core.config import Settings
logger = logging.getLogger("iaai_scraper.network") logger = logging.getLogger("dubizzle_scraper.network")
@dataclass @dataclass
@@ -44,7 +44,7 @@ class NetworkCapture:
if not self.settings.capture.capture_same_origin_only or not self._origin: if not self.settings.capture.capture_same_origin_only or not self._origin:
return True return True
netloc = urlparse(url).netloc.lower() netloc = urlparse(url).netloc.lower()
return netloc == self._origin or netloc.endswith(".iaai.com") return netloc == self._origin or netloc.endswith(".dubizzle.com")
def _on_request(self, request: Request) -> None: def _on_request(self, request: Request) -> None:
# Берём только xhr/fetch # Берём только xhr/fetch

View File

@@ -1,13 +1,13 @@
import argparse import argparse
from pathlib import Path from pathlib import Path
from .core.config import Settings from .core.config import Settings, parse_listing_segments
from .core.utils import save_to_json from .core.utils import save_to_json
from .scraper import IAAIScraper from .scraper import DUBIZZLEScraper
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="IAAI scraper CLI") parser = argparse.ArgumentParser(description="Dubizzle scraper CLI")
parser.add_argument("--headless", choices=["true", "false"], default=None, help="Override headless mode") parser.add_argument("--headless", choices=["true", "false"], default=None, help="Override headless mode")
parser.add_argument("--debug", action="store_true", help="Enable DEBUG logging") parser.add_argument("--debug", action="store_true", help="Enable DEBUG logging")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
@@ -19,24 +19,24 @@ def build_parser() -> argparse.ArgumentParser:
listing_parser = subparsers.add_parser("collect-listing", help="Collect vehicle URLs from listing page") listing_parser = subparsers.add_parser("collect-listing", help="Collect vehicle URLs from listing page")
listing_parser.add_argument("--make", default=None) listing_parser.add_argument("--make", default=None)
listing_parser.add_argument("--model", default=None) listing_parser.add_argument("--model", default=None)
listing_parser.add_argument("--output", default=str(default_output_dir / "iaai_listing_links.json")) listing_parser.add_argument("--output", default=str(default_output_dir / "dubizzle_listing_links.json"))
scrape_parser = subparsers.add_parser("scrape-vehicle", help="Scrape a vehicle detail page") scrape_parser = subparsers.add_parser("scrape-vehicle", help="Scrape a vehicle detail page")
scrape_parser.add_argument("vehicle_url") scrape_parser.add_argument("vehicle_url")
scrape_parser.add_argument("--output", default=str(default_output_dir / "iaai_vehicle_detail.json")) scrape_parser.add_argument("--output", default=str(default_output_dir / "dubizzle_vehicle_detail.json"))
sync_vehicle_parser = subparsers.add_parser("sync-vehicle", help="Scrape + upsert one vehicle") sync_vehicle_parser = subparsers.add_parser("sync-vehicle", help="Scrape + upsert one vehicle")
sync_vehicle_parser.add_argument("vehicle_url") sync_vehicle_parser.add_argument("vehicle_url")
sync_vehicle_parser.add_argument("--lane", default="iaai") sync_vehicle_parser.add_argument("--lane", default="dubizzle")
sync_vehicle_parser.add_argument("--output", default=str(default_output_dir / "iaai_sync_vehicle.json")) sync_vehicle_parser.add_argument("--output", default=str(default_output_dir / "dubizzle_sync_vehicle.json"))
sync_listing_parser = subparsers.add_parser("sync-listing", help="Collect listing + sync all vehicles") sync_listing_parser = subparsers.add_parser("sync-listing", help="Collect listing + sync all vehicles")
sync_listing_parser.add_argument("--make", default=None) sync_listing_parser.add_argument("--make", default=None)
sync_listing_parser.add_argument("--model", default=None) sync_listing_parser.add_argument("--model", default=None)
sync_listing_parser.add_argument("--lane", default="iaai_cars") sync_listing_parser.add_argument("--lane", default="dubizzle_cars")
sync_listing_parser.add_argument("--limit", type=int, default=None) sync_listing_parser.add_argument("--limit", type=int, default=None)
sync_listing_parser.add_argument("--only-new", choices=["true", "false"], default=None) sync_listing_parser.add_argument("--only-new", choices=["true", "false"], default=None)
sync_listing_parser.add_argument("--output", default=str(default_output_dir / "iaai_sync_listing.json")) sync_listing_parser.add_argument("--output", default=str(default_output_dir / "dubizzle_sync_listing.json"))
return parser return parser
@@ -53,7 +53,7 @@ def main() -> None:
if args.debug: if args.debug:
runtime_settings.log_level = "DEBUG" runtime_settings.log_level = "DEBUG"
with IAAIScraper(runtime_settings) as scraper: with DUBIZZLEScraper(runtime_settings) as scraper:
if args.command == "init-db": if args.command == "init-db":
data = scraper.init_db() data = scraper.init_db()
print(f"DB initialized: {data}") print(f"DB initialized: {data}")
@@ -66,6 +66,21 @@ def main() -> None:
data = scraper.sync_vehicle(args.vehicle_url, lane=args.lane) data = scraper.sync_vehicle(args.vehicle_url, lane=args.lane)
else: else:
only_new = None if args.only_new is None else args.only_new == "true" only_new = None if args.only_new is None else args.only_new == "true"
segments = parse_listing_segments(scraper.settings.listing.listing_segments_json)
use_segmented = (
bool(segments)
and args.make is None
and args.model is None
and args.limit is None
and scraper.settings.discovery.mode in {"listing", "algolia"}
)
if use_segmented:
data = scraper.sync_listing_segmented(
segments=segments,
lane=args.lane,
only_new=only_new,
)
else:
data = scraper.sync_listing( data = scraper.sync_listing(
make=args.make, make=args.make,
model=args.model, model=args.model,

View File

@@ -0,0 +1,315 @@
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
TRUE_VALUES = {"1", "true", "yes", "on"}
def _resolve_env_value(name: str) -> str | None:
"""Возвращает значение env с поддержкой legacy-префиксов DUBIZZLE_/IAAI_."""
value = os.getenv(name)
if value is not None:
return value
if name.startswith("DUBIZZLE_"):
return os.getenv("IAAI_" + name[len("DUBIZZLE_"):])
return None
# Хелперы для чтения env-переменных с приведением типов
def _env_str(name: str, default: str) -> str:
value = _resolve_env_value(name)
return value if value is not None else default
def _env_optional_str(name: str) -> str | None:
value = _resolve_env_value(name)
if value is None:
return None
value = value.strip()
return value or None
def _env_path_str(name: str) -> str | None:
value = _env_optional_str(name)
if value is None:
return None
return str(Path(value).expanduser())
def _env_bool(name: str, default: bool) -> bool:
fallback = "true" if default else "false"
return _env_str(name, fallback).strip().lower() in TRUE_VALUES
def _env_int(name: str, default: int) -> int:
return int(_env_str(name, str(default)).strip())
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("DUBIZZLE_CAPTURE_SAME_ORIGIN_ONLY", True)
max_requests: int = _env_int("DUBIZZLE_MAX_CAPTURED_REQUESTS", 40)
max_json_responses: int = _env_int("DUBIZZLE_MAX_CAPTURED_JSON_RESPONSES", 30)
# Конфиг пауз между действиями (имитация человека)
@dataclass(slots=True)
class HumanPaceConfig:
enabled: bool = _env_bool("DUBIZZLE_HUMAN_PACE_ENABLED", True)
after_listing_open_min_s: float = _env_float("DUBIZZLE_AFTER_LISTING_OPEN_MIN_S", 0.5)
after_listing_open_max_s: float = _env_float("DUBIZZLE_AFTER_LISTING_OPEN_MAX_S", 1.2)
after_filter_action_min_s: float = _env_float("DUBIZZLE_AFTER_FILTER_ACTION_MIN_S", 0.5)
after_filter_action_max_s: float = _env_float("DUBIZZLE_AFTER_FILTER_ACTION_MAX_S", 1.2)
before_vehicle_open_min_s: float = _env_float("DUBIZZLE_BEFORE_VEHICLE_OPEN_MIN_S", 0.1)
before_vehicle_open_max_s: float = _env_float("DUBIZZLE_BEFORE_VEHICLE_OPEN_MAX_S", 0.3)
after_vehicle_open_min_s: float = _env_float("DUBIZZLE_AFTER_VEHICLE_OPEN_MIN_S", 0.05)
after_vehicle_open_max_s: float = _env_float("DUBIZZLE_AFTER_VEHICLE_OPEN_MAX_S", 0.15)
between_vehicles_min_s: float = _env_float("DUBIZZLE_BETWEEN_VEHICLES_MIN_S", 0.05)
between_vehicles_max_s: float = _env_float("DUBIZZLE_BETWEEN_VEHICLES_MAX_S", 0.15)
after_page_change_min_s: float = _env_float("DUBIZZLE_AFTER_PAGE_CHANGE_MIN_S", 0.8)
after_page_change_max_s: float = _env_float("DUBIZZLE_AFTER_PAGE_CHANGE_MAX_S", 1.8)
# Конфиг сбора листинга (URL, лимиты страниц и машин)
@dataclass(slots=True)
class ListingConfig:
cars_url: str = _env_str("DUBIZZLE_CARS_LISTING_URL", "https://dubai.dubizzle.com/motors/used-cars/")
max_pages_per_run: int = _env_int("DUBIZZLE_MAX_PAGES_PER_RUN", 9999)
max_vehicles_per_run: int = _env_int("DUBIZZLE_MAX_VEHICLES_PER_RUN", 50000)
page_link_limit: int = _env_int("DUBIZZLE_PAGE_LINK_LIMIT", 500)
include_pagination: bool = _env_bool("DUBIZZLE_INCLUDE_PAGINATION", True)
collect_current_page_only: bool = _env_bool("DUBIZZLE_COLLECT_CURRENT_PAGE_ONLY", False)
# Порог раннего останова: если доля уже известных машин на странице >= этого значения,
# прекращаем листать — все новые машины уже найдены. 0 = отключено.
early_stop_threshold: float = _env_float("DUBIZZLE_EARLY_STOP_THRESHOLD", 0.8)
# Сегментация листинга по брендам для обхода лимита пагинации DUBIZZLE (~22 600 машин).
# JSON-массив объектов: [{"make":"TOYOTA"},{"make":"FORD"},...] или "auto" для авто-списка.
# Пустая строка = без сегментации (backward compatible).
listing_segments_json: str = _env_str("DUBIZZLE_LISTING_SEGMENTS", "")
# Пагинационный потолок одного Algolia-запроса: ~100 страниц × 100 = 10 000 результатов.
DUBIZZLE_PAGINATION_CEILING = 10_000
# Авто-сегментация по году. Эти диапазоны подобраны так, чтобы каждый сегмент
# оставался ниже лимита Algolia и собирался быстрее, чем старая make/year схема.
_AUTO_YEAR_SPLITS: tuple[tuple[int, int], ...] = (
(1900, 2012),
(2013, 2015),
(2016, 2017),
(2018, 2019),
(2020, 2021),
(2022, 2023),
(2024, 2025),
(2026, 2027),
)
def parse_listing_segments(raw: str) -> list[dict[str, str | int | None]]:
"""Парсит DUBIZZLE_LISTING_SEGMENTS в список сегментов.
Каждый сегмент — dict с ключами: make (str), year_min/year_max (int|None).
Специальное значение ``"auto"`` генерирует быстрые year-only сегменты,
которые гарантированно проходят через Algolia без упора в лимит ~10k.
"""
raw = raw.strip()
if not raw:
return []
if raw.lower() == "auto":
return [
{"make": None, "year_min": yr_min, "year_max": yr_max}
for yr_min, yr_max in _AUTO_YEAR_SPLITS
]
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)
@dataclass(slots=True)
class DatabaseConfig:
url: str = _env_str("DUBIZZLE_DATABASE_URL", "postgresql+psycopg2://dubizzle:dubizzle@localhost:5432/dubizzle_scraper")
echo: bool = _env_bool("DUBIZZLE_DATABASE_ECHO", False)
pool_size: int = _env_int("DUBIZZLE_DATABASE_POOL_SIZE", 5)
max_overflow: int = _env_int("DUBIZZLE_DATABASE_MAX_OVERFLOW", 10)
pool_recycle_seconds: int = _env_int("DUBIZZLE_DATABASE_POOL_RECYCLE_SECONDS", 1800)
auto_create_tables: bool = _env_bool("DUBIZZLE_DATABASE_AUTO_CREATE_TABLES", False)
# --- Конфиг Redis (URL для Celery broker) ---
@dataclass(slots=True)
class RedisConfig:
url: str = _env_str("DUBIZZLE_REDIS_URL", "redis://localhost:6379/0")
socket_timeout_seconds: float = _env_float("DUBIZZLE_REDIS_SOCKET_TIMEOUT_SECONDS", 10.0)
socket_connect_timeout_seconds: float = _env_float("DUBIZZLE_REDIS_SOCKET_CONNECT_TIMEOUT_SECONDS", 5.0)
health_check_interval_seconds: int = _env_int("DUBIZZLE_REDIS_HEALTH_CHECK_INTERVAL_SECONDS", 30)
# --- Конфиг Discovery (режим обнаружения, hourly batch) ---
@dataclass(slots=True)
class DiscoveryConfig:
mode: str = _env_str("DUBIZZLE_DISCOVERY_MODE", "algolia")
hourly_mode: str = _env_str("DUBIZZLE_HOURLY_MODE", "rolling_refresh")
hourly_refresh_batch_size: int = _env_int("DUBIZZLE_HOURLY_REFRESH_BATCH_SIZE", 500)
always_full_scan: bool = _env_bool("DUBIZZLE_ALWAYS_FULL_SCAN", False)
sitemap_fallback_on_empty: bool = _env_bool("DUBIZZLE_SITEMAP_FALLBACK_ON_EMPTY", False)
@dataclass(slots=True)
class AlgoliaConfig:
application_id: str = _env_str("DUBIZZLE_ALGOLIA_APPLICATION_ID", "WD0PTZ13ZS")
api_key: str = _env_str(
"DUBIZZLE_ALGOLIA_API_KEY",
"cef139620248f1bc328a00fddc7107a6",
)
index_name: str = _env_str("DUBIZZLE_ALGOLIA_INDEX_NAME", "motors.com")
category_slug: str = _env_str("DUBIZZLE_ALGOLIA_CATEGORY_SLUG", "motors/used-cars")
base_url: str = _env_str("DUBIZZLE_ALGOLIA_BASE_URL", "https://WD0PTZ13ZS-dsn.algolia.net")
hits_per_page: int = _env_int("DUBIZZLE_ALGOLIA_HITS_PER_PAGE", 100)
# --- Конфиг Celery (лимиты задач, concurrency, beat-расписание) ---
@dataclass(slots=True)
class CeleryConfig:
broker_url: str = _env_str("CELERY_BROKER_URL", "")
result_backend: str = _env_str("CELERY_RESULT_BACKEND", "")
task_soft_time_limit: int = _env_int("CELERY_TASK_SOFT_TIME_LIMIT", 3300)
task_time_limit: int = _env_int("CELERY_TASK_TIME_LIMIT", 3600)
task_stall_timeout_seconds: int = _env_int("CELERY_TASK_STALL_TIMEOUT_SECONDS", 600)
worker_concurrency: int = _env_int("CELERY_WORKER_CONCURRENCY", 4)
worker_max_tasks_per_child: int = _env_int("CELERY_WORKER_MAX_TASKS_PER_CHILD", 5)
broker_visibility_timeout: int = _env_int("CELERY_BROKER_VISIBILITY_TIMEOUT", 7200)
beat_sync_interval_minutes: int = _env_int("CELERY_BEAT_SYNC_INTERVAL_MINUTES", 60)
beat_sync_limit: int | None = _env_int("CELERY_BEAT_SYNC_LIMIT", 0) or None
batch_size: int = _env_int("CELERY_BATCH_SIZE", 50)
parallel_tabs: int = _env_int("DUBIZZLE_PARALLEL_TABS", 8)
parallel_segments: bool = _env_bool("CELERY_PARALLEL_SEGMENTS", False)
block_resources: bool = _env_bool("DUBIZZLE_BLOCK_RESOURCES", True)
# --- Конфиг прокси (server, username, password) ---
@dataclass(slots=True)
class ProxyConfig:
server: str | None = _env_optional_str("DUBIZZLE_PROXY_SERVER")
username: str | None = _env_optional_str("DUBIZZLE_PROXY_USERNAME")
password: str | None = _env_optional_str("DUBIZZLE_PROXY_PASSWORD")
@property
def enabled(self) -> bool:
return bool(self.server)
def to_playwright_dict(self) -> dict[str, str] | None:
if not self.server:
return None
result: dict[str, str] = {"server": self.server}
if self.username:
result["username"] = self.username
if self.password:
result["password"] = self.password
return result
# Главный объект настроек: собирает все блоки конфигурации
@dataclass(slots=True)
class Settings:
home_url: str = "https://www.dubizzle.com/"
default_timeout_ms: int = _env_int("DUBIZZLE_TIMEOUT_MS", 45000)
network_settle_ms: int = _env_int("DUBIZZLE_NETWORK_SETTLE_MS", 400)
fast_path_timeout_ms: int = _env_int("DUBIZZLE_FAST_PATH_TIMEOUT_MS", 5000)
fast_path_max_attempts: int = _env_int("DUBIZZLE_FAST_PATH_MAX_ATTEMPTS", 1)
fallback_navigation_timeout_ms: int = _env_int("DUBIZZLE_FALLBACK_NAV_TIMEOUT_MS", 15000)
max_retries: int = _env_int("DUBIZZLE_MAX_RETRIES", 3)
retry_delay_seconds: float = _env_float("DUBIZZLE_RETRY_DELAY_SECONDS", 2.5)
retry_backoff_multiplier: float = _env_float("DUBIZZLE_RETRY_BACKOFF_MULTIPLIER", 2.0)
retry_jitter_seconds: float = _env_float("DUBIZZLE_RETRY_JITTER_SECONDS", 0.25)
headless: bool = _env_bool("DUBIZZLE_HEADLESS", True)
browser_engine: str = _env_str("DUBIZZLE_BROWSER_ENGINE", "auto")
log_level: str = _env_str("DUBIZZLE_LOG_LEVEL", "INFO")
log_file: str | None = _env_optional_str("DUBIZZLE_LOG_FILE")
enable_trace_id_logs: bool = _env_bool("DUBIZZLE_ENABLE_TRACE_ID_LOGS", True)
sync_only_new: bool = _env_bool("DUBIZZLE_SYNC_ONLY_NEW", False)
raw_output_json: str | None = _env_optional_str("DUBIZZLE_RAW_OUTPUT_JSON")
tokens_file: str | None = _env_path_str("DUBIZZLE_TOKENS_FILE")
runtime_config_file: str | None = _env_path_str("DUBIZZLE_RUNTIME_CONFIG_FILE")
scheduler_interval_minutes: int = _env_int("DUBIZZLE_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)
algolia: AlgoliaConfig = field(default_factory=AlgoliaConfig)
@property
def parallel_tabs(self) -> int:
return self.celery.parallel_tabs
@property
def block_resources(self) -> bool:
return self.celery.block_resources
# Глобальный синглтон — используется по умолчанию во всех модулях.
settings = Settings()

View File

@@ -9,7 +9,7 @@ from playwright.sync_api import Error, TimeoutError as PlaywrightTimeoutError
from .exceptions import AntiBotDetectedError from .exceptions import AntiBotDetectedError
logger = logging.getLogger("iaai_scraper.retry") logger = logging.getLogger("dubizzle_scraper.retry")
# Типы исключений, при которых retry имеет смысл. # Типы исключений, при которых retry имеет смысл.
RETRYABLE_EXCEPTIONS = ( RETRYABLE_EXCEPTIONS = (

View File

@@ -5,7 +5,7 @@ from pathlib import Path
from typing import Any from typing import Any
logger = logging.getLogger("iaai_scraper.runtime_config") logger = logging.getLogger("dubizzle_scraper.runtime_config")
def _normalize_text(value: str) -> str: def _normalize_text(value: str) -> str:

View File

@@ -5,6 +5,11 @@ from .sitemap import (
discover_vehicle_urls_from_sitemap, discover_vehicle_urls_from_sitemap,
discover_vehicle_urls_from_sitemap_with_stats, discover_vehicle_urls_from_sitemap_with_stats,
) )
from .algolia import (
AlgoliaDiscoveryError,
AlgoliaDiscoveryResult,
discover_vehicle_hits_from_algolia,
)
__all__ = [ __all__ = [
"SitemapDiscoveryError", "SitemapDiscoveryError",
@@ -12,4 +17,7 @@ __all__ = [
"SitemapDiscoveryStats", "SitemapDiscoveryStats",
"discover_vehicle_urls_from_sitemap", "discover_vehicle_urls_from_sitemap",
"discover_vehicle_urls_from_sitemap_with_stats", "discover_vehicle_urls_from_sitemap_with_stats",
"AlgoliaDiscoveryError",
"AlgoliaDiscoveryResult",
"discover_vehicle_hits_from_algolia",
] ]

View File

@@ -0,0 +1,479 @@
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.request import Request, urlopen
logger = logging.getLogger("dubizzle_scraper.discovery.algolia")
ALGOLIA_HARD_PAGE_LIMIT = 100
ALGOLIA_MAX_HITS_PER_QUERY = ALGOLIA_HARD_PAGE_LIMIT * 100
ALGOLIA_ID_RANGE_START = 0
ALGOLIA_ID_RANGE_END = 20_000_000
ALGOLIA_ID_RANGE_MIN_WINDOW = 100
class AlgoliaDiscoveryError(RuntimeError):
pass
@dataclass(slots=True)
class AlgoliaDiscoveryPageStat:
page_number: int
hits_count: int
@dataclass(slots=True)
class AlgoliaDiscoveryResult:
vehicle_urls: list[str] = field(default_factory=list)
hit_records: dict[str, dict[str, Any]] = field(default_factory=dict)
origin_ids_by_url: dict[str, str] = field(default_factory=dict)
pages: list[dict[str, int]] = field(default_factory=list)
early_stopped: bool = False
truncated_by_time_budget: bool = False
total_hits: int = 0
@dataclass(slots=True)
class _AlgoliaShard:
category_slug: str
id_min: int | None = None
id_max: int | None = None
year_min: int | None = None
year_max: int | None = None
def filter_expr(self) -> str:
parts = [f"(category_v2.slug_paths:{self.category_slug})"]
if self.year_min is not None:
parts.append(f"year>={int(self.year_min)}")
if self.year_max is not None:
parts.append(f"year<={int(self.year_max)}")
if self.id_min is not None:
parts.append(f"id>={int(self.id_min)}")
if self.id_max is not None:
parts.append(f"id<={int(self.id_max)}")
return " AND ".join(parts)
def label(self) -> str:
label = self.category_slug
if self.year_min is not None or self.year_max is not None:
label += f"[year:{self.year_min}-{self.year_max}]"
if self.id_min is None and self.id_max is None:
return label
return f"{label}[id:{self.id_min}-{self.id_max}]"
@dataclass(slots=True)
class _AlgoliaHttpClient:
endpoint: str
app_id: str
api_key: str
user_agent: str
index_name: str
hits_per_page: int
def request(self, *, page: int, filters: str) -> dict[str, Any]:
params = urlencode(
{
"query": "",
"page": page,
"hitsPerPage": self.hits_per_page,
"filters": filters,
}
)
payload = {"requests": [{"indexName": self.index_name, "params": params}]}
request = Request(
self.endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={
"content-type": "application/json",
"x-algolia-application-id": self.app_id,
"x-algolia-api-key": self.api_key,
"accept": "application/json",
"user-agent": self.user_agent,
},
method="POST",
)
with urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8", errors="ignore")
parsed = json.loads(body)
results = parsed.get("results") or []
return results[0] if results and isinstance(results[0], dict) else {}
def _build_origin_id_from_hit(hit: dict[str, Any]) -> str | None:
for key in ("id", "objectID", "uuid"):
value = hit.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return f"dubizzle:{text}"
permalink = str(hit.get("permalink") or "").strip()
if permalink:
tail = permalink.rstrip("/").split("/")[-1]
if tail:
return f"dubizzle:{tail}"
return None
def _build_vehicle_url_from_hit(hit: dict[str, Any]) -> str | None:
permalink = str(hit.get("permalink") or "").strip()
if permalink:
if permalink.startswith("http://") or permalink.startswith("https://"):
return permalink
if permalink.startswith("/"):
return f"https://www.dubizzle.com{permalink}"
return f"https://www.dubizzle.com/{permalink.lstrip('/')}"
short = str(hit.get("short_url") or "").strip()
if short:
if short.startswith("http://") or short.startswith("https://"):
return short
return f"https://dubizzle.com/s/{short.strip('/')}"
hit_id = hit.get("id") or hit.get("objectID")
if hit_id is not None:
return f"https://www.dubizzle.com/motors/used-cars/ad-{hit_id}/"
return None
def _extract_make_from_listing_url(listing_url: str | None) -> str | None:
if not listing_url:
return None
try:
parsed = urlparse(listing_url)
params = parse_qs(parsed.query)
candidate = (params.get("Make") or params.get("make") or [None])[0]
if candidate:
return str(candidate).strip()
parts = [p for p in parsed.path.split("/") if p]
if len(parts) >= 3 and parts[0].lower() == "motors" and parts[1].lower() == "used-cars":
slug = parts[2].strip().lower()
if slug and slug != "s":
return slug.replace("-", " ")
except Exception:
return None
return None
def _make_slug(make: str | None) -> str | None:
if not make:
return None
slug = make.strip().lower().replace(" ", "-")
return slug or None
def _hit_matches_filters(
hit: dict[str, Any],
*,
make: str | None,
model: str | None,
year_min: int | None,
year_max: int | None,
) -> bool:
if make:
hit_make = str(hit.get("make") or "").strip().lower()
if hit_make != make.strip().lower():
return False
if model:
hit_model = str(hit.get("model") or "").strip().lower()
if hit_model != model.strip().lower():
return False
hit_year_raw = hit.get("year")
hit_year: int | None = None
if hit_year_raw is not None:
try:
hit_year = int(hit_year_raw)
except Exception:
hit_year = None
if year_min is not None and (hit_year is None or hit_year < year_min):
return False
if year_max is not None and (hit_year is None or hit_year > year_max):
return False
return True
def _probe_total_hits(client: _AlgoliaHttpClient, shard: _AlgoliaShard) -> int:
first = client.request(page=0, filters=shard.filter_expr())
return int(first.get("nbHits") or 0)
def _split_id_range(shard: _AlgoliaShard) -> tuple[_AlgoliaShard, _AlgoliaShard] | None:
lo = shard.id_min if shard.id_min is not None else ALGOLIA_ID_RANGE_START
hi = shard.id_max if shard.id_max is not None else ALGOLIA_ID_RANGE_END
if hi - lo <= ALGOLIA_ID_RANGE_MIN_WINDOW:
return None
mid = (lo + hi) // 2
return (
_AlgoliaShard(
category_slug=shard.category_slug,
id_min=lo,
id_max=mid,
year_min=shard.year_min,
year_max=shard.year_max,
),
_AlgoliaShard(
category_slug=shard.category_slug,
id_min=mid + 1,
id_max=hi,
year_min=shard.year_min,
year_max=shard.year_max,
),
)
def _expand_shards(client: _AlgoliaHttpClient, initial_shard: _AlgoliaShard, max_duration_seconds: float | None, started_at: float) -> tuple[list[_AlgoliaShard], bool, int]:
queue: list[_AlgoliaShard] = [initial_shard]
ready: list[_AlgoliaShard] = []
truncated = False
total_hits = 0
while queue:
if max_duration_seconds is not None and (time.perf_counter() - started_at) > max_duration_seconds:
truncated = True
break
shard = queue.pop(0)
shard_total = _probe_total_hits(client, shard)
if shard is initial_shard:
total_hits = shard_total
if shard_total == 0:
continue
if shard_total <= ALGOLIA_MAX_HITS_PER_QUERY:
ready.append(shard)
continue
split = _split_id_range(shard)
if split is None:
logger.warning(
"Shard %s still exceeds limit=%d but id-window is too small to split further (hits=%d)",
shard.label(),
ALGOLIA_MAX_HITS_PER_QUERY,
shard_total,
)
ready.append(shard)
continue
logger.warning(
"Splitting Algolia shard %s with hits=%d into %s and %s",
shard.label(),
shard_total,
split[0].label(),
split[1].label(),
)
queue.insert(0, split[1])
queue.insert(0, split[0])
return ready, truncated, total_hits
def _fetch_shard_hits(
*,
client: _AlgoliaHttpClient,
shard: _AlgoliaShard,
make: str | None,
model: str | None,
year_min: int | None,
year_max: int | None,
known_origin_ids: set[str] | None,
threshold: float,
max_duration_seconds: float | None,
started_at: float,
) -> tuple[list[str], dict[str, dict[str, Any]], dict[str, str], list[dict[str, int]], bool, bool]:
urls: list[str] = []
hit_records: dict[str, dict[str, Any]] = {}
origin_ids_by_url: dict[str, str] = {}
pages: list[dict[str, int]] = []
early_stopped = False
truncated_by_time_budget = False
page = 0
nb_pages = None
while True:
if max_duration_seconds is not None and (time.perf_counter() - started_at) > max_duration_seconds:
truncated_by_time_budget = True
break
try:
first = client.request(page=page, filters=shard.filter_expr())
except Exception as exc:
raise AlgoliaDiscoveryError(
f"Algolia request failed for shard={shard.label()} page={page}: {exc}"
) from exc
hits = first.get("hits") or []
if not isinstance(hits, list):
hits = []
if page == 0:
nb_pages = min(int(first.get("nbPages") or 0), ALGOLIA_HARD_PAGE_LIMIT)
pages.append({"page_number": page + 1, "links_found": len(hits), "shard": shard.label()})
if not hits:
break
page_known = 0
page_new = 0
for raw_hit in hits:
if not isinstance(raw_hit, dict):
continue
if not _hit_matches_filters(
raw_hit,
make=make,
model=model,
year_min=year_min,
year_max=year_max,
):
continue
url = _build_vehicle_url_from_hit(raw_hit)
if not url:
continue
origin_id = _build_origin_id_from_hit(raw_hit)
if origin_id and known_origin_ids is not None and origin_id in known_origin_ids:
page_known += 1
else:
page_new += 1
if url in hit_records:
continue
urls.append(url)
hit_records[url] = raw_hit
if origin_id:
origin_ids_by_url[url] = origin_id
if known_origin_ids is not None and threshold > 0 and (page_known + page_new) > 0:
ratio = page_known / (page_known + page_new)
if ratio >= threshold and page_new == 0:
early_stopped = True
break
page += 1
if nb_pages is not None and page >= nb_pages:
break
return urls, hit_records, origin_ids_by_url, pages, early_stopped, truncated_by_time_budget
def discover_vehicle_hits_from_algolia(
*,
settings,
make: str | None = None,
model: str | None = None,
limit: int | None = None,
year_min: int | None = None,
year_max: int | None = None,
listing_url: str | None = None,
known_origin_ids: set[str] | None = None,
max_duration_seconds: float | None = None,
) -> AlgoliaDiscoveryResult:
app_id = settings.algolia.application_id.strip()
api_key = settings.algolia.api_key.strip()
index_name = settings.algolia.index_name.strip()
if not app_id or not api_key or not index_name:
raise AlgoliaDiscoveryError("Algolia credentials/index are not configured")
effective_make = make or _extract_make_from_listing_url(listing_url)
hits_per_page = max(1, min(100, int(settings.algolia.hits_per_page)))
base_url = settings.algolia.base_url.strip().rstrip("/")
if not base_url:
base_url = f"https://{app_id}-dsn.algolia.net"
category_slug = settings.algolia.category_slug.strip() or "motors/used-cars"
make_slug = _make_slug(effective_make)
if make_slug:
category_slug = f"{category_slug}/{make_slug}"
client = _AlgoliaHttpClient(
endpoint=f"{base_url}/1/indexes/*/queries",
app_id=app_id,
api_key=api_key,
user_agent=settings.fingerprint.user_agent,
index_name=index_name,
hits_per_page=hits_per_page,
)
threshold = float(settings.listing.early_stop_threshold)
started_at = time.perf_counter()
initial_shard = _AlgoliaShard(
category_slug=category_slug,
id_min=ALGOLIA_ID_RANGE_START,
id_max=ALGOLIA_ID_RANGE_END,
year_min=year_min,
year_max=year_max,
)
shards, shard_build_truncated, total_hits = _expand_shards(
client,
initial_shard,
max_duration_seconds,
started_at,
)
collected_urls: list[str] = []
hits_by_url: dict[str, dict[str, Any]] = {}
origin_ids_by_url: dict[str, str] = {}
pages: list[dict[str, int]] = []
early_stopped = False
truncated_by_time_budget = shard_build_truncated
logger.warning(
"Algolia shard plan ready: total_hits=%d shards=%d category=%s",
total_hits,
len(shards),
category_slug,
)
for shard in shards:
shard_urls, shard_hits, shard_origin_ids, shard_pages, shard_early_stop, shard_truncated = _fetch_shard_hits(
client=client,
shard=shard,
make=effective_make,
model=model,
year_min=year_min,
year_max=year_max,
known_origin_ids=known_origin_ids,
threshold=threshold,
max_duration_seconds=max_duration_seconds,
started_at=started_at,
)
pages.extend(shard_pages)
early_stopped = early_stopped or shard_early_stop
truncated_by_time_budget = truncated_by_time_budget or shard_truncated
for url in shard_urls:
if url in hits_by_url:
continue
collected_urls.append(url)
hits_by_url[url] = shard_hits[url]
if url in shard_origin_ids:
origin_ids_by_url[url] = shard_origin_ids[url]
if limit is not None and limit > 0 and len(collected_urls) >= limit:
break
if truncated_by_time_budget:
break
logger.info(
"Algolia discovery done: urls=%d total_hits=%d pages=%d shards=%d",
len(collected_urls),
total_hits,
len(pages),
len(shards),
)
if limit is not None and limit > 0 and len(collected_urls) > limit:
collected_urls = collected_urls[:limit]
return AlgoliaDiscoveryResult(
vehicle_urls=collected_urls,
hit_records={url: hits_by_url[url] for url in collected_urls if url in hits_by_url},
origin_ids_by_url={url: origin_ids_by_url[url] for url in collected_urls if url in origin_ids_by_url},
pages=pages,
early_stopped=early_stopped,
truncated_by_time_budget=truncated_by_time_budget,
total_hits=total_hits,
)

View File

@@ -10,9 +10,9 @@ from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen, ProxyHandler, build_opener from urllib.request import Request, urlopen, ProxyHandler, build_opener
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
logger = logging.getLogger("iaai_scraper.discovery.sitemap") logger = logging.getLogger("dubizzle_scraper.discovery.sitemap")
DEFAULT_SITEMAP_INDEX_URL = "https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemap_index.xml" DEFAULT_SITEMAP_INDEX_URL = "https://www.dubizzle.com/Xj9rDOVMEi0hc38S/sitemap_index.xml"
_SITEMAP_TIMEOUT_SECONDS = 30 _SITEMAP_TIMEOUT_SECONDS = 30
_LOC_TAG_RE = re.compile(rb"<loc>\s*(.*?)\s*</loc>", re.IGNORECASE | re.DOTALL) _LOC_TAG_RE = re.compile(rb"<loc>\s*(.*?)\s*</loc>", re.IGNORECASE | re.DOTALL)

View File

@@ -20,7 +20,7 @@ from ..storage.schemas import CarRecord, ImageRecord
class CarMapper: class CarMapper:
# Маппер IAAI в CarRecord. # Маппер DUBIZZLE в CarRecord.
BODY_MAP = { BODY_MAP = {
"sedan": "SEDAN", "coupe": "COUPE", "hatchback": "HATCHBACK", "sport utility": "SUV", "sedan": "SEDAN", "coupe": "COUPE", "hatchback": "HATCHBACK", "sport utility": "SUV",
@@ -44,6 +44,7 @@ class CarMapper:
COUNTRY_MAP = { COUNTRY_MAP = {
"us": "US", "usa": "US", "united states": "US", "ca": "CA", "canada": "CA", "us": "US", "usa": "US", "united states": "US", "ca": "CA", "canada": "CA",
"jp": "JP", "japan": "JP", "kr": "KR", "korea": "KR", "south korea": "KR", "jp": "JP", "japan": "JP", "kr": "KR", "korea": "KR", "south korea": "KR",
"ae": "AE", "uae": "AE", "united arab emirates": "AE", "dubai": "AE", "abu dhabi": "AE",
} }
NO_DAMAGE_MARKERS = {"normal wear", "normal wear & tear", "normal wear and tear", "n/a", "na", "none", "no damage", "minor dents/scratches"} NO_DAMAGE_MARKERS = {"normal wear", "normal wear & tear", "normal wear and tear", "n/a", "na", "none", "no damage", "minor dents/scratches"}
@@ -59,9 +60,14 @@ class CarMapper:
origin_id = self._build_origin_id(vehicle_url, vehicle_summary, core) origin_id = self._build_origin_id(vehicle_url, vehicle_summary, core)
parser_id = self._generate_parser_id(origin_id) parser_id = self._generate_parser_id(origin_id)
brand = self._as_str(first_non_empty([core.get("make"), vehicle_summary.get("make")])) or "UNKNOWN" brand = self._resolve_make(core, vehicle_summary) or "UNKNOWN"
model = self._as_str(first_non_empty([core.get("model"), vehicle_summary.get("model")])) or "UNKNOWN" model = self._resolve_model(core, vehicle_summary) or "UNKNOWN"
year = self._to_year(first_non_empty([core.get("year"), vehicle_summary.get("year")])) year = self._to_year(first_non_empty([
core.get("year"),
vehicle_summary.get("year"),
self._extract_detail_v2_value(vehicle_summary, "year"),
self._extract_detail_value(vehicle_summary, "Year"),
]))
price = self._first_parsed_int( price = self._first_parsed_int(
[ [
pricing.get("buy_now"), pricing.get("buy_now"),
@@ -69,29 +75,90 @@ class CarMapper:
vehicle_summary.get("buy_now"), vehicle_summary.get("buy_now"),
vehicle_summary.get("current_bid"), vehicle_summary.get("current_bid"),
pricing.get("actual_cash_value"), pricing.get("actual_cash_value"),
vehicle_summary.get("price"),
self._extract_detail_v2_value(vehicle_summary, "price"),
self._extract_detail_value(vehicle_summary, "Price"),
], ],
self._to_money_int, self._to_money_int,
) )
mileage = self._parse_odometer( mileage = self._parse_odometer(
first_non_empty([core.get("odometer"), vehicle_summary.get("odometer")]) first_non_empty([
core.get("odometer"),
core.get("kilometers"),
vehicle_summary.get("odometer"),
vehicle_summary.get("kilometers"),
self._extract_detail_v2_value(vehicle_summary, "kilometers"),
self._extract_detail_value(vehicle_summary, "Kilometers"),
])
) )
color = self._normalize_color(first_non_empty([core.get("color"), vehicle_summary.get("color"), "other"])) color = self._normalize_color(first_non_empty([
drive = self._normalize_drive(first_non_empty([core.get("drive"), vehicle_summary.get("drive")])) core.get("color"),
vehicle_summary.get("color"),
vehicle_summary.get("exterior_color"),
self._extract_detail_v2_value(vehicle_summary, "exterior_color"),
self._extract_detail_value(vehicle_summary, "Exterior Color"),
"other",
]))
drive = self._normalize_drive(first_non_empty([
core.get("drive"),
vehicle_summary.get("drive"),
vehicle_summary.get("drive_type"),
self._extract_detail_v2_value(vehicle_summary, "drive_system"),
self._extract_detail_value(vehicle_summary, "Drive Type"),
self._extract_detail_value(vehicle_summary, "Drive System"),
]))
# Пробуем взять привод из двигателя. # Пробуем взять привод из двигателя.
if not drive or drive == "NA": if not drive or drive == "NA":
engine_text = self._as_str(first_non_empty([core.get("engine"), vehicle_summary.get("engine")])) engine_text = self._as_str(first_non_empty([
core.get("engine"),
vehicle_summary.get("engine"),
self._extract_detail_v2_value(vehicle_summary, "engine_capacity_cc"),
self._extract_detail_value(vehicle_summary, "Engine Capacity (cc)"),
]))
if engine_text: if engine_text:
inferred_drive = self._normalize_drive(engine_text) inferred_drive = self._normalize_drive(engine_text)
if inferred_drive and inferred_drive != "NA": if inferred_drive and inferred_drive != "NA":
drive = inferred_drive drive = inferred_drive
gearbox = self._normalize_gearbox(first_non_empty([core.get("gearbox"), vehicle_summary.get("gearbox")])) gearbox = self._normalize_gearbox(first_non_empty([
steering = self._normalize_steering(first_non_empty([core.get("steering_wheel"), vehicle_summary.get("steering_wheel")])) or "LEFT" core.get("gearbox"),
body_type = self._normalize_body_type(first_non_empty([core.get("body_type"), vehicle_summary.get("body_type")])) vehicle_summary.get("gearbox"),
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")])) vehicle_summary.get("transmission_type"),
vehicle_summary.get("transmission"),
self._extract_detail_v2_value(vehicle_summary, "transmission_type"),
self._extract_detail_value(vehicle_summary, "Transmission Type"),
]))
steering = self._normalize_steering(first_non_empty([
core.get("steering_wheel"),
vehicle_summary.get("steering_wheel"),
self._extract_detail_v2_value(vehicle_summary, "steering_side"),
self._extract_detail_value(vehicle_summary, "Steering Side"),
])) or "LEFT"
body_type = self._normalize_body_type(first_non_empty([
core.get("body_type"),
vehicle_summary.get("body_type"),
self._extract_detail_v2_value(vehicle_summary, "body_type"),
self._extract_detail_value(vehicle_summary, "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"),
self._extract_detail_v2_value(vehicle_summary, "engine_capacity_cc"),
self._extract_detail_value(vehicle_summary, "Engine Capacity (cc)"),
]))
title_text = self._as_str(first_non_empty([core.get("title"), vehicle_summary.get("title"), ""])) 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"), ""])) 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"), ""])) location = self._as_str(first_non_empty([
country = self._normalize_country(first_non_empty([core.get("country"), location, "US"])) core.get("location"),
vehicle_summary.get("location"),
vehicle_summary.get("location_name"),
self._extract_nested_text(vehicle_summary.get("neighbourhood"), "en"),
self._extract_location_country(vehicle_summary),
auction.get("branch"),
"",
]))
country = self._normalize_country(first_non_empty([core.get("country"), location, "AE"]))
is_damaged = self._bool_damage(damage, vehicle_summary) is_damaged = self._bool_damage(damage, vehicle_summary)
is_sold = self._bool_sold(auction) is_sold = self._bool_sold(auction)
one_owner = self._boolish(first_non_empty([core.get("one_owner"), vehicle_summary.get("one_owner"), False])) one_owner = self._boolish(first_non_empty([core.get("one_owner"), vehicle_summary.get("one_owner"), False]))
@@ -115,13 +182,13 @@ class CarMapper:
) )
slug = self._slugify(" ".join(filter(None, [brand, model, str(year or "")]))) 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 []) images_records = self._build_images(images.get("urls") or vehicle_summary.get("image_urls") or [])
origin = "IAAI" origin = "DUBIZZLE"
return CarRecord( return CarRecord(
parser_id=parser_id, brand=brand, model=model, year=year, price=price, currency=currency, 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, 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, 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, selling_type=self._normalize_selling_type(first_non_empty([core.get("selling_type"), "STOCK"])), 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, 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, evaluation=evaluation, non_smoking=non_smoking, rental=rental, repair_history=repair_history,
slug=slug, last_seen_at=datetime.now(timezone.utc), images=images_records, slug=slug, last_seen_at=datetime.now(timezone.utc), images=images_records,
@@ -131,6 +198,146 @@ class CarMapper:
def _as_str(value: Any) -> str: def _as_str(value: Any) -> str:
return "" if value is None else str(value).strip() return "" if value is None else str(value).strip()
def _resolve_make(self, core: dict[str, Any], vehicle_summary: dict[str, Any]) -> str | None:
category_make, _ = self._extract_category_make_model(vehicle_summary)
slug_make, _ = self._extract_slug_make_model(vehicle_summary)
return self._first_text(
[
core.get("make"),
vehicle_summary.get("make"),
self._extract_detail_v2_value(vehicle_summary, "make"),
self._extract_detail_value(vehicle_summary, "Make"),
category_make,
slug_make,
]
)
def _resolve_model(self, core: dict[str, Any], vehicle_summary: dict[str, Any]) -> str | None:
_, category_model = self._extract_category_make_model(vehicle_summary)
_, slug_model = self._extract_slug_make_model(vehicle_summary)
return self._first_text(
[
core.get("model"),
vehicle_summary.get("model"),
self._extract_detail_v2_value(vehicle_summary, "model"),
self._extract_detail_value(vehicle_summary, "Model"),
category_model,
slug_model,
]
)
def _first_text(self, values: list[Any]) -> str | None:
for value in values:
text = self._coerce_text(value)
if text:
return text
return None
def _coerce_text(self, value: Any) -> str | None:
if value is None:
return None
if isinstance(value, list):
for item in value:
text = self._coerce_text(item)
if text:
return text
return None
if isinstance(value, dict):
for key in ("en", "value", "name", "text", "label"):
if key in value:
text = self._coerce_text(value.get(key))
if text:
return text
for nested in value.values():
text = self._coerce_text(nested)
if text:
return text
return None
text = self._as_str(value)
return text or None
def _extract_nested_text(self, value: Any, preferred_key: str = "en") -> str | None:
if not isinstance(value, dict):
return self._coerce_text(value)
return self._coerce_text(value.get(preferred_key)) or self._coerce_text(value)
def _extract_detail_v2_value(self, vehicle_summary: dict[str, Any], slug: str) -> str | None:
details_v2 = vehicle_summary.get("details_v2")
if not isinstance(details_v2, dict):
return None
target = slug.strip().lower()
for section in details_v2.values():
if not isinstance(section, list):
continue
for item in section:
if not isinstance(item, dict):
continue
if self._as_str(item.get("slug")).lower() != target:
continue
text = self._coerce_text(item.get("value"))
if text:
return text
return None
def _extract_detail_value(self, vehicle_summary: dict[str, Any], detail_key: str) -> str | None:
details = vehicle_summary.get("details")
if not isinstance(details, dict):
return None
target = detail_key.strip().lower()
for key, value in details.items():
if self._as_str(key).lower() != target:
continue
text = self._coerce_text(value)
if text:
return text
return None
def _extract_category_make_model(self, vehicle_summary: dict[str, Any]) -> tuple[str | None, str | None]:
category_v2 = vehicle_summary.get("category_v2")
if isinstance(category_v2, dict):
names_en = category_v2.get("names_en")
if isinstance(names_en, list) and len(names_en) >= 4:
return self._coerce_text(names_en[2]), self._coerce_text(names_en[3])
category = vehicle_summary.get("category")
if isinstance(category, dict):
names_en = category.get("en")
if isinstance(names_en, list) and len(names_en) >= 3:
return self._coerce_text(names_en[1]), self._coerce_text(names_en[2])
return None, None
def _extract_slug_make_model(self, vehicle_summary: dict[str, Any]) -> tuple[str | None, str | None]:
category_v2 = vehicle_summary.get("category_v2")
if not isinstance(category_v2, dict):
return None, None
slug_paths = category_v2.get("slug_paths")
if not isinstance(slug_paths, list) or len(slug_paths) < 3:
return None, None
make = self._humanize_slug_path_part(slug_paths[2])
model = self._humanize_slug_path_part(slug_paths[3]) if len(slug_paths) >= 4 else None
return make, model
def _humanize_slug_path_part(self, value: Any) -> str | None:
text = self._as_str(value)
if not text:
return None
slug = text.split("/")[-1].strip().strip("-")
if not slug:
return None
return slug.replace("-", " ").title()
def _extract_location_country(self, vehicle_summary: dict[str, Any]) -> str | None:
site = vehicle_summary.get("site")
if isinstance(site, dict):
return self._coerce_text(site.get("en"))
location_list = vehicle_summary.get("location_list")
if isinstance(location_list, dict):
en_values = location_list.get("en")
if isinstance(en_values, list) and en_values:
return self._coerce_text(en_values[0])
return None
@staticmethod @staticmethod
def _to_int(value: Any) -> int | None: def _to_int(value: Any) -> int | None:
if value is None: if value is None:
@@ -238,7 +445,7 @@ class CarMapper:
return None return None
if liters_match := re.search(r"(\d+(?:\.\d+)?)\s*l", text): if liters_match := re.search(r"(\d+(?:\.\d+)?)\s*l", text):
return int(float(liters_match.group(1)) * 1000) return int(float(liters_match.group(1)) * 1000)
if cubic_match := re.search(r"(\d{3,5})\s*(?:cc|cm3|cubic)", text): if cubic_match := re.search(r"(\d{3,5})(?:\+)?\s*(?:cc|cm3|cubic)", text):
return int(cubic_match.group(1)) return int(cubic_match.group(1))
return int(text) if re.match(r"^\d+$", text) else None return int(text) if re.match(r"^\d+$", text) else None
@@ -278,12 +485,16 @@ class CarMapper:
return self._map_value(value, self.BODY_MAP, BODY_TYPE_ENUM_VALUES, empty_default="OTHER", fallback="OTHER") or "OTHER" 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: def _normalize_country(self, value: Any) -> str:
fallback = "US" if "US" in COUNTRY_ENUM_VALUES else "NA" fallback = "AE" if "AE" in COUNTRY_ENUM_VALUES else "NA"
return self._map_value(value, self.COUNTRY_MAP, COUNTRY_ENUM_VALUES, empty_default="US", fallback=fallback) or fallback return self._map_value(value, self.COUNTRY_MAP, COUNTRY_ENUM_VALUES, empty_default="AE", fallback=fallback) or fallback
def _normalize_selling_type(self, value: Any) -> str: def _normalize_selling_type(self, value: Any) -> str:
text = self._as_str(value) or "AUCTION" text = (self._as_str(value) or "STOCK").upper()
return text if text in SELLING_TYPE_ENUM_VALUES else "AUCTION" if text in SELLING_TYPE_ENUM_VALUES:
return text
if text in {"DEALER", "PRIVATE", "CLASSIFIED"}:
return "STOCK"
return "STOCK"
def _map_value( def _map_value(
self, self,
@@ -357,13 +568,13 @@ class CarMapper:
@staticmethod @staticmethod
def _make_fullres_url(url: str) -> str: def _make_fullres_url(url: str) -> str:
if "vis.iaai.com" in url: if "vis.dubizzle.com" in url:
return re.sub(r'height=\d+', 'height=633', re.sub(r'width=\d+', 'width=845', url)) return re.sub(r'height=\d+', 'height=633', re.sub(r'width=\d+', 'width=845', url))
return url return url
@staticmethod @staticmethod
def _make_preview_url(url: str) -> str: def _make_preview_url(url: str) -> str:
if "vis.iaai.com" in url: if "vis.dubizzle.com" in url:
preview = re.sub(r'height=\d+', 'height=300', re.sub(r'width=\d+', 'width=400', url)) preview = re.sub(r'height=\d+', 'height=300', re.sub(r'width=\d+', 'width=400', url))
if preview != url: if preview != url:
return preview return preview
@@ -387,16 +598,22 @@ class CarMapper:
return "car-" + "".join(chars) return "car-" + "".join(chars)
def _build_origin_id(self, vehicle_url: str, vehicle_summary: dict[str, Any], core: dict[str, Any]) -> str: def _build_origin_id(self, vehicle_url: str, vehicle_summary: dict[str, Any], core: dict[str, Any]) -> str:
# Формат: iaai:{lot_number}. # Формат: dubizzle:{lot_number}.
for value in [core.get("lot_number"), vehicle_summary.get("lot_number")]: for value in [
core.get("lot_number"),
vehicle_summary.get("lot_number"),
vehicle_summary.get("id"),
vehicle_summary.get("objectID"),
vehicle_summary.get("uuid"),
]:
text = self._as_str(value) text = self._as_str(value)
if text: if text:
return f"iaai:{text}" return f"dubizzle:{text}"
tail = urlparse(vehicle_url).path.rstrip("/").split("/")[-1] tail = urlparse(vehicle_url).path.rstrip("/").split("/")[-1]
if "~" in tail: if "~" in tail:
tail = tail.split("~")[0] tail = tail.split("~")[0]
raw = tail or self._slugify(vehicle_url) raw = tail or self._slugify(vehicle_url)
return f"iaai:{raw}" return f"dubizzle:{raw}"
@staticmethod @staticmethod
def _slugify(value: str) -> str: def _slugify(value: str) -> str:

View File

@@ -6,7 +6,7 @@ from typing import Any
from ..core.utils import LOT_RE, PRICE_RE, deep_find_all_keys, deep_find_key, first_non_empty from ..core.utils import LOT_RE, PRICE_RE, deep_find_all_keys, deep_find_key, first_non_empty
logger = logging.getLogger("iaai_scraper.parsers") logger = logging.getLogger("dubizzle_scraper.parsers")
class VehicleParser: class VehicleParser:
@@ -350,7 +350,7 @@ class VehicleParser:
if isinstance(item, str) and item.startswith("http"): if isinstance(item, str) and item.startswith("http"):
cleaned = html_module.unescape(item) cleaned = html_module.unescape(item)
lowered = cleaned.lower() lowered = cleaned.lower()
if vehicle_key and "vis.iaai.com" in lowered and vehicle_key not in cleaned: if vehicle_key and "vis.dubizzle.com" in lowered and vehicle_key not in cleaned:
continue continue
if cleaned not in seen_flat: if cleaned not in seen_flat:
seen_flat.add(cleaned) seen_flat.add(cleaned)
@@ -360,7 +360,7 @@ class VehicleParser:
if isinstance(child, str) and child.startswith("http"): if isinstance(child, str) and child.startswith("http"):
cleaned = html_module.unescape(child) cleaned = html_module.unescape(child)
lowered = cleaned.lower() lowered = cleaned.lower()
if vehicle_key and "vis.iaai.com" in lowered and vehicle_key not in cleaned: if vehicle_key and "vis.dubizzle.com" in lowered and vehicle_key not in cleaned:
continue continue
if cleaned not in seen_flat: if cleaned not in seen_flat:
seen_flat.add(cleaned) seen_flat.add(cleaned)
@@ -374,13 +374,13 @@ class VehicleParser:
if vehicle_key and vehicle_key in url: if vehicle_key and vehicle_key in url:
seen_flat.add(url) seen_flat.add(url)
flat.append(url) flat.append(url)
elif any(token in lowered for token in ["vis.iaai.com", "anvis", "vehicleimage"]): elif any(token in lowered for token in ["vis.dubizzle.com", "anvis", "vehicleimage"]):
if vehicle_key and vehicle_key not in url: if vehicle_key and vehicle_key not in url:
continue continue
seen_flat.add(url) seen_flat.add(url)
flat.append(url) flat.append(url)
if vehicle_key: if vehicle_key:
for url in re.findall(r'https?://vis\.iaai\.com[^\s"\'<>]+', html or ""): for url in re.findall(r'https?://vis\.dubizzle\.com[^\s"\'<>]+', html or ""):
cleaned = html_module.unescape(url) cleaned = html_module.unescape(url)
if cleaned not in seen_flat and vehicle_key in cleaned: if cleaned not in seen_flat and vehicle_key in cleaned:
seen_flat.add(cleaned) seen_flat.add(cleaned)
@@ -390,7 +390,7 @@ class VehicleParser:
lowered = url.lower() lowered = url.lower()
if any(pat in lowered for pat in {"dimensions", "threesixty", "360view", ".js", ".css", ".svg", "/home/", "iframeview"}): if any(pat in lowered for pat in {"dimensions", "threesixty", "360view", ".js", ".css", ".svg", "/home/", "iframeview"}):
continue continue
if "vis.iaai.com" in lowered and "/resizer" not in lowered: if "vis.dubizzle.com" in lowered and "/resizer" not in lowered:
continue continue
filtered.append(url) filtered.append(url)
return filtered return filtered

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ from ..core.config import Settings
from .models import Base, Car, Image, SyncRun from .models import Base, Car, Image, SyncRun
from .schemas import CarRecord from .schemas import CarRecord
logger = logging.getLogger("iaai_scraper.db") logger = logging.getLogger("dubizzle_scraper.db")
CAR_DB_FIELDS = { CAR_DB_FIELDS = {
@@ -492,7 +492,7 @@ class PersistenceService:
logger.error("Individual upsert failed for %s: %s", record.origin_id, exc) logger.error("Individual upsert failed for %s: %s", record.origin_id, exc)
return {"inserted": inserted, "updated": updated, "images_upserted": images_total} return {"inserted": inserted, "updated": updated, "images_upserted": images_total}
def mark_sold_not_in_listing(self, active_origin_ids: set[str], lane: str = "iaai") -> int: def mark_sold_not_in_listing(self, active_origin_ids: set[str], lane: str = "dubizzle") -> int:
"""Помечает авто как проданные, если их нет в активном листинге (по origin_id).""" """Помечает авто как проданные, если их нет в активном листинге (по origin_id)."""
if not active_origin_ids: if not active_origin_ids:
return 0 return 0
@@ -501,7 +501,7 @@ class PersistenceService:
update(Car) update(Car)
.where(Car.origin_id.notin_(active_origin_ids)) .where(Car.origin_id.notin_(active_origin_ids))
.where(Car.is_sold == False) # noqa: E712 .where(Car.is_sold == False) # noqa: E712
.where(Car.origin_id.like("iaai:%")) .where(Car.origin_id.like("dubizzle:%"))
.values(is_sold=True) .values(is_sold=True)
) )
result = session.execute(stmt) result = session.execute(stmt)
@@ -510,7 +510,7 @@ class PersistenceService:
logger.info("Marked %d cars as sold (no longer in listing)", count) logger.info("Marked %d cars as sold (no longer in listing)", count)
return count return count
def mark_sold_not_in_listing_by_urls(self, active_origin_urls: set[str], lane: str = "iaai") -> int: def mark_sold_not_in_listing_by_urls(self, active_origin_urls: set[str], lane: str = "dubizzle") -> int:
"""Помечает авто как проданные, если их URL нет в активном листинге. """Помечает авто как проданные, если их URL нет в активном листинге.
Для PostgreSQL использует временную таблицу + LEFT JOIN вместо NOT IN, Для PostgreSQL использует временную таблицу + LEFT JOIN вместо NOT IN,
@@ -532,7 +532,7 @@ class PersistenceService:
if is_postgres: if is_postgres:
# Считаем кандидатов на sold. # Считаем кандидатов на sold.
total_active = session.execute( total_active = session.execute(
text("SELECT count(*) FROM cars WHERE is_sold = FALSE AND origin_id LIKE 'iaai:%%'") text("SELECT count(*) FROM cars WHERE is_sold = FALSE AND origin_id LIKE 'dubizzle:%%'")
).scalar() or 0 ).scalar() or 0
if total_active == 0: if total_active == 0:
@@ -560,7 +560,7 @@ class PersistenceService:
LEFT JOIN _active_urls a ON replace(c.origin_url, '~', '-') = a.url LEFT JOIN _active_urls a ON replace(c.origin_url, '~', '-') = a.url
WHERE a.url IS NULL WHERE a.url IS NULL
AND c.is_sold = FALSE AND c.is_sold = FALSE
AND c.origin_id LIKE 'iaai:%%' AND c.origin_id LIKE 'dubizzle:%%'
""")).scalar() or 0 """)).scalar() or 0
# Защита от аномалии. # Защита от аномалии.
@@ -581,7 +581,7 @@ class PersistenceService:
LEFT JOIN _active_urls a ON replace(c.origin_url, '~', '-') = a.url LEFT JOIN _active_urls a ON replace(c.origin_url, '~', '-') = a.url
WHERE a.url IS NULL WHERE a.url IS NULL
AND c.is_sold = FALSE AND c.is_sold = FALSE
AND c.origin_id LIKE 'iaai:%%' AND c.origin_id LIKE 'dubizzle:%%'
) sub ) sub
WHERE cars.id = sub.id WHERE cars.id = sub.id
""")) """))
@@ -591,13 +591,13 @@ class PersistenceService:
stmt = ( stmt = (
update(Car) update(Car)
.where(Car.is_sold == False) # noqa: E712 .where(Car.is_sold == False) # noqa: E712
.where(Car.origin_id.like("iaai:%")) .where(Car.origin_id.like("dubizzle:%"))
.values(is_sold=True) .values(is_sold=True)
) )
# Загружаем active URL. # Загружаем active URL.
all_active = session.execute( all_active = session.execute(
select(Car.id, Car.origin_url).where( select(Car.id, Car.origin_url).where(
Car.is_sold == False, Car.origin_id.like("iaai:%") # noqa: E712 Car.is_sold == False, Car.origin_id.like("dubizzle:%") # noqa: E712
) )
).all() ).all()
mark_ids = [row[0] for row in all_active if _norm(row[1]) not in normalized_urls] mark_ids = [row[0] for row in all_active if _norm(row[1]) not in normalized_urls]
@@ -621,7 +621,7 @@ class PersistenceService:
logger.info("Marked %d cars as sold by URL (no longer in listing)", count) logger.info("Marked %d cars as sold by URL (no longer in listing)", count)
return count return count
def get_all_origin_ids_for_lane(self, prefix: str = "iaai:") -> set[str]: def get_all_origin_ids_for_lane(self, prefix: str = "dubizzle:") -> set[str]:
"""Возвращает все известные origin_id для заданного префикса. """Возвращает все известные origin_id для заданного префикса.
Использует yield_per для потоковой загрузки при большом количестве записей. Использует yield_per для потоковой загрузки при большом количестве записей.
@@ -632,7 +632,7 @@ class PersistenceService:
) )
return {str(row[0]) for row in result if row and row[0]} return {str(row[0]) for row in result if row and row[0]}
def get_all_active_origin_urls_for_lane(self, prefix: str = "iaai:") -> set[str]: def get_all_active_origin_urls_for_lane(self, prefix: str = "dubizzle:") -> set[str]:
"""Возвращает origin_url всех активных (не проданных) авто для заданного lane-префикса.""" """Возвращает origin_url всех активных (не проданных) авто для заданного lane-префикса."""
with self.session_scope() as session: with self.session_scope() as session:
result = session.execute( result = session.execute(
@@ -643,7 +643,7 @@ class PersistenceService:
) )
return {str(row[0]) for row in result if row and row[0]} return {str(row[0]) for row in result if row and row[0]}
def count_active_cars_for_lane(self, prefix: str = "iaai:") -> int: def count_active_cars_for_lane(self, prefix: str = "dubizzle:") -> int:
"""Возвращает количество активных (не проданных) авто для заданного lane-префикса.""" """Возвращает количество активных (не проданных) авто для заданного lane-префикса."""
from sqlalchemy import func as sa_func from sqlalchemy import func as sa_func
with self.session_scope() as session: with self.session_scope() as session:
@@ -657,7 +657,7 @@ class PersistenceService:
def get_active_origin_urls_batch_for_refresh( def get_active_origin_urls_batch_for_refresh(
self, self,
prefix: str = "iaai:", prefix: str = "dubizzle:",
offset: int = 0, offset: int = 0,
limit: int = 500, limit: int = 500,
) -> list[str]: ) -> list[str]:

View File

@@ -16,9 +16,9 @@ BODY_TYPE_ENUM_VALUES = (
"OTHER", "OTHER",
"NA", "NA",
) )
COUNTRY_ENUM_VALUES = ("JP", "KR", "US", "CA", "NA") COUNTRY_ENUM_VALUES = ("JP", "KR", "US", "CA", "AE", "NA")
ORIGIN_ENUM_VALUES = ( ORIGIN_ENUM_VALUES = (
"IAAI", "DUBIZZLE",
"NA", "NA",
) )
SELLING_TYPE_ENUM_VALUES = ("STOCK", "AUCTION", "TENDER", "NA") SELLING_TYPE_ENUM_VALUES = ("STOCK", "AUCTION", "TENDER", "NA")

View File

@@ -9,8 +9,7 @@ from redis import Redis
from ..core.config import settings from ..core.config import settings
from ..core.logs import setup_logging from ..core.logs import setup_logging
logger = logging.getLogger("iaai_scraper.worker.celery_app") logger = logging.getLogger("dubizzle_scraper.worker.celery_app")
STARTUP_SYNC_DISPATCH_KEY = "iaai:state:startup_sync_dispatched"
@celery_setup_logging.connect @celery_setup_logging.connect
@@ -37,7 +36,7 @@ def _result_backend() -> str:
celery_app = Celery( celery_app = Celery(
"iaai_scraper", "dubizzle_scraper",
broker=_broker_url(), broker=_broker_url(),
backend=_result_backend(), backend=_result_backend(),
) )
@@ -79,10 +78,13 @@ celery_app.conf.update(
worker_hijack_root_logger=False, worker_hijack_root_logger=False,
beat_schedule={ beat_schedule={
"periodic-sync-listing": { "periodic-sync-listing": {
"task": "iaai_scraper.worker.tasks.sync_listing_task", "task": "dubizzle_scraper.worker.tasks.sync_listing_task",
"schedule": settings.celery.beat_sync_interval_minutes * 60.0, "schedule": settings.celery.beat_sync_interval_minutes * 60.0,
"args": (), "args": (),
"kwargs": {"limit": settings.celery.beat_sync_limit, "only_new": True}, "kwargs": {
"limit": settings.celery.beat_sync_limit,
"only_new": False if settings.discovery.always_full_scan else True,
},
"options": { "options": {
"queue": "scraping", "queue": "scraping",
"expires": settings.celery.beat_sync_interval_minutes * 60.0, "expires": settings.celery.beat_sync_interval_minutes * 60.0,
@@ -90,11 +92,11 @@ celery_app.conf.update(
} }
}, },
task_routes={ task_routes={
"iaai_scraper.worker.tasks.*": {"queue": "scraping"} "dubizzle_scraper.worker.tasks.*": {"queue": "scraping"}
}, },
) )
celery_app.autodiscover_tasks(["iaai_scraper.worker"]) celery_app.autodiscover_tasks(["dubizzle_scraper.worker"])
@worker_ready.connect @worker_ready.connect
@@ -111,7 +113,7 @@ def _on_worker_ready(**kwargs):
retry_on_timeout=True, retry_on_timeout=True,
) )
for stale_key in ("iaai:locks:sync_listing",): for stale_key in ("dubizzle:locks:sync_listing",):
try: try:
ttl = redis_client.ttl(stale_key) ttl = redis_client.ttl(stale_key)
if ttl is not None and ttl != -2: if ttl is not None and ttl != -2:
@@ -127,10 +129,8 @@ def _on_worker_ready(**kwargs):
if queue_len > 0: if queue_len > 0:
logger.info("Worker ready: scraping queue already has %d task(s); skip startup dispatch", queue_len) logger.info("Worker ready: scraping queue already has %d task(s); skip startup dispatch", queue_len)
return return
should_dispatch = bool(redis_client.set(STARTUP_SYNC_DISPATCH_KEY, "1", nx=True, ex=600))
except Exception: except Exception:
logger.warning("Worker ready startup sync dedupe check failed; skipping immediate dispatch", exc_info=True) logger.warning("Worker ready startup sync check failed; skipping immediate dispatch", exc_info=True)
return return
finally: finally:
if redis_client is not None: if redis_client is not None:
@@ -139,13 +139,9 @@ def _on_worker_ready(**kwargs):
except Exception: except Exception:
pass pass
if not should_dispatch:
logger.info("Worker ready immediate sync already dispatched recently; skipping duplicate enqueue")
return
logger.info("Worker ready — dispatching initial sync_listing task") logger.info("Worker ready — dispatching initial sync_listing task")
celery_app.send_task( celery_app.send_task(
"iaai_scraper.worker.tasks.sync_listing_task", "dubizzle_scraper.worker.tasks.sync_listing_task",
kwargs={"limit": settings.celery.beat_sync_limit, "only_new": False}, kwargs={"limit": settings.celery.beat_sync_limit, "only_new": False},
queue="scraping", queue="scraping",
expires=settings.celery.beat_sync_interval_minutes * 60.0, expires=settings.celery.beat_sync_interval_minutes * 60.0,

View File

@@ -8,21 +8,28 @@ import time
from redis import Redis from redis import Redis
logger = logging.getLogger("iaai_scraper.worker.self_heal") logger = logging.getLogger("dubizzle_scraper.worker.self_heal")
GLOBAL_PROGRESS_TS_KEY = "iaai:state:last_progress_ts" GLOBAL_PROGRESS_TS_KEY = "dubizzle:state:last_progress_ts"
SELF_HEAL_RESTART_LOCK_KEY = "iaai:state:self_heal_restart_in_progress" SELF_HEAL_RESTART_LOCK_KEY = "dubizzle:state:self_heal_restart_in_progress"
def _env_str(name: str, default: str) -> str:
value = os.getenv(name)
if value is None and name.startswith("DUBIZZLE_"):
value = os.getenv("DUBIZZLE_" + name[len("DUBIZZLE_"):])
return value if value is not None else default
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name) value = _env_str(name, "true" if default else "false")
if value is None: if value is None:
return default return default
return value.strip().lower() in {"1", "true", "yes", "on"} return value.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int: def _env_int(name: str, default: int) -> int:
value = os.getenv(name) value = _env_str(name, str(default))
if value is None: if value is None:
return default return default
try: try:
@@ -32,7 +39,7 @@ def _env_int(name: str, default: int) -> int:
def _get_redis() -> Redis: def _get_redis() -> Redis:
url = os.getenv("IAAI_REDIS_URL", "redis://redis:6379/0") url = _env_str("DUBIZZLE_REDIS_URL", "redis://redis:6379/0")
return Redis.from_url( return Redis.from_url(
url, url,
decode_responses=True, decode_responses=True,
@@ -62,7 +69,7 @@ def _read_last_progress_ts(redis_client: Redis) -> int | None:
# Fallback: если глобальный ключ не найден, берём max(ts) из task_progress:*. # Fallback: если глобальный ключ не найден, берём max(ts) из task_progress:*.
# Это дороже, но выполняется только при отсутствии основного маркера. # Это дороже, но выполняется только при отсутствии основного маркера.
max_ts = 0 max_ts = 0
for key in redis_client.scan_iter(match="iaai:state:task_progress:*"): for key in redis_client.scan_iter(match="dubizzle:state:task_progress:*"):
try: try:
payload = redis_client.get(key) payload = redis_client.get(key)
if not payload: if not payload:
@@ -101,7 +108,8 @@ def _kill_worker_process() -> None:
# Если процесс ещё жив — принудительно убиваем. # Если процесс ещё жив — принудительно убиваем.
os.kill(pid, 0) os.kill(pid, 0)
logger.error("Self-heal: worker pid=%s did not stop after SIGTERM; sending SIGKILL", pid) logger.error("Self-heal: worker pid=%s did not stop after SIGTERM; sending SIGKILL", pid)
os.kill(pid, signal.SIGKILL) kill_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
os.kill(pid, kill_signal)
except ProcessLookupError: except ProcessLookupError:
pass pass
except Exception: except Exception:
@@ -109,15 +117,15 @@ def _kill_worker_process() -> None:
def main() -> None: def main() -> None:
if not _env_bool("IAAI_SELF_HEAL_ENABLED", True): if not _env_bool("DUBIZZLE_SELF_HEAL_ENABLED", True):
logger.info("Self-heal watchdog disabled via IAAI_SELF_HEAL_ENABLED") logger.info("Self-heal watchdog disabled via DUBIZZLE_SELF_HEAL_ENABLED/DUBIZZLE_SELF_HEAL_ENABLED")
return return
queue_name = os.getenv("IAAI_CELERY_QUEUE", "scraping") queue_name = _env_str("DUBIZZLE_CELERY_QUEUE", "scraping")
check_interval = max(5, _env_int("IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS", 30)) check_interval = max(5, _env_int("DUBIZZLE_SELF_HEAL_CHECK_INTERVAL_SECONDS", 30))
stall_seconds = max(180, _env_int("IAAI_SELF_HEAL_STALL_SECONDS", 720)) stall_seconds = max(180, _env_int("DUBIZZLE_SELF_HEAL_STALL_SECONDS", 720))
startup_grace = max(30, _env_int("IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS", 300)) startup_grace = max(30, _env_int("DUBIZZLE_SELF_HEAL_STARTUP_GRACE_SECONDS", 300))
restart_cooldown = max(60, _env_int("IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS", 300)) restart_cooldown = max(60, _env_int("DUBIZZLE_SELF_HEAL_RESTART_COOLDOWN_SECONDS", 300))
logger.warning( logger.warning(
"Self-heal watchdog enabled: queue=%s check_interval=%ss stall=%ss startup_grace=%ss cooldown=%ss", "Self-heal watchdog enabled: queue=%s check_interval=%ss stall=%ss startup_grace=%ss cooldown=%ss",
@@ -195,7 +203,7 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig( logging.basicConfig(
level=os.getenv("IAAI_LOG_LEVEL", "INFO"), level=_env_str("DUBIZZLE_LOG_LEVEL", "INFO"),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
) )
main() main()

View File

@@ -1,4 +1,4 @@
# Задачи Celery для синхронизации автомобилей и листинга IAAI. # Задачи Celery для синхронизации автомобилей и листинга Dubizzle.
import json import json
import logging import logging
@@ -13,25 +13,39 @@ from celery import shared_task
from redis import Redis from redis import Redis
from ..core.config import Settings, parse_listing_segments from ..core.config import Settings, parse_listing_segments
from ..scraper import IAAIScraper from ..scraper import DUBIZZLEScraper
from ..storage.db import PersistenceService from ..storage.db import PersistenceService
from ..discovery import SitemapDiscoveryError, discover_vehicle_urls_from_sitemap_with_stats from ..discovery import SitemapDiscoveryError, discover_vehicle_urls_from_sitemap_with_stats
logger = logging.getLogger("iaai_scraper.worker.tasks") logger = logging.getLogger("dubizzle_scraper.worker.tasks")
# Минимальная пауза между батчами (секунды) — не давит IAAI.
_INTER_BATCH_DELAY = max(float(os.getenv("IAAI_INTER_BATCH_DELAY_SECONDS", "0.3")), 0.0)
# Если доля failed в батче превышает порог — прерываем (IAAI блокирует).
_FAIL_RATE_THRESHOLD = min(max(float(os.getenv("IAAI_FAIL_RATE_THRESHOLD", "0.9")), 0.0), 1.0)
SYNC_LISTING_LOCK_KEY = "iaai:locks:sync_listing" def _env_str(name: str, default: str) -> str:
SYNC_FULL_SCAN_DONE_KEY = "iaai:state:sync_full_scan_done" value = os.getenv(name)
SYNC_LISTING_CHECKPOINT_KEY = "iaai:state:sync_listing_checkpoint" if value is None and name.startswith("DUBIZZLE_"):
value = os.getenv("DUBIZZLE_" + name[len("DUBIZZLE_"):])
return value if value is not None else default
def _env_float(name: str, default: float) -> float:
try:
return float(_env_str(name, str(default)).strip())
except Exception:
return default
# Минимальная пауза между батчами (секунды).
_INTER_BATCH_DELAY = max(_env_float("DUBIZZLE_INTER_BATCH_DELAY_SECONDS", 0.3), 0.0)
# Если доля failed в батче превышает порог — прерываем.
_FAIL_RATE_THRESHOLD = min(max(_env_float("DUBIZZLE_FAIL_RATE_THRESHOLD", 0.9), 0.0), 1.0)
SYNC_LISTING_LOCK_KEY = "dubizzle:locks:sync_listing"
SYNC_FULL_SCAN_DONE_KEY = "dubizzle:state:sync_full_scan_done"
SYNC_LISTING_CHECKPOINT_KEY = "dubizzle:state:sync_listing_checkpoint"
SYNC_LISTING_CHECKPOINT_TTL_SECONDS = 7 * 24 * 60 * 60 SYNC_LISTING_CHECKPOINT_TTL_SECONDS = 7 * 24 * 60 * 60
SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY = "iaai:state:sync_listing_bootstrap_failure_streak" SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY = "dubizzle:state:sync_listing_bootstrap_failure_streak"
SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT = 3 SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT = 3
SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_TTL_SECONDS = 24 * 60 * 60 SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_TTL_SECONDS = 24 * 60 * 60
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_KEY = "iaai:state:sync_listing_bootstrap_continuation_streak" SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_KEY = "dubizzle:state:sync_listing_bootstrap_continuation_streak"
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT = max( SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT = max(
1, 1,
int(os.getenv("SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT", "6")), int(os.getenv("SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT", "6")),
@@ -40,19 +54,45 @@ SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS = max(
60, 60,
int(os.getenv("SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS", str(6 * 60 * 60))), int(os.getenv("SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS", str(6 * 60 * 60))),
) )
HOURLY_FAILURE_STREAK_KEY = "iaai:state:hourly_failure_streak" HOURLY_FAILURE_STREAK_KEY = "dubizzle:state:hourly_failure_streak"
HOURLY_FAILURE_STREAK_LIMIT = 3 HOURLY_FAILURE_STREAK_LIMIT = 3
HOURLY_FAILURE_STREAK_TTL_SECONDS = 6 * 60 * 60 # сброс через 6 часов HOURLY_FAILURE_STREAK_TTL_SECONDS = 6 * 60 * 60 # сброс через 6 часов
SYNC_LISTING_FOLLOWUP_PENDING_KEY = "iaai:state:sync_listing_followup_pending" SYNC_LISTING_FOLLOWUP_PENDING_KEY = "dubizzle:state:sync_listing_followup_pending"
SYNC_LISTING_TASK_NAME = "iaai_scraper.worker.tasks.sync_listing_task" SYNC_LISTING_TASK_NAME = "dubizzle_scraper.worker.tasks.sync_listing_task"
SYNC_SEGMENT_LOCK_KEY_FMT = "iaai:locks:sync_segment:{idx}" SYNC_SEGMENT_LOCK_KEY_FMT = "dubizzle:locks:sync_segment:{idx}"
SYNC_SEGMENTS_PROGRESS_KEY = "iaai:state:sync_segments_progress" SYNC_SEGMENTS_PROGRESS_KEY = "dubizzle:state:sync_segments_progress"
SYNC_SEGMENTS_TOTAL_KEY = "iaai:state:sync_segments_total" SYNC_SEGMENTS_TOTAL_KEY = "dubizzle:state:sync_segments_total"
SYNC_SEGMENTED_SCAN_ACTIVE_KEY = "dubizzle:state:sync_segmented_scan_active"
SYNC_SEGMENTS_PROGRESS_TTL_SECONDS = 24 * 60 * 60 SYNC_SEGMENTS_PROGRESS_TTL_SECONDS = 24 * 60 * 60
TASK_PROGRESS_KEY_FMT = "iaai:state:task_progress:{task_id}" TASK_PROGRESS_KEY_FMT = "dubizzle:state:task_progress:{task_id}"
GLOBAL_PROGRESS_TS_KEY = "iaai:state:last_progress_ts" GLOBAL_PROGRESS_TS_KEY = "dubizzle:state:last_progress_ts"
SITEMAP_HOURLY_LAST_COUNT_KEY = "iaai:state:sitemap_hourly_last_count" SITEMAP_HOURLY_LAST_COUNT_KEY = "dubizzle:state:sitemap_hourly_last_count"
SITEMAP_HOURLY_REFRESH_OFFSET_KEY = "iaai:state:sitemap_hourly_refresh_offset" SITEMAP_HOURLY_REFRESH_OFFSET_KEY = "dubizzle:state:sitemap_hourly_refresh_offset"
def _runtime_key(key: str) -> str:
return key.replace("dubizzle:", "dubizzle:")
def _origin_prefix() -> str:
return _env_str("DUBIZZLE_ORIGIN_PREFIX", "dubizzle:")
SYNC_LISTING_LOCK_KEY = _runtime_key(SYNC_LISTING_LOCK_KEY)
SYNC_FULL_SCAN_DONE_KEY = _runtime_key(SYNC_FULL_SCAN_DONE_KEY)
SYNC_LISTING_CHECKPOINT_KEY = _runtime_key(SYNC_LISTING_CHECKPOINT_KEY)
SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY = _runtime_key(SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY)
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_KEY = _runtime_key(SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_KEY)
HOURLY_FAILURE_STREAK_KEY = _runtime_key(HOURLY_FAILURE_STREAK_KEY)
SYNC_LISTING_FOLLOWUP_PENDING_KEY = _runtime_key(SYNC_LISTING_FOLLOWUP_PENDING_KEY)
SYNC_SEGMENT_LOCK_KEY_FMT = _runtime_key(SYNC_SEGMENT_LOCK_KEY_FMT)
SYNC_SEGMENTS_PROGRESS_KEY = _runtime_key(SYNC_SEGMENTS_PROGRESS_KEY)
SYNC_SEGMENTS_TOTAL_KEY = _runtime_key(SYNC_SEGMENTS_TOTAL_KEY)
SYNC_SEGMENTED_SCAN_ACTIVE_KEY = _runtime_key(SYNC_SEGMENTED_SCAN_ACTIVE_KEY)
TASK_PROGRESS_KEY_FMT = _runtime_key(TASK_PROGRESS_KEY_FMT)
GLOBAL_PROGRESS_TS_KEY = _runtime_key(GLOBAL_PROGRESS_TS_KEY)
SITEMAP_HOURLY_LAST_COUNT_KEY = _runtime_key(SITEMAP_HOURLY_LAST_COUNT_KEY)
SITEMAP_HOURLY_REFRESH_OFFSET_KEY = _runtime_key(SITEMAP_HOURLY_REFRESH_OFFSET_KEY)
STALL_WATCHDOG_NAVIGATION_STAGES = { STALL_WATCHDOG_NAVIGATION_STAGES = {
"listing_next_page_started", "listing_next_page_started",
"listing_resume_progress", "listing_resume_progress",
@@ -150,7 +190,16 @@ def _clear_task_progress(redis_client: Redis, task_id: str) -> None:
logger.warning("Failed to clear task progress for %s", task_id, exc_info=True) logger.warning("Failed to clear task progress for %s", task_id, exc_info=True)
def _stall_timeout_for_progress(stage: str | None, default_timeout: int) -> int: def _stall_timeout_for_progress(
stage: str | None,
default_timeout: int,
*,
heartbeat_only: bool = False,
) -> int:
if heartbeat_only:
# Heartbeat-пульсы не считаем полноценным прогрессом:
# при долгом зависании даём только ограниченный grace-период.
return min(int(default_timeout), 120)
if stage in STALL_WATCHDOG_NAVIGATION_STAGES: if stage in STALL_WATCHDOG_NAVIGATION_STAGES:
return max(int(default_timeout), STALL_WATCHDOG_NAVIGATION_GRACE_SECONDS) return max(int(default_timeout), STALL_WATCHDOG_NAVIGATION_GRACE_SECONDS)
return int(default_timeout) return int(default_timeout)
@@ -165,12 +214,12 @@ def _hourly_sitemap_diff_sync(*, lane: str, limit: int | None, only_new: bool |
discovery_result = discover_vehicle_urls_from_sitemap_with_stats(settings=settings) discovery_result = discover_vehicle_urls_from_sitemap_with_stats(settings=settings)
discovered_urls = discovery_result.vehicle_urls discovered_urls = discovery_result.vehicle_urls
active_urls = set(discovered_urls) active_urls = set(discovered_urls)
existing_urls = persistence.get_all_active_origin_urls_for_lane("iaai:") existing_urls = persistence.get_all_active_origin_urls_for_lane(_origin_prefix())
new_urls = [url for url in discovered_urls if url not in existing_urls] new_urls = [url for url in discovered_urls if url not in existing_urls]
sold_count = 0 sold_count = 0
if active_urls: if active_urls:
sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane="iaai") sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane=_origin_prefix().rstrip(":"))
cars_upserted = 0 cars_upserted = 0
cars_failed = 0 cars_failed = 0
@@ -179,7 +228,7 @@ def _hourly_sitemap_diff_sync(*, lane: str, limit: int | None, only_new: bool |
failures: list[dict[str, str]] = [] failures: list[dict[str, str]] = []
if new_urls: if new_urls:
with IAAIScraper(settings) as scraper: with DUBIZZLEScraper(settings) as scraper:
if progress_callback: if progress_callback:
scraper.set_progress_callback(progress_callback) scraper.set_progress_callback(progress_callback)
batch_size = settings.celery.batch_size batch_size = settings.celery.batch_size
@@ -194,7 +243,7 @@ def _hourly_sitemap_diff_sync(*, lane: str, limit: int | None, only_new: bool |
protection_events += batch_protection protection_events += batch_protection
images_upserted += int(batch_result.get("images_upserted", 0)) images_upserted += int(batch_result.get("images_upserted", 0))
failures.extend(batch_result.get("failures", [])) failures.extend(batch_result.get("failures", []))
# Fail-fast: если слишком много ошибок — IAAI блокирует, не тратим ресурсы. # Fail-fast: если слишком много ошибок — DUBIZZLE блокирует, не тратим ресурсы.
total_in_batch = batch_ok + batch_fail total_in_batch = batch_ok + batch_fail
if total_in_batch > 0 and batch_fail / total_in_batch >= _FAIL_RATE_THRESHOLD: if total_in_batch > 0 and batch_fail / total_in_batch >= _FAIL_RATE_THRESHOLD:
logger.warning("Fail-fast: %d/%d failed in batch, stopping", batch_fail, total_in_batch) logger.warning("Fail-fast: %d/%d failed in batch, stopping", batch_fail, total_in_batch)
@@ -233,7 +282,7 @@ def _hourly_sitemap_full_refresh_sync(*, lane: str, limit: int | None, only_new:
active_urls = set(discovered_urls) active_urls = set(discovered_urls)
sold_count = 0 sold_count = 0
if active_urls: if active_urls:
sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane="iaai") sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane=_origin_prefix().rstrip(":"))
cars_upserted = 0 cars_upserted = 0
cars_failed = 0 cars_failed = 0
@@ -241,7 +290,7 @@ def _hourly_sitemap_full_refresh_sync(*, lane: str, limit: int | None, only_new:
images_upserted = 0 images_upserted = 0
failures: list[dict[str, str]] = [] failures: list[dict[str, str]] = []
with IAAIScraper(settings) as scraper: with DUBIZZLEScraper(settings) as scraper:
if progress_callback: if progress_callback:
scraper.set_progress_callback(progress_callback) scraper.set_progress_callback(progress_callback)
batch_size = settings.celery.batch_size batch_size = settings.celery.batch_size
@@ -301,12 +350,12 @@ def _hourly_sitemap_rolling_refresh_sync(
active_urls = set(discovered_urls) active_urls = set(discovered_urls)
sold_count = 0 sold_count = 0
if active_urls: if active_urls:
sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane="iaai") sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane=_origin_prefix().rstrip(":"))
existing_urls = persistence.get_all_active_origin_urls_for_lane("iaai:") existing_urls = persistence.get_all_active_origin_urls_for_lane(_origin_prefix())
new_urls = [url for url in discovered_urls if url not in existing_urls] new_urls = [url for url in discovered_urls if url not in existing_urls]
total_active = persistence.count_active_cars_for_lane("iaai:") total_active = persistence.count_active_cars_for_lane(_origin_prefix())
batch_size = max(1, int(settings.discovery.hourly_refresh_batch_size)) batch_size = max(1, int(settings.discovery.hourly_refresh_batch_size))
try: try:
offset = int(redis_client.get(SITEMAP_HOURLY_REFRESH_OFFSET_KEY) or 0) offset = int(redis_client.get(SITEMAP_HOURLY_REFRESH_OFFSET_KEY) or 0)
@@ -314,14 +363,14 @@ def _hourly_sitemap_rolling_refresh_sync(
offset = 0 offset = 0
refresh_urls = persistence.get_active_origin_urls_batch_for_refresh( refresh_urls = persistence.get_active_origin_urls_batch_for_refresh(
prefix="iaai:", prefix=_origin_prefix(),
offset=offset, offset=offset,
limit=batch_size, limit=batch_size,
) )
if not refresh_urls and total_active > 0: if not refresh_urls and total_active > 0:
offset = 0 offset = 0
refresh_urls = persistence.get_active_origin_urls_batch_for_refresh( refresh_urls = persistence.get_active_origin_urls_batch_for_refresh(
prefix="iaai:", prefix=_origin_prefix(),
offset=0, offset=0,
limit=batch_size, limit=batch_size,
) )
@@ -352,7 +401,7 @@ def _hourly_sitemap_rolling_refresh_sync(
failures: list[dict[str, str]] = [] failures: list[dict[str, str]] = []
if target_urls: if target_urls:
with IAAIScraper(settings) as scraper: with DUBIZZLEScraper(settings) as scraper:
if progress_callback: if progress_callback:
scraper.set_progress_callback(progress_callback) scraper.set_progress_callback(progress_callback)
worker_batch_size = settings.celery.batch_size worker_batch_size = settings.celery.batch_size
@@ -438,12 +487,19 @@ def _start_stall_watchdog(
no_data_count = 0 no_data_count = 0
data = json.loads(raw) data = json.loads(raw)
stage = data.get("stage") stage = data.get("stage")
heartbeat_only = bool(data.get("heartbeat_only"))
last_ts = int(data.get("ts") or 0) last_ts = int(data.get("ts") or 0)
if not last_ts: if not last_ts:
continue continue
effective_stall_timeout = _stall_timeout_for_progress(stage, stall_timeout_seconds) effective_stall_timeout = _stall_timeout_for_progress(
stage,
stall_timeout_seconds,
heartbeat_only=heartbeat_only,
)
age = int(time.time()) - last_ts age = int(time.time()) - last_ts
if age < effective_stall_timeout: if age < effective_stall_timeout:
# Heartbeat-пульс не должен бесконечно продлевать дедлайн.
if not heartbeat_only:
watchdog_born = time.monotonic() # reset absolute deadline on real progress watchdog_born = time.monotonic() # reset absolute deadline on real progress
continue continue
logger.error( logger.error(
@@ -620,6 +676,15 @@ def _release_lock_if_owner(redis_client: Redis, key: str, owner_token: str) -> N
def _has_running_sync_listing_tasks(celery_app, *, exclude_task_id: str | None = None) -> bool: def _has_running_sync_listing_tasks(celery_app, *, exclude_task_id: str | None = None) -> bool:
return _has_running_task_named(celery_app, SYNC_LISTING_TASK_NAME, exclude_task_id=exclude_task_id)
def _has_running_task_named(
celery_app,
task_name: str,
*,
exclude_task_id: str | None = None,
) -> bool:
try: try:
inspector = celery_app.control.inspect(timeout=1.0) inspector = celery_app.control.inspect(timeout=1.0)
snapshots = [ snapshots = [
@@ -634,8 +699,8 @@ def _has_running_sync_listing_tasks(celery_app, *, exclude_task_id: str | None =
for snapshot in snapshots: for snapshot in snapshots:
for entries in snapshot.values(): for entries in snapshot.values():
for entry in entries or []: for entry in entries or []:
task_name = str(entry.get("name") or entry.get("request", {}).get("name") or "") entry_task_name = str(entry.get("name") or entry.get("request", {}).get("name") or "")
if task_name != SYNC_LISTING_TASK_NAME: if entry_task_name != task_name:
continue continue
# Исключаем текущую задачу — она не считается "другой запущенной" # Исключаем текущую задачу — она не считается "другой запущенной"
entry_id = str(entry.get("id") or entry.get("request", {}).get("id") or "") entry_id = str(entry.get("id") or entry.get("request", {}).get("id") or "")
@@ -645,6 +710,59 @@ def _has_running_sync_listing_tasks(celery_app, *, exclude_task_id: str | None =
return False return False
def _has_running_sync_segment_tasks(celery_app) -> bool:
return _has_running_task_named(celery_app, "dubizzle_scraper.worker.tasks.sync_segment_task")
def _set_segmented_scan_active(redis_client: Redis, *, total: int) -> None:
try:
payload = {
"ts": int(time.time()),
"total": int(total),
}
redis_client.set(
SYNC_SEGMENTED_SCAN_ACTIVE_KEY,
json.dumps(payload, ensure_ascii=False),
ex=SYNC_SEGMENTS_PROGRESS_TTL_SECONDS,
)
except Exception:
logger.warning("Failed to persist segmented scan active state", exc_info=True)
def _clear_segmented_scan_active(redis_client: Redis) -> None:
try:
redis_client.delete(SYNC_SEGMENTED_SCAN_ACTIVE_KEY)
except Exception:
logger.warning("Failed to clear segmented scan active state", exc_info=True)
def _is_segmented_scan_in_progress(redis_client: Redis, celery_app) -> bool:
try:
marker = redis_client.get(SYNC_SEGMENTED_SCAN_ACTIVE_KEY)
except Exception:
logger.warning("Failed to read segmented scan active state", exc_info=True)
return False
if not marker:
return False
if _has_running_sync_segment_tasks(celery_app):
return True
try:
queue_len = int(redis_client.llen("scraping") or 0)
except Exception:
logger.warning("Failed to inspect scraping queue length", exc_info=True)
queue_len = 0
if queue_len > 0:
return True
logger.warning("Clearing stale segmented scan active state: no running segment tasks detected")
_clear_segmented_scan_active(redis_client)
return False
def _clear_orphan_sync_listing_lock(redis_client: Redis, celery_app, *, current_task_id: str | None = None) -> bool: def _clear_orphan_sync_listing_lock(redis_client: Redis, celery_app, *, current_task_id: str | None = None) -> bool:
try: try:
owner_token = redis_client.get(SYNC_LISTING_LOCK_KEY) owner_token = redis_client.get(SYNC_LISTING_LOCK_KEY)
@@ -902,7 +1020,7 @@ def _mark_segment_completed(redis_client: Redis, segment_index: int) -> tuple[in
@shared_task( @shared_task(
name="iaai_scraper.worker.tasks.sync_segment_task", name="dubizzle_scraper.worker.tasks.sync_segment_task",
bind=True, bind=True,
max_retries=2, max_retries=2,
default_retry_delay=60, default_retry_delay=60,
@@ -912,7 +1030,7 @@ def sync_segment_task(
self, self,
segment_index: int, segment_index: int,
segment: dict, segment: dict,
lane: str = "iaai_cars", lane: str = "dubizzle_cars",
only_new: bool | None = None, only_new: bool | None = None,
is_bootstrap: bool = False, is_bootstrap: bool = False,
): ):
@@ -965,7 +1083,7 @@ def sync_segment_task(
try: try:
def _job(): def _job():
with IAAIScraper() as scraper: with DUBIZZLEScraper() as scraper:
scraper.set_progress_callback( scraper.set_progress_callback(
lambda stage, meta: _update_task_progress( lambda stage, meta: _update_task_progress(
redis_client, redis_client,
@@ -1011,9 +1129,15 @@ def sync_segment_task(
segment_index, seg_label, completed, total, segment_index, seg_label, completed, total,
) )
if total > 0 and completed >= total: if total > 0 and completed >= total:
_set_full_scan_done(redis_client, True) always_full_scan = bool(Settings().discovery.always_full_scan)
_set_full_scan_done(redis_client, False if always_full_scan else True)
_clear_sync_checkpoint(redis_client) _clear_sync_checkpoint(redis_client)
_clear_segmented_scan_active(redis_client)
_clear_bootstrap_failure_streak(redis_client) _clear_bootstrap_failure_streak(redis_client)
_clear_bootstrap_continuation_streak(redis_client)
if always_full_scan:
logger.warning("All %d segments completed; next hourly run will restart from segment 0", total)
else:
logger.warning("All %d segments completed; bootstrap full scan done", total) logger.warning("All %d segments completed; bootstrap full scan done", total)
return { return {
@@ -1075,20 +1199,20 @@ def sync_segment_task(
@shared_task( @shared_task(
name="iaai_scraper.worker.tasks.sync_vehicle_task", name="dubizzle_scraper.worker.tasks.sync_vehicle_task",
bind=True, bind=True,
max_retries=2, max_retries=2,
default_retry_delay=30, default_retry_delay=30,
acks_late=True, acks_late=True,
) )
def sync_vehicle_task(self, vehicle_url: str, lane: str = "iaai"): def sync_vehicle_task(self, vehicle_url: str, lane: str = "dubizzle"):
# Скрапинг и upsert одного автомобиля. # Скрапинг и upsert одного автомобиля.
persistence = _get_persistence() persistence = _get_persistence()
persistence.create_tables() persistence.create_tables()
try: try:
def _job(): def _job():
with IAAIScraper() as scraper: with DUBIZZLEScraper() as scraper:
return scraper.sync_vehicle(vehicle_url, lane=lane) return scraper.sync_vehicle(vehicle_url, lane=lane)
result = _run_browser_job(_job) result = _run_browser_job(_job)
@@ -1106,7 +1230,7 @@ def sync_vehicle_task(self, vehicle_url: str, lane: str = "iaai"):
@shared_task( @shared_task(
name="iaai_scraper.worker.tasks.sync_listing_task", name="dubizzle_scraper.worker.tasks.sync_listing_task",
bind=True, bind=True,
max_retries=3, max_retries=3,
default_retry_delay=120, default_retry_delay=120,
@@ -1116,7 +1240,7 @@ def sync_listing_task(
self, self,
make: str | None = None, make: str | None = None,
model: str | None = None, model: str | None = None,
lane: str = "iaai_cars", lane: str = "dubizzle_cars",
limit: int | None = None, limit: int | None = None,
only_new: bool | None = None, only_new: bool | None = None,
): ):
@@ -1159,11 +1283,12 @@ def sync_listing_task(
continuation_streak, should_continue = _bump_bootstrap_continuation_streak(redis_client) continuation_streak, should_continue = _bump_bootstrap_continuation_streak(redis_client)
if not should_continue: if not should_continue:
_clear_followup_pending(redis_client) _clear_followup_pending(redis_client)
# Переключаемся на часовой beat-режим и останавливаем # Останавливаем немедленные bootstrap continuation, чтобы не зациклиться.
# немедленные bootstrap continuation, чтобы не зациклиться. # Фиксируем done + чистим checkpoint даже при always_full_scan:
if not always_full_scan: # это безопасно и предотвращает повторный старт со stale-сегмента.
_set_full_scan_done(redis_client, True) _set_full_scan_done(redis_client, True)
_clear_sync_checkpoint(redis_client) _clear_sync_checkpoint(redis_client)
if not always_full_scan:
logger.error( logger.error(
"Bootstrap continuation stopped after %d immediate runs; switching to hourly schedule", "Bootstrap continuation stopped after %d immediate runs; switching to hourly schedule",
continuation_streak, continuation_streak,
@@ -1178,7 +1303,7 @@ def sync_listing_task(
try: try:
followup_expires = max(int(lock_ttl), int(delay_seconds) + 300) followup_expires = max(int(lock_ttl), int(delay_seconds) + 300)
self.app.send_task( self.app.send_task(
"iaai_scraper.worker.tasks.sync_listing_task", "dubizzle_scraper.worker.tasks.sync_listing_task",
kwargs={ kwargs={
"make": make, "make": make,
"model": model, "model": model,
@@ -1247,17 +1372,15 @@ def sync_listing_task(
) )
full_scan_done_before_run = _is_full_scan_done(redis_client) full_scan_done_before_run = _is_full_scan_done(redis_client)
hourly_mode = settings.discovery.hourly_mode.strip().lower()
discovery_mode = settings.discovery.mode.strip().lower()
always_full_scan = bool(settings.discovery.always_full_scan) always_full_scan = bool(settings.discovery.always_full_scan)
force_bootstrap_full_scan = always_full_scan or (not full_scan_done_before_run) force_bootstrap_full_scan = always_full_scan or (not full_scan_done_before_run)
effective_limit = None if force_bootstrap_full_scan else limit effective_limit = None if force_bootstrap_full_scan else limit
effective_only_new = False if force_bootstrap_full_scan else only_new effective_only_new = False if force_bootstrap_full_scan else only_new
hourly_mode = settings.discovery.hourly_mode.strip().lower()
discovery_mode = settings.discovery.mode.strip().lower()
if always_full_scan:
# Для режима "полный прогон каждый запуск" приоритет — устойчивый resume,
# поэтому принудительно уходим в listing/segmented path вместо sitemap-mainline.
discovery_mode = "listing"
prefer_sitemap_mainline = ( prefer_sitemap_mainline = (
discovery_mode == "sitemap"
and
not force_bootstrap_full_scan not force_bootstrap_full_scan
and make is None and make is None
and model is None and model is None
@@ -1268,22 +1391,23 @@ def sync_listing_task(
use_hourly_sitemap_sync = (not always_full_scan) and full_scan_done_before_run and prefer_sitemap_mainline use_hourly_sitemap_sync = (not always_full_scan) and full_scan_done_before_run and prefer_sitemap_mainline
# Segment-level checkpoint: хранит индекс последнего ПОЛНОСТЬЮ пройденного сегмента. # Segment-level checkpoint: хранит индекс последнего ПОЛНОСТЬЮ пройденного сегмента.
# Используется только во время bootstrap для пропуска уже обработанных сегментов. # Используется во время bootstrap/full-scan resume.
# Никаких page-level resume — внутри сегмента всегда стартуем с page 1.
last_completed_segment: int | None = None last_completed_segment: int | None = None
if force_bootstrap_full_scan: resume_from_checkpoint = force_bootstrap_full_scan and (
always_full_scan or (not full_scan_done_before_run)
)
if resume_from_checkpoint:
last_completed_segment = _load_last_completed_segment(redis_client) last_completed_segment = _load_last_completed_segment(redis_client)
else: else:
# После завершения bootstrap чекпоинт не нужен никогда.
_clear_sync_checkpoint(redis_client) _clear_sync_checkpoint(redis_client)
if force_bootstrap_full_scan: if force_bootstrap_full_scan:
logger.info( logger.info(
"Bootstrap mode: forcing full scan (only_new=False, limit=None) until first complete run", "Bootstrap mode: forcing full scan (only_new=False, limit=None) until first complete run",
) )
if always_full_scan: if discovery_mode == "algolia":
logger.info( logger.info(
"Always full scan mode enabled (IAAI_ALWAYS_FULL_SCAN=true): using listing resume path", "Algolia full scan enabled: using segmented Algolia traversal when configured",
) )
logger.info( logger.info(
@@ -1413,28 +1537,31 @@ def sync_listing_task(
self.update_state(state="STARTED", meta={"stage": "sync_listing_started", "task_id": task_id}) self.update_state(state="STARTED", meta={"stage": "sync_listing_started", "task_id": task_id})
# Определяем сегменты из конфига.
segments = parse_listing_segments(settings.listing.listing_segments_json) segments = parse_listing_segments(settings.listing.listing_segments_json)
use_segmented = ( use_segmented = (
bool(segments) bool(segments)
and make is None and make is None
and model is None and model is None
and not prefer_sitemap_mainline and not prefer_sitemap_mainline
and discovery_mode != "sitemap" and discovery_mode in {"listing", "algolia"}
) )
if prefer_sitemap_mainline: if prefer_sitemap_mainline and segments:
if discovery_mode != "sitemap":
logger.warning(
"Unfiltered full scan forcing sitemap discovery despite IAAI_DISCOVERY_MODE=%s",
discovery_mode or "unset",
)
if segments:
logger.info("Ignoring configured listing segments for unfiltered sitemap full scan") logger.info("Ignoring configured listing segments for unfiltered sitemap full scan")
# --- Параллельный диспатч сегментов: dispatch & exit ---
if use_segmented and settings.celery.parallel_segments: if use_segmented and settings.celery.parallel_segments:
# Сегменты уже завершённые (для bootstrap resume) пропускаем по Redis SET. segmented_scan_in_progress = False
if force_bootstrap_full_scan:
segmented_scan_in_progress = _is_segmented_scan_in_progress(redis_client, self.app)
if segmented_scan_in_progress:
logger.warning("Parallel segments: scan already in progress, skipping duplicate dispatch")
return {
"status": "skipped",
"reason": "segmented_scan_in_progress",
"task_id": task_id,
"mode": "parallel_segments",
}
already_completed: set[int] = set() already_completed: set[int] = set()
if force_bootstrap_full_scan: if force_bootstrap_full_scan:
try: try:
@@ -1443,17 +1570,20 @@ def sync_listing_task(
except Exception: except Exception:
already_completed = set() already_completed = set()
if always_full_scan:
already_completed = set()
pending = [ pending = [
(idx, seg) for idx, seg in enumerate(segments) (idx, seg) for idx, seg in enumerate(segments)
if idx not in already_completed if idx not in already_completed
] ]
if not pending: if not pending:
# Всё уже сделано — фиксируем bootstrap done.
if force_bootstrap_full_scan: if force_bootstrap_full_scan:
_set_full_scan_done(redis_client, True) _set_full_scan_done(redis_client, True)
_clear_sync_checkpoint(redis_client) _clear_sync_checkpoint(redis_client)
_clear_bootstrap_failure_streak(redis_client) _clear_bootstrap_failure_streak(redis_client)
_clear_segmented_scan_active(redis_client)
logger.warning("Parallel segments: nothing to dispatch (all completed)") logger.warning("Parallel segments: nothing to dispatch (all completed)")
return { return {
"status": "success", "status": "success",
@@ -1464,15 +1594,18 @@ def sync_listing_task(
"segments_already_completed": len(already_completed), "segments_already_completed": len(already_completed),
} }
# При первом запуске bootstrap фиксируем total, чтобы знать когда остановиться. if force_bootstrap_full_scan and (always_full_scan or not already_completed):
if force_bootstrap_full_scan and not already_completed:
_reset_segments_progress(redis_client, len(segments)) _reset_segments_progress(redis_client, len(segments))
already_completed = set()
if force_bootstrap_full_scan:
_set_segmented_scan_active(redis_client, total=len(segments))
dispatched = 0 dispatched = 0
for idx, seg in pending: for idx, seg in pending:
try: try:
self.app.send_task( self.app.send_task(
"iaai_scraper.worker.tasks.sync_segment_task", "dubizzle_scraper.worker.tasks.sync_segment_task",
kwargs={ kwargs={
"segment_index": idx, "segment_index": idx,
"segment": seg, "segment": seg,
@@ -1498,13 +1631,11 @@ def sync_listing_task(
"segments_dispatched": dispatched, "segments_dispatched": dispatched,
"segments_already_completed": len(already_completed), "segments_already_completed": len(already_completed),
} }
# --- конец параллельной ветки ---
resume_from_segment = 0 resume_from_segment = 0
if force_bootstrap_full_scan and use_segmented and last_completed_segment is not None: if force_bootstrap_full_scan and use_segmented and last_completed_segment is not None:
resume_from_segment = max(0, last_completed_segment + 1) resume_from_segment = max(0, last_completed_segment + 1)
if resume_from_segment >= len(segments): if resume_from_segment >= len(segments):
# Все сегменты уже пройдены — чекпоинт устарел, начинаем заново.
logger.info( logger.info(
"Stored checkpoint segment=%d is beyond configured segments (%d); restarting bootstrap from segment 0", "Stored checkpoint segment=%d is beyond configured segments (%d); restarting bootstrap from segment 0",
last_completed_segment, len(segments), last_completed_segment, len(segments),
@@ -1527,7 +1658,7 @@ def sync_listing_task(
) )
def _job(): def _job():
with IAAIScraper() as scraper: with DUBIZZLEScraper() as scraper:
scraper.set_progress_callback(_progress_cb_main) scraper.set_progress_callback(_progress_cb_main)
if use_segmented: if use_segmented:
return scraper.sync_listing_segmented( return scraper.sync_listing_segmented(
@@ -1538,7 +1669,7 @@ def sync_listing_task(
start_page=1, start_page=1,
progress_callback=( progress_callback=(
(lambda seg_idx: _save_last_completed_segment(redis_client, seg_idx)) (lambda seg_idx: _save_last_completed_segment(redis_client, seg_idx))
if force_bootstrap_full_scan else None if resume_from_checkpoint else None
), ),
) )
return scraper.sync_listing( return scraper.sync_listing(
@@ -1654,7 +1785,7 @@ def sync_listing_task(
if _try_set_followup_pending(redis_client, ttl_seconds=followup_ttl): if _try_set_followup_pending(redis_client, ttl_seconds=followup_ttl):
try: try:
self.app.send_task( self.app.send_task(
"iaai_scraper.worker.tasks.sync_listing_task", "dubizzle_scraper.worker.tasks.sync_listing_task",
kwargs={ kwargs={
"make": make, "make": make,
"model": model, "model": model,

View File

@@ -4,7 +4,7 @@ set -euo pipefail
is_worker_command() { is_worker_command() {
local joined="$*" local joined="$*"
case "$joined" in case "$joined" in
*"celery -A iaai_scraper.worker.celery_app worker"*|*" celery -A iaai_scraper.worker.celery_app worker"*) *"celery -A dubizzle_scraper.worker.celery_app worker"*|*" celery -A dubizzle_scraper.worker.celery_app worker"*)
return 0 return 0
;; ;;
esac esac
@@ -36,19 +36,19 @@ start_proxy_bridge_if_needed() {
echo "[entrypoint] Starting proxy bridge (HTTP :8899 → SOCKS5 ${SOCKS5_PROXY_HOST}:${SOCKS5_PROXY_PORT:-1002})..." echo "[entrypoint] Starting proxy bridge (HTTP :8899 → SOCKS5 ${SOCKS5_PROXY_HOST}:${SOCKS5_PROXY_PORT:-1002})..."
if [ -z "${IAAI_PROXY_SERVER:-}" ]; then if [ -z "${DUBIZZLE_PROXY_SERVER:-}" ]; then
export IAAI_PROXY_SERVER="http://127.0.0.1:8899" export DUBIZZLE_PROXY_SERVER="http://127.0.0.1:8899"
elif [ "${IAAI_PROXY_SERVER}" = "http://localhost:8899" ]; then elif [ "${DUBIZZLE_PROXY_SERVER}" = "http://localhost:8899" ]; then
export IAAI_PROXY_SERVER="http://127.0.0.1:8899" export DUBIZZLE_PROXY_SERVER="http://127.0.0.1:8899"
fi fi
echo "[entrypoint] Using browser proxy: ${IAAI_PROXY_SERVER}" echo "[entrypoint] Using browser proxy: ${DUBIZZLE_PROXY_SERVER}"
python -m iaai_scraper.proxy_bridge & python -m dubizzle_scraper.proxy_bridge &
BRIDGE_PID=$! BRIDGE_PID=$!
sleep 1 sleep 1
if ! kill -0 "${BRIDGE_PID}" 2>/dev/null; then if ! kill -0 "${BRIDGE_PID}" 2>/dev/null; then
echo "[entrypoint] ERROR: iaai_scraper.proxy_bridge failed to start" echo "[entrypoint] ERROR: dubizzle_scraper.proxy_bridge failed to start"
exit 1 exit 1
fi fi
@@ -65,13 +65,13 @@ start_self_heal_watchdog_if_worker() {
# Удаляем его перед запуском worker-процесса. # Удаляем его перед запуском worker-процесса.
rm -f /tmp/celery-worker.pid rm -f /tmp/celery-worker.pid
if [ "${IAAI_SELF_HEAL_ENABLED:-true}" = "false" ]; then if [ "${DUBIZZLE_SELF_HEAL_ENABLED:-true}" = "false" ]; then
echo "[entrypoint] Self-heal watchdog disabled" echo "[entrypoint] Self-heal watchdog disabled"
return 0 return 0
fi fi
echo "[entrypoint] Starting self-heal watchdog for worker..." echo "[entrypoint] Starting self-heal watchdog for worker..."
python -m iaai_scraper.worker.self_heal & python -m dubizzle_scraper.worker.self_heal &
SELF_HEAL_PID=$! SELF_HEAL_PID=$!
sleep 1 sleep 1

View File

@@ -1,308 +0,0 @@
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
TRUE_VALUES = {"1", "true", "yes", "on"}
# Хелперы для чтения env-переменных с приведением типов
def _env_str(name: str, default: str) -> str:
value = os.getenv(name)
return value if value is not None else default
def _env_optional_str(name: str) -> str | None:
value = os.getenv(name)
if value is None:
return None
value = value.strip()
return value or None
def _env_path_str(name: str) -> str | None:
value = _env_optional_str(name)
if value is None:
return None
return str(Path(value).expanduser())
def _env_bool(name: str, default: bool) -> bool:
fallback = "true" if default else "false"
return _env_str(name, fallback).strip().lower() in TRUE_VALUES
def _env_int(name: str, default: int) -> int:
return int(_env_str(name, str(default)).strip())
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("IAAI_CAPTURE_SAME_ORIGIN_ONLY", True)
max_requests: int = _env_int("IAAI_MAX_CAPTURED_REQUESTS", 40)
max_json_responses: int = _env_int("IAAI_MAX_CAPTURED_JSON_RESPONSES", 30)
# Конфиг пауз между действиями (имитация человека)
@dataclass(slots=True)
class HumanPaceConfig:
enabled: bool = _env_bool("IAAI_HUMAN_PACE_ENABLED", True)
after_listing_open_min_s: float = _env_float("IAAI_AFTER_LISTING_OPEN_MIN_S", 0.5)
after_listing_open_max_s: float = _env_float("IAAI_AFTER_LISTING_OPEN_MAX_S", 1.2)
after_filter_action_min_s: float = _env_float("IAAI_AFTER_FILTER_ACTION_MIN_S", 0.5)
after_filter_action_max_s: float = _env_float("IAAI_AFTER_FILTER_ACTION_MAX_S", 1.2)
before_vehicle_open_min_s: float = _env_float("IAAI_BEFORE_VEHICLE_OPEN_MIN_S", 0.1)
before_vehicle_open_max_s: float = _env_float("IAAI_BEFORE_VEHICLE_OPEN_MAX_S", 0.3)
after_vehicle_open_min_s: float = _env_float("IAAI_AFTER_VEHICLE_OPEN_MIN_S", 0.05)
after_vehicle_open_max_s: float = _env_float("IAAI_AFTER_VEHICLE_OPEN_MAX_S", 0.15)
between_vehicles_min_s: float = _env_float("IAAI_BETWEEN_VEHICLES_MIN_S", 0.05)
between_vehicles_max_s: float = _env_float("IAAI_BETWEEN_VEHICLES_MAX_S", 0.15)
after_page_change_min_s: float = _env_float("IAAI_AFTER_PAGE_CHANGE_MIN_S", 0.8)
after_page_change_max_s: float = _env_float("IAAI_AFTER_PAGE_CHANGE_MAX_S", 1.8)
# Конфиг сбора листинга (URL, лимиты страниц и машин)
@dataclass(slots=True)
class ListingConfig:
cars_url: str = _env_str("IAAI_CARS_LISTING_URL", "https://www.iaai.com/Vehiclelisting/Cars")
max_pages_per_run: int = _env_int("IAAI_MAX_PAGES_PER_RUN", 9999)
max_vehicles_per_run: int = _env_int("IAAI_MAX_VEHICLES_PER_RUN", 50000)
page_link_limit: int = _env_int("IAAI_PAGE_LINK_LIMIT", 500)
include_pagination: bool = _env_bool("IAAI_INCLUDE_PAGINATION", True)
collect_current_page_only: bool = _env_bool("IAAI_COLLECT_CURRENT_PAGE_ONLY", False)
# Порог раннего останова: если доля уже известных машин на странице >= этого значения,
# прекращаем листать — все новые машины уже найдены. 0 = отключено.
early_stop_threshold: float = _env_float("IAAI_EARLY_STOP_THRESHOLD", 0.8)
# Сегментация листинга по брендам для обхода лимита пагинации IAAI (~22 600 машин).
# JSON-массив объектов: [{"make":"TOYOTA"},{"make":"FORD"},...] или "auto" для авто-списка.
# Пустая строка = без сегментации (backward compatible).
listing_segments_json: str = _env_str("IAAI_LISTING_SEGMENTS", "")
# Список брендов IAAI для автоматической сегментации.
# Покрывает >99% автомобилей на сайте. Порядок: от крупных к мелким.
IAAI_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",
)
# Пагинационный потолок IAAI: ~226 страниц × 100 = 22 600 результатов.
IAAI_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 parse_listing_segments(raw: str) -> list[dict[str, str | int | None]]:
"""Парсит IAAI_LISTING_SEGMENTS в список сегментов.
Каждый сегмент — dict с ключами: make (str), year_min/year_max (int|None).
Специальное значение ``"auto"`` генерирует сегменты из IAAI_DEFAULT_MAKES.
Крупные бренды автоматически разбиваются по диапазонам годов.
"""
raw = raw.strip()
if not raw:
return []
if raw.lower() == "auto":
segments: list[dict[str, str | int | None]] = []
for m in IAAI_DEFAULT_MAKES:
if m in _LARGE_MAKES:
for yr_min, yr_max in _YEAR_SPLITS:
segments.append({"make": m, "year_min": yr_min, "year_max": yr_max})
else:
segments.append({"make": m, "year_min": None, "year_max": None})
return segments
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)
@dataclass(slots=True)
class DatabaseConfig:
url: str = _env_str("IAAI_DATABASE_URL", "postgresql+psycopg2://iaai:iaai@localhost:5432/iaai_scraper")
echo: bool = _env_bool("IAAI_DATABASE_ECHO", False)
pool_size: int = _env_int("IAAI_DATABASE_POOL_SIZE", 5)
max_overflow: int = _env_int("IAAI_DATABASE_MAX_OVERFLOW", 10)
pool_recycle_seconds: int = _env_int("IAAI_DATABASE_POOL_RECYCLE_SECONDS", 1800)
auto_create_tables: bool = _env_bool("IAAI_DATABASE_AUTO_CREATE_TABLES", False)
# --- Конфиг Redis (URL для Celery broker) ---
@dataclass(slots=True)
class RedisConfig:
url: str = _env_str("IAAI_REDIS_URL", "redis://localhost:6379/0")
socket_timeout_seconds: float = _env_float("IAAI_REDIS_SOCKET_TIMEOUT_SECONDS", 10.0)
socket_connect_timeout_seconds: float = _env_float("IAAI_REDIS_SOCKET_CONNECT_TIMEOUT_SECONDS", 5.0)
health_check_interval_seconds: int = _env_int("IAAI_REDIS_HEALTH_CHECK_INTERVAL_SECONDS", 30)
# --- Конфиг Discovery (режим обнаружения, hourly batch) ---
@dataclass(slots=True)
class DiscoveryConfig:
mode: str = _env_str("IAAI_DISCOVERY_MODE", "sitemap")
hourly_mode: str = _env_str("IAAI_HOURLY_MODE", "rolling_refresh")
hourly_refresh_batch_size: int = _env_int("IAAI_HOURLY_REFRESH_BATCH_SIZE", 500)
always_full_scan: bool = _env_bool("IAAI_ALWAYS_FULL_SCAN", True)
# --- Конфиг Celery (лимиты задач, concurrency, beat-расписание) ---
@dataclass(slots=True)
class CeleryConfig:
broker_url: str = _env_str("CELERY_BROKER_URL", "")
result_backend: str = _env_str("CELERY_RESULT_BACKEND", "")
task_soft_time_limit: int = _env_int("CELERY_TASK_SOFT_TIME_LIMIT", 3300)
task_time_limit: int = _env_int("CELERY_TASK_TIME_LIMIT", 3600)
task_stall_timeout_seconds: int = _env_int("CELERY_TASK_STALL_TIMEOUT_SECONDS", 600)
worker_concurrency: int = _env_int("CELERY_WORKER_CONCURRENCY", 4)
worker_max_tasks_per_child: int = _env_int("CELERY_WORKER_MAX_TASKS_PER_CHILD", 5)
broker_visibility_timeout: int = _env_int("CELERY_BROKER_VISIBILITY_TIMEOUT", 7200)
beat_sync_interval_minutes: int = _env_int("CELERY_BEAT_SYNC_INTERVAL_MINUTES", 60)
beat_sync_limit: int | None = _env_int("CELERY_BEAT_SYNC_LIMIT", 0) or None
batch_size: int = _env_int("CELERY_BATCH_SIZE", 50)
parallel_tabs: int = _env_int("IAAI_PARALLEL_TABS", 8)
parallel_segments: bool = _env_bool("CELERY_PARALLEL_SEGMENTS", False)
block_resources: bool = _env_bool("IAAI_BLOCK_RESOURCES", True)
# --- Конфиг прокси (server, username, password) ---
@dataclass(slots=True)
class ProxyConfig:
server: str | None = _env_optional_str("IAAI_PROXY_SERVER")
username: str | None = _env_optional_str("IAAI_PROXY_USERNAME")
password: str | None = _env_optional_str("IAAI_PROXY_PASSWORD")
@property
def enabled(self) -> bool:
return bool(self.server)
def to_playwright_dict(self) -> dict[str, str] | None:
if not self.server:
return None
result: dict[str, str] = {"server": self.server}
if self.username:
result["username"] = self.username
if self.password:
result["password"] = self.password
return result
# Главный объект настроек: собирает все блоки конфигурации
@dataclass(slots=True)
class Settings:
home_url: str = "https://www.iaai.com/"
default_timeout_ms: int = _env_int("IAAI_TIMEOUT_MS", 45000)
network_settle_ms: int = _env_int("IAAI_NETWORK_SETTLE_MS", 400)
fast_path_timeout_ms: int = _env_int("IAAI_FAST_PATH_TIMEOUT_MS", 5000)
fast_path_max_attempts: int = _env_int("IAAI_FAST_PATH_MAX_ATTEMPTS", 1)
fallback_navigation_timeout_ms: int = _env_int("IAAI_FALLBACK_NAV_TIMEOUT_MS", 15000)
max_retries: int = _env_int("IAAI_MAX_RETRIES", 3)
retry_delay_seconds: float = _env_float("IAAI_RETRY_DELAY_SECONDS", 2.5)
retry_backoff_multiplier: float = _env_float("IAAI_RETRY_BACKOFF_MULTIPLIER", 2.0)
retry_jitter_seconds: float = _env_float("IAAI_RETRY_JITTER_SECONDS", 0.25)
headless: bool = _env_bool("IAAI_HEADLESS", True)
browser_engine: str = _env_str("IAAI_BROWSER_ENGINE", "auto")
log_level: str = _env_str("IAAI_LOG_LEVEL", "INFO")
log_file: str | None = _env_optional_str("IAAI_LOG_FILE")
enable_trace_id_logs: bool = _env_bool("IAAI_ENABLE_TRACE_ID_LOGS", True)
sync_only_new: bool = _env_bool("IAAI_SYNC_ONLY_NEW", False)
raw_output_json: str | None = _env_optional_str("IAAI_RAW_OUTPUT_JSON")
tokens_file: str | None = _env_path_str("IAAI_TOKENS_FILE")
runtime_config_file: str | None = _env_path_str("IAAI_RUNTIME_CONFIG_FILE")
scheduler_interval_minutes: int = _env_int("IAAI_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)
@property
def parallel_tabs(self) -> int:
return self.celery.parallel_tabs
@property
def block_resources(self) -> bool:
return self.celery.block_resources
# Глобальный синглтон — используется по умолчанию во всех модулях.
settings = Settings()

View File

@@ -3,9 +3,9 @@ requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "iaai-scraper" name = "dubizzle-scraper"
version = "0.1.0" version = "0.1.0"
description = "IAAI scraper service with FastAPI, Celery, Playwright and PostgreSQL" description = "Dubizzle scraper service with FastAPI, Celery, Playwright and PostgreSQL"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -29,13 +29,13 @@ dev = [
] ]
[project.scripts] [project.scripts]
iaai = "iaai_scraper.cli:main" dubizzle = "dubizzle_scraper.cli:main"
[tool.setuptools] [tool.setuptools]
include-package-data = true include-package-data = true
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["iaai_scraper*"] include = ["dubizzle_scraper*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-q --disable-warnings" addopts = "-q --disable-warnings"

View File

@@ -5,7 +5,7 @@
"ids_next_size": null, "ids_next_size": null,
"ids_max_pages": null, "ids_max_pages": null,
"condition_check_enabled": false, "condition_check_enabled": false,
"lane": "iaai_cars", "lane": "dubizzle_cars",
"only_new": false, "only_new": false,
"limit": null "limit": null
}, },

View File

@@ -6,10 +6,10 @@ from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from iaai_scraper.core.config import Settings from dubizzle_scraper.core.config import Settings
from iaai_scraper.storage.db import PersistenceService from dubizzle_scraper.storage.db import PersistenceService
from iaai_scraper.storage.models import Car, Image, SyncRun from dubizzle_scraper.storage.models import Car, Image, SyncRun
from iaai_scraper.storage.schemas import CarRecord, ImageRecord from dubizzle_scraper.storage.schemas import CarRecord, ImageRecord
class TestPersistenceServiceIntegration(unittest.TestCase): class TestPersistenceServiceIntegration(unittest.TestCase):
@@ -31,18 +31,18 @@ class TestPersistenceServiceIntegration(unittest.TestCase):
@staticmethod @staticmethod
def _record(origin_id: str, *, price: int = 1000) -> CarRecord: def _record(origin_id: str, *, price: int = 1000) -> CarRecord:
return CarRecord( return CarRecord(
parser_id=f"iaai:{origin_id}", parser_id=f"dubizzle:{origin_id}",
brand="Toyota", brand="Toyota",
model="Camry", model="Camry",
year=2014, year=2014,
price=price, price=price,
origin_url=f"https://www.iaai.com/VehicleDetail/{origin_id}~US", origin_url=f"https://www.dubizzle.com/VehicleDetail/{origin_id}~US",
origin_id=origin_id, origin_id=origin_id,
slug=f"toyota-camry-{origin_id}", slug=f"toyota-camry-{origin_id}",
images=[ images=[
ImageRecord( ImageRecord(
fullres_image="https://vis.iaai.com/resizer?imageKeys=1&width=845&height=633", fullres_image="https://vis.dubizzle.com/resizer?imageKeys=1&width=845&height=633",
preview_image="https://vis.iaai.com/resizer?imageKeys=1&width=400&height=300", preview_image="https://vis.dubizzle.com/resizer?imageKeys=1&width=400&height=300",
order_index=0, order_index=0,
) )
], ],
@@ -79,8 +79,8 @@ class TestPersistenceServiceIntegration(unittest.TestCase):
second = self._record("888") second = self._record("888")
second.images = [ second.images = [
ImageRecord( ImageRecord(
fullres_image="https://vis.iaai.com/resizer?imageKeys=2&width=845&height=633", fullres_image="https://vis.dubizzle.com/resizer?imageKeys=2&width=845&height=633",
preview_image="https://vis.iaai.com/resizer?imageKeys=2&width=400&height=300", preview_image="https://vis.dubizzle.com/resizer?imageKeys=2&width=400&height=300",
order_index=0, order_index=0,
) )
] ]
@@ -108,11 +108,11 @@ class TestPersistenceServiceIntegration(unittest.TestCase):
def test_upsert_falls_back_to_origin_url_to_prevent_duplicates(self) -> None: def test_upsert_falls_back_to_origin_url_to_prevent_duplicates(self) -> None:
first = self._record("OLD-ID") first = self._record("OLD-ID")
first.origin_url = "https://www.iaai.com/VehicleDetail/45089484~US" first.origin_url = "https://www.dubizzle.com/VehicleDetail/45089484~US"
self.persistence.upsert_car(first) self.persistence.upsert_car(first)
second = self._record("NEW-ID") second = self._record("NEW-ID")
second.origin_url = "https://www.iaai.com/VehicleDetail/45089484~US" second.origin_url = "https://www.dubizzle.com/VehicleDetail/45089484~US"
result = self.persistence.upsert_car(second) result = self.persistence.upsert_car(second)
self.assertEqual(result["action"], "updated") self.assertEqual(result["action"], "updated")

View File

@@ -2,9 +2,9 @@ from __future__ import annotations
import unittest import unittest
from iaai_scraper.browser.pace import HumanPacer from dubizzle_scraper.browser.pace import HumanPacer
from iaai_scraper.core.config import Settings from dubizzle_scraper.core.config import Settings
from iaai_scraper.browser.listing import ListingCollector from dubizzle_scraper.browser.listing import ListingCollector
class _FakePage: class _FakePage:
@@ -58,8 +58,8 @@ class TestListingUnit(unittest.TestCase):
self.assertEqual( self.assertEqual(
links, links,
[ [
("https://www.iaai.com/VehicleDetail/45184893~US", "45184893"), ("https://www.dubizzle.com/VehicleDetail/45184893~US", "45184893"),
("https://www.iaai.com/VehicleDetail/45171480~US", "45171480"), ("https://www.dubizzle.com/VehicleDetail/45171480~US", "45171480"),
], ],
) )

View File

@@ -2,22 +2,109 @@ from __future__ import annotations
import unittest import unittest
from iaai_scraper.parsing.mapper import CarMapper from dubizzle_scraper.parsing.mapper import CarMapper
class TestCarMapper(unittest.TestCase): class TestCarMapper(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.mapper = CarMapper() self.mapper = CarMapper()
def test_extracts_make_model_from_algolia_details(self) -> None:
record = self.mapper.map_to_car_record(
vehicle_url="https://dubizzle.com/s/DOBI7J3",
vehicle_summary={
"id": 16810399,
"details_v2": {
"tertiary": [
{"slug": "make", "value": {"en": "Toyota", "ar": "تويوتا"}},
{"slug": "model", "value": {"en": "Land Cruiser", "ar": "لاند كروزر"}},
]
},
"category_v2": {
"names_en": ["Motors", "Used Cars", "Toyota", "Land Cruiser"],
"slug_paths": [
"motors",
"motors/used-cars",
"motors/used-cars/toyota",
"motors/used-cars/toyota/land-cruiser",
],
},
},
payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
)
self.assertEqual(record.brand, "Toyota")
self.assertEqual(record.model, "Land Cruiser")
def test_extracts_make_model_from_legacy_details(self) -> None:
record = self.mapper.map_to_car_record(
vehicle_url="https://dubizzle.com/s/DOBIlegacy",
vehicle_summary={
"objectID": "item:legacy:1",
"details": {
"Make": {"en": {"label": "Make", "value": "Honda"}},
"Model": {"en": {"label": "Model", "value": "Civic"}},
},
},
payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
)
self.assertEqual(record.brand, "Honda")
self.assertEqual(record.model, "Civic")
def test_extracts_extended_algolia_fields(self) -> None:
record = self.mapper.map_to_car_record(
vehicle_url="https://dubizzle.com/s/DOBGaGs",
vehicle_summary={
"id": 16345114,
"year": 2012,
"price": 59000.0,
"kilometers": 91800,
"seller_type": "DL",
"site": {"en": "Dubai"},
"category_v2": {
"names_en": ["Motors", "Used Cars", "Porsche", "Cayenne"],
"slug_paths": [
"motors",
"motors/used-cars",
"motors/used-cars/porsche",
"motors/used-cars/porsche/cayenne",
],
},
"details": {
"Body Type": {"en": {"value": "SUV"}},
"Transmission Type": {"en": {"value": "Automatic Transmission"}},
"Exterior Color": {"en": {"value": "Brown"}},
"Steering Side": {"en": {"value": "Left Hand"}},
"Engine Capacity (cc)": {"en": {"value": "4800 cc"}},
"Make": {"en": {"value": "Porsche"}},
"Model": {"en": {"value": "Cayenne"}},
},
"photo_mains": ["https://dbz-images.dubizzle.com/images/example.jpeg?impolicy=dpv"],
},
payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
)
self.assertEqual(record.brand, "Porsche")
self.assertEqual(record.model, "Cayenne")
self.assertEqual(record.price, 59000)
self.assertEqual(record.mileage, 91800)
self.assertEqual(record.color, "brown")
self.assertEqual(record.body_type, "SUV")
self.assertEqual(record.gearbox, "AT")
self.assertEqual(record.steering_wheel, "LEFT")
self.assertEqual(record.engine_volume, 4800)
self.assertEqual(record.country, "AE")
def test_deduplicates_images_by_image_key(self) -> None: def test_deduplicates_images_by_image_key(self) -> None:
urls = [ urls = [
"https://vis.iaai.com/resizer?imageKeys=1&width=200&height=150", "https://vis.dubizzle.com/resizer?imageKeys=1&width=200&height=150",
"https://vis.iaai.com/resizer?imageKeys=1&width=845&height=633", "https://vis.dubizzle.com/resizer?imageKeys=1&width=845&height=633",
"https://vis.iaai.com/resizer?imageKeys=2&width=400&height=300", "https://vis.dubizzle.com/resizer?imageKeys=2&width=400&height=300",
] ]
record = self.mapper.map_to_car_record( record = self.mapper.map_to_car_record(
vehicle_url="https://www.iaai.com/VehicleDetail/123~US", vehicle_url="https://www.dubizzle.com/VehicleDetail/123~US",
vehicle_summary={"make": "Honda", "model": "Civic", "image_urls": urls}, vehicle_summary={"make": "Honda", "model": "Civic", "image_urls": urls},
payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}}, payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
) )
@@ -28,7 +115,7 @@ class TestCarMapper(unittest.TestCase):
def test_no_damage_marker_is_not_damaged(self) -> None: def test_no_damage_marker_is_not_damaged(self) -> None:
record = self.mapper.map_to_car_record( record = self.mapper.map_to_car_record(
vehicle_url="https://www.iaai.com/VehicleDetail/123~US", vehicle_url="https://www.dubizzle.com/VehicleDetail/123~US",
vehicle_summary={"make": "Ford", "model": "Focus"}, vehicle_summary={"make": "Ford", "model": "Focus"},
payload_insights={ payload_insights={
"vehicle_core": {}, "vehicle_core": {},
@@ -43,7 +130,7 @@ class TestCarMapper(unittest.TestCase):
def test_unknown_empty_values_and_normalization(self) -> None: def test_unknown_empty_values_and_normalization(self) -> None:
record = self.mapper.map_to_car_record( record = self.mapper.map_to_car_record(
vehicle_url="https://www.iaai.com/VehicleDetail/999~US", vehicle_url="https://www.dubizzle.com/VehicleDetail/999~US",
vehicle_summary={"make": " ", "model": None, "drive": "???", "gearbox": "unknown"}, vehicle_summary={"make": " ", "model": None, "drive": "???", "gearbox": "unknown"},
payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}}, payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
) )
@@ -54,7 +141,7 @@ class TestCarMapper(unittest.TestCase):
# Нормализация регистра и пробелов. # Нормализация регистра и пробелов.
record2 = self.mapper.map_to_car_record( record2 = self.mapper.map_to_car_record(
vehicle_url="https://www.iaai.com/VehicleDetail/888~US", vehicle_url="https://www.dubizzle.com/VehicleDetail/888~US",
vehicle_summary={"make": "Honda", "model": "Civic", "drive": " Front Wheel Drive ", "gearbox": " AUTOMATIC "}, vehicle_summary={"make": "Honda", "model": "Civic", "drive": " Front Wheel Drive ", "gearbox": " AUTOMATIC "},
payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}}, payload_insights={"vehicle_core": {}, "pricing": {}, "damage": {}, "auction": {}, "images": {}},
) )
@@ -63,7 +150,7 @@ class TestCarMapper(unittest.TestCase):
def test_price_and_currency_parsing(self) -> None: def test_price_and_currency_parsing(self) -> None:
record = self.mapper.map_to_car_record( record = self.mapper.map_to_car_record(
vehicle_url="https://www.iaai.com/VehicleDetail/777~US", vehicle_url="https://www.dubizzle.com/VehicleDetail/777~US",
vehicle_summary={"make": "Toyota", "model": "Corolla"}, vehicle_summary={"make": "Toyota", "model": "Corolla"},
payload_insights={ payload_insights={
"vehicle_core": {}, "vehicle_core": {},
@@ -76,7 +163,7 @@ class TestCarMapper(unittest.TestCase):
self.assertEqual(record.price, 5200) self.assertEqual(record.price, 5200)
record2 = self.mapper.map_to_car_record( record2 = self.mapper.map_to_car_record(
vehicle_url="https://www.iaai.com/VehicleDetail/778~US", vehicle_url="https://www.dubizzle.com/VehicleDetail/778~US",
vehicle_summary={"make": "Toyota", "model": "Corolla"}, vehicle_summary={"make": "Toyota", "model": "Corolla"},
payload_insights={ payload_insights={
"vehicle_core": {}, "vehicle_core": {},

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import unittest import unittest
from iaai_scraper.parsing.parser import VehicleParser from dubizzle_scraper.parsing.parser import VehicleParser
class TestVehicleParserUnit(unittest.TestCase): class TestVehicleParserUnit(unittest.TestCase):
@@ -29,11 +29,11 @@ class TestVehicleParserUnit(unittest.TestCase):
self.assertEqual(parsed["model"], "CAMRY") self.assertEqual(parsed["model"], "CAMRY")
def test_extract_image_urls_filters_and_deduplicates(self) -> None: def test_extract_image_urls_filters_and_deduplicates(self) -> None:
vehicle_url = "https://www.iaai.com/VehicleDetail/45089484~US" vehicle_url = "https://www.dubizzle.com/VehicleDetail/45089484~US"
payloads = [{"imageUrls": [ payloads = [{"imageUrls": [
"https://vis.iaai.com/resizer?imageKeys=45089484~SID1&width=845&height=633", "https://vis.dubizzle.com/resizer?imageKeys=45089484~SID1&width=845&height=633",
"https://vis.iaai.com/resizer?imageKeys=45089484~SID1&width=845&height=633", "https://vis.dubizzle.com/resizer?imageKeys=45089484~SID1&width=845&height=633",
"https://vis.iaai.com/resizer?imageKeys=99999999~SID2&width=845&height=633", "https://vis.dubizzle.com/resizer?imageKeys=99999999~SID2&width=845&height=633",
]}] ]}]
urls = self.parser._extract_image_urls(payloads, "", vehicle_url) urls = self.parser._extract_image_urls(payloads, "", vehicle_url)
self.assertEqual(len(urls), 1) self.assertEqual(len(urls), 1)

View File

@@ -4,17 +4,17 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from iaai_scraper.scraper import IAAIScraper from dubizzle_scraper.scraper import DUBIZZLEScraper
from iaai_scraper.core.config import Settings from dubizzle_scraper.core.config import Settings
from iaai_scraper.worker import tasks from dubizzle_scraper.worker import tasks
class TestResilience(unittest.TestCase): class TestResilience(unittest.TestCase):
def _make_scraper(self) -> IAAIScraper: def _make_scraper(self) -> DUBIZZLEScraper:
s = Settings() s = Settings()
s.log_level = "CRITICAL" s.log_level = "CRITICAL"
s.database.url = "sqlite://" s.database.url = "sqlite://"
return IAAIScraper(s) return DUBIZZLEScraper(s)
def test_update_task_progress_updates_global_marker(self) -> None: def test_update_task_progress_updates_global_marker(self) -> None:
redis_client = MagicMock() redis_client = MagicMock()
@@ -60,11 +60,11 @@ class TestResilience(unittest.TestCase):
result = scraper._sync_listing_streaming( result = scraper._sync_listing_streaming(
make=None, make=None,
model=None, model=None,
lane="iaai_cars", lane="dubizzle_cars",
limit=None, limit=None,
effective_only_new=False, effective_only_new=False,
started_at=0.0, started_at=0.0,
listing_url="https://www.iaai.com/Vehiclelisting/Cars?Make=TEST", listing_url="https://www.dubizzle.com/Vehiclelisting/Cars?Make=TEST",
) )
self.assertEqual(result["total"], 0) self.assertEqual(result["total"], 0)

View File

@@ -5,29 +5,29 @@ from concurrent.futures import TimeoutError as FuturesTimeoutError
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from iaai_scraper.core.config import Settings from dubizzle_scraper.core.config import Settings
from iaai_scraper.core.exceptions import AntiBotDetectedError, SiteStructureChangedError from dubizzle_scraper.core.exceptions import AntiBotDetectedError, SiteStructureChangedError
from iaai_scraper.scraper import IAAIScraper from dubizzle_scraper.scraper import DUBIZZLEScraper
from iaai_scraper.storage.schemas import CarRecord from dubizzle_scraper.storage.schemas import CarRecord
def make_db_record(origin_id: str) -> dict[str, object]: def make_db_record(origin_id: str) -> dict[str, object]:
return CarRecord( return CarRecord(
parser_id=f"iaai:{origin_id}", parser_id=f"dubizzle:{origin_id}",
brand="Toyota", brand="Toyota",
model="Camry", model="Camry",
origin_url=f"https://www.iaai.com/VehicleDetail/{origin_id}~US", origin_url=f"https://www.dubizzle.com/VehicleDetail/{origin_id}~US",
origin_id=origin_id, origin_id=origin_id,
slug=f"toyota-camry-{origin_id}", slug=f"toyota-camry-{origin_id}",
).model_dump(mode="json") ).model_dump(mode="json")
class TestScraperSync(unittest.TestCase): class TestScraperSync(unittest.TestCase):
def _make_scraper(self) -> IAAIScraper: def _make_scraper(self) -> DUBIZZLEScraper:
s = Settings() s = Settings()
s.log_level = "CRITICAL" s.log_level = "CRITICAL"
s.database.url = "sqlite://" s.database.url = "sqlite://"
return IAAIScraper(s) return DUBIZZLEScraper(s)
def test_sync_vehicle_uses_db_record(self) -> None: def test_sync_vehicle_uses_db_record(self) -> None:
scraper = self._make_scraper() scraper = self._make_scraper()
@@ -37,7 +37,7 @@ class TestScraperSync(unittest.TestCase):
scraper.persistence.upsert_car = MagicMock(return_value={"action": "inserted", "images_upserted": 0}) 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")}) scraper.scrape_vehicle_detail = MagicMock(return_value={"db_record": make_db_record("111")})
result = scraper.sync_vehicle("https://www.iaai.com/VehicleDetail/111~US") result = scraper.sync_vehicle("https://www.dubizzle.com/VehicleDetail/111~US")
self.assertEqual(result["status"], "success") self.assertEqual(result["status"], "success")
self.assertIn("trace_id", result) self.assertIn("trace_id", result)
@@ -50,13 +50,13 @@ class TestScraperSync(unittest.TestCase):
scraper.persistence.start_sync_run = MagicMock(return_value=2) scraper.persistence.start_sync_run = MagicMock(return_value=2)
scraper.persistence.finish_sync_run = MagicMock() scraper.persistence.finish_sync_run = MagicMock()
scraper.persistence.get_existing_urls_and_ids = MagicMock(return_value=( scraper.persistence.get_existing_urls_and_ids = MagicMock(return_value=(
{"https://www.iaai.com/VehicleDetail/111~US"}, {"iaai:222"}, {"https://www.dubizzle.com/VehicleDetail/111~US"}, {"dubizzle:222"},
)) ))
scraper.collect_listing = MagicMock(return_value={ scraper.collect_listing = MagicMock(return_value={
"vehicle_urls": [ "vehicle_urls": [
"https://www.iaai.com/VehicleDetail/111~US", "https://www.dubizzle.com/VehicleDetail/111~US",
"https://www.iaai.com/VehicleDetail/222~US", "https://www.dubizzle.com/VehicleDetail/222~US",
"https://www.iaai.com/VehicleDetail/333~US", "https://www.dubizzle.com/VehicleDetail/333~US",
] ]
}) })
scraper.sync_batch = MagicMock(return_value={ scraper.sync_batch = MagicMock(return_value={
@@ -79,7 +79,7 @@ class TestScraperSync(unittest.TestCase):
}) })
result2 = scraper2.sync_listing( result2 = scraper2.sync_listing(
only_new=True, only_new=True,
listing_url="https://www.iaai.com/Vehiclelisting/Cars?Make=TOYOTA", listing_url="https://www.dubizzle.com/Vehiclelisting/Cars?Make=TOYOTA",
year_min=2020, year_max=2027, year_min=2020, year_max=2027,
) )
self.assertEqual(result2["cars_upserted"], 10) self.assertEqual(result2["cars_upserted"], 10)
@@ -155,35 +155,35 @@ class TestScraperSync(unittest.TestCase):
self.assertEqual(result["status"], "partial_success") self.assertEqual(result["status"], "partial_success")
def test_build_segment_listing_url(self) -> None: def test_build_segment_listing_url(self) -> None:
base = "https://www.iaai.com/Vehiclelisting/Cars" base = "https://www.dubizzle.com/Vehiclelisting/Cars"
self.assertEqual( self.assertEqual(
IAAIScraper._build_segment_listing_url(base, "TOYOTA"), DUBIZZLEScraper._build_segment_listing_url(base, "TOYOTA"),
"https://www.iaai.com/Vehiclelisting/Cars?Make=TOYOTA", "https://www.dubizzle.com/Vehiclelisting/Cars?Make=TOYOTA",
) )
self.assertEqual( self.assertEqual(
IAAIScraper._build_segment_listing_url(base, "LAND ROVER"), DUBIZZLEScraper._build_segment_listing_url(base, "LAND ROVER"),
"https://www.iaai.com/Vehiclelisting/Cars?Make=LAND%20ROVER", "https://www.dubizzle.com/Vehiclelisting/Cars?Make=LAND%20ROVER",
) )
self.assertEqual(IAAIScraper._build_segment_listing_url(base, None), base) self.assertEqual(DUBIZZLEScraper._build_segment_listing_url(base, None), base)
self.assertEqual(IAAIScraper._build_segment_listing_url(base, ""), base) self.assertEqual(DUBIZZLEScraper._build_segment_listing_url(base, ""), base)
def test_guard_and_protection_detection(self) -> None: def test_guard_and_protection_detection(self) -> None:
with self.assertRaises(AntiBotDetectedError): with self.assertRaises(AntiBotDetectedError):
IAAIScraper._raise_if_blocked_or_incomplete( DUBIZZLEScraper._raise_if_blocked_or_incomplete(
{"dom_hints": {"has_captcha_text": True, "has_antibot_text": False}, {"dom_hints": {"has_captcha_text": True, "has_antibot_text": False},
"access_notes": {}, "vehicle_summary": {}}, "access_notes": {}, "vehicle_summary": {}},
"https://www.iaai.com/VehicleDetail/999~US", "https://www.dubizzle.com/VehicleDetail/999~US",
) )
with self.assertRaises(SiteStructureChangedError): with self.assertRaises(SiteStructureChangedError):
IAAIScraper._raise_if_blocked_or_incomplete( DUBIZZLEScraper._raise_if_blocked_or_incomplete(
{"dom_hints": {"has_captcha_text": False, "has_antibot_text": False}, {"dom_hints": {"has_captcha_text": False, "has_antibot_text": False},
"access_notes": {"possible_captcha": False, "possible_antibot": False}, "access_notes": {"possible_captcha": False, "possible_antibot": False},
"vehicle_summary": {}}, "vehicle_summary": {}},
"https://www.iaai.com/VehicleDetail/999~US", "https://www.dubizzle.com/VehicleDetail/999~US",
) )
self.assertTrue(IAAIScraper._is_protection_or_network_error(RuntimeError("NS_ERROR_NET_INTERRUPT"))) self.assertTrue(DUBIZZLEScraper._is_protection_or_network_error(RuntimeError("NS_ERROR_NET_INTERRUPT")))
self.assertTrue(IAAIScraper._is_protection_or_network_error(RuntimeError("captcha challenge"))) self.assertTrue(DUBIZZLEScraper._is_protection_or_network_error(RuntimeError("captcha challenge")))
self.assertFalse(IAAIScraper._is_protection_or_network_error(RuntimeError("plain validation error"))) self.assertFalse(DUBIZZLEScraper._is_protection_or_network_error(RuntimeError("plain validation error")))
def test_close_resets_browser_state(self) -> None: def test_close_resets_browser_state(self) -> None:
scraper = self._make_scraper() scraper = self._make_scraper()
@@ -201,7 +201,7 @@ class TestScraperSync(unittest.TestCase):
scraper = self._make_scraper() scraper = self._make_scraper()
page = MagicMock() page = MagicMock()
page_result = SimpleNamespace( page_result = SimpleNamespace(
vehicle_links=[SimpleNamespace(href="https://www.iaai.com/VehicleDetail/123~US")], vehicle_links=[SimpleNamespace(href="https://www.dubizzle.com/VehicleDetail/123~US")],
) )
scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result) scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result)
scraper.listing_collector.open_cars_listing = MagicMock() scraper.listing_collector.open_cars_listing = MagicMock()
@@ -228,7 +228,7 @@ class TestScraperSync(unittest.TestCase):
page = MagicMock() page = MagicMock()
page_result = SimpleNamespace( page_result = SimpleNamespace(
page_number=1, page_number=1,
vehicle_links=[SimpleNamespace(href="https://www.iaai.com/VehicleDetail/999~US", lot_number="999")], vehicle_links=[SimpleNamespace(href="https://www.dubizzle.com/VehicleDetail/999~US", lot_number="999")],
next_page_detected=False, next_page_detected=False,
) )
@@ -245,7 +245,7 @@ class TestScraperSync(unittest.TestCase):
"year_max": None, "year_max": None,
}) })
scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result) scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result)
scraper._extract_page_urls = MagicMock(return_value=["https://www.iaai.com/VehicleDetail/999~US"]) scraper._extract_page_urls = MagicMock(return_value=["https://www.dubizzle.com/VehicleDetail/999~US"])
scraper.sync_batch = MagicMock(return_value={ scraper.sync_batch = MagicMock(return_value={
"cars_upserted": 1, "cars_upserted": 1,
"cars_failed": 0, "cars_failed": 0,
@@ -256,11 +256,11 @@ class TestScraperSync(unittest.TestCase):
result = scraper._sync_listing_streaming( result = scraper._sync_listing_streaming(
make=None, make=None,
model=None, model=None,
lane="iaai_cars", lane="dubizzle_cars",
limit=None, limit=None,
effective_only_new=False, effective_only_new=False,
started_at=0.0, started_at=0.0,
listing_url="https://www.iaai.com/Vehiclelisting/Cars?Make=EAGLE", listing_url="https://www.dubizzle.com/Vehiclelisting/Cars?Make=EAGLE",
) )
scraper.listing_collector.open_cars_listing.assert_called_once() scraper.listing_collector.open_cars_listing.assert_called_once()
@@ -278,9 +278,9 @@ class TestScraperSync(unittest.TestCase):
}) })
urls = [ urls = [
"https://www.iaai.com/VehicleDetail/111~US", "https://www.dubizzle.com/VehicleDetail/111~US",
"https://www.iaai.com/VehicleDetail/222~US", "https://www.dubizzle.com/VehicleDetail/222~US",
"https://www.iaai.com/VehicleDetail/333~US", "https://www.dubizzle.com/VehicleDetail/333~US",
] ]
first_record = CarRecord.model_validate(make_db_record("111")) first_record = CarRecord.model_validate(make_db_record("111"))
@@ -298,8 +298,8 @@ class TestScraperSync(unittest.TestCase):
yield 0, first_record yield 0, first_record
raise FuturesTimeoutError() raise FuturesTimeoutError()
with patch("iaai_scraper.core.runtime_config.RuntimeFiltersConfig.is_empty", return_value=True), \ with patch("dubizzle_scraper.core.runtime_config.RuntimeFiltersConfig.is_empty", return_value=True), \
patch("iaai_scraper.scraper.ThreadPoolExecutor", _FakeExecutor), \ patch("dubizzle_scraper.scraper.ThreadPoolExecutor", _FakeExecutor), \
patch.object(scraper, "_browser_fallback_parallel", return_value={ patch.object(scraper, "_browser_fallback_parallel", return_value={
"records": [], "records": [],
"failures": [], "failures": [],

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from iaai_scraper.worker import self_heal from dubizzle_scraper.worker import self_heal
class TestSelfHeal(unittest.TestCase): class TestSelfHeal(unittest.TestCase):
@@ -20,14 +20,14 @@ class TestSelfHeal(unittest.TestCase):
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.side_effect = lambda key: { redis_client.get.side_effect = lambda key: {
self_heal.GLOBAL_PROGRESS_TS_KEY: None, self_heal.GLOBAL_PROGRESS_TS_KEY: None,
"iaai:state:task_progress:a": '{"ts": 100}', "dubizzle:state:task_progress:a": '{"ts": 100}',
"iaai:state:task_progress:b": '{"ts": 250}', "dubizzle:state:task_progress:b": '{"ts": 250}',
"iaai:state:task_progress:c": '{"ts": 150}', "dubizzle:state:task_progress:c": '{"ts": 150}',
}.get(key) }.get(key)
redis_client.scan_iter.return_value = [ redis_client.scan_iter.return_value = [
"iaai:state:task_progress:a", "dubizzle:state:task_progress:a",
"iaai:state:task_progress:b", "dubizzle:state:task_progress:b",
"iaai:state:task_progress:c", "dubizzle:state:task_progress:c",
] ]
ts = self_heal._read_last_progress_ts(redis_client) ts = self_heal._read_last_progress_ts(redis_client)
@@ -38,20 +38,20 @@ class TestSelfHeal(unittest.TestCase):
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.side_effect = lambda key: { redis_client.get.side_effect = lambda key: {
self_heal.GLOBAL_PROGRESS_TS_KEY: None, self_heal.GLOBAL_PROGRESS_TS_KEY: None,
"iaai:state:task_progress:a": "{bad-json}", "dubizzle:state:task_progress:a": "{bad-json}",
"iaai:state:task_progress:b": '{"foo": "bar"}', "dubizzle:state:task_progress:b": '{"foo": "bar"}',
}.get(key) }.get(key)
redis_client.scan_iter.return_value = [ redis_client.scan_iter.return_value = [
"iaai:state:task_progress:a", "dubizzle:state:task_progress:a",
"iaai:state:task_progress:b", "dubizzle:state:task_progress:b",
] ]
ts = self_heal._read_last_progress_ts(redis_client) ts = self_heal._read_last_progress_ts(redis_client)
self.assertIsNone(ts) self.assertIsNone(ts)
@patch("iaai_scraper.worker.self_heal.time.sleep", return_value=None) @patch("dubizzle_scraper.worker.self_heal.time.sleep", return_value=None)
@patch("iaai_scraper.worker.self_heal.os.kill") @patch("dubizzle_scraper.worker.self_heal.os.kill")
@patch("builtins.open") @patch("builtins.open")
def test_kill_worker_process_sends_term_and_kill(self, open_mock, kill_mock, _sleep_mock) -> None: def test_kill_worker_process_sends_term_and_kill(self, open_mock, kill_mock, _sleep_mock) -> None:
open_mock.return_value.__enter__.return_value.read.return_value = "123" open_mock.return_value.__enter__.return_value.read.return_value = "123"
@@ -64,8 +64,8 @@ class TestSelfHeal(unittest.TestCase):
self.assertEqual(kill_mock.call_args_list[1].args, (123, 0)) self.assertEqual(kill_mock.call_args_list[1].args, (123, 0))
self.assertEqual(kill_mock.call_args_list[2].args[0], 123) self.assertEqual(kill_mock.call_args_list[2].args[0], 123)
@patch("iaai_scraper.worker.self_heal.time.sleep", return_value=None) @patch("dubizzle_scraper.worker.self_heal.time.sleep", return_value=None)
@patch("iaai_scraper.worker.self_heal.os.kill") @patch("dubizzle_scraper.worker.self_heal.os.kill")
@patch("builtins.open") @patch("builtins.open")
def test_kill_worker_process_skips_sigkill_when_already_exited(self, open_mock, kill_mock, _sleep_mock) -> None: def test_kill_worker_process_skips_sigkill_when_already_exited(self, open_mock, kill_mock, _sleep_mock) -> None:
open_mock.return_value.__enter__.return_value.read.return_value = "123" open_mock.return_value.__enter__.return_value.read.return_value = "123"

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
import unittest import unittest
from iaai_scraper.core.utils import deep_find_key from dubizzle_scraper.core.utils import deep_find_key
from iaai_scraper.core.config import parse_listing_segments, IAAI_DEFAULT_MAKES, _LARGE_MAKES, _YEAR_SPLITS from dubizzle_scraper.core.config import parse_listing_segments, _AUTO_YEAR_SPLITS
class TestDeepFindKey(unittest.TestCase): class TestDeepFindKey(unittest.TestCase):
@@ -28,32 +28,19 @@ class TestParseListingSegments(unittest.TestCase):
self.assertEqual(parse_listing_segments("invalid"), []) self.assertEqual(parse_listing_segments("invalid"), [])
def test_auto_segments_complete_coverage(self) -> None: def test_auto_segments_complete_coverage(self) -> None:
"""auto: все бренды покрыты, крупные разбиты по годам, годы без дыр.""" """auto: все годовые диапазоны покрыты, без дыр и без make-фильтра."""
segs = parse_listing_segments("auto") segs = parse_listing_segments("auto")
# Точное число сегментов. self.assertEqual(len(segs), len(_AUTO_YEAR_SPLITS))
expected = len(_LARGE_MAKES) * len(_YEAR_SPLITS) + (len(IAAI_DEFAULT_MAKES) - len(_LARGE_MAKES)) self.assertEqual(
self.assertEqual(len(segs), expected) [(s["year_min"], s["year_max"]) for s in segs],
list(_AUTO_YEAR_SPLITS),
# Все бренды из списка присутствуют. )
makes_in_segments = {s["make"] for s in segs} self.assertTrue(all(s["make"] is None for s in segs))
for make in IAAI_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. # Годовые диапазоны покрывают 1950-2027.
years = set() years = set()
for yr_min, yr_max in _YEAR_SPLITS: for yr_min, yr_max in _AUTO_YEAR_SPLITS:
years.update(range(yr_min, yr_max + 1)) years.update(range(yr_min, yr_max + 1))
for year in range(1950, 2027): for year in range(1950, 2027):
self.assertIn(year, years) self.assertIn(year, years)

View File

@@ -5,8 +5,8 @@ from dataclasses import replace
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from iaai_scraper.worker import tasks from dubizzle_scraper.worker import tasks
from iaai_scraper.core.config import settings as base_settings from dubizzle_scraper.core.config import settings as base_settings
class TestWorkerTaskLockHelpers(unittest.TestCase): class TestWorkerTaskLockHelpers(unittest.TestCase):
@@ -187,13 +187,29 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
] ]
with patch.object(tasks, "_get_persistence") as get_persistence, \ with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \ 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=False,
mode="listing",
),
celery=replace(
base_settings.celery,
parallel_segments=False,
),
),
), \
patch.object(tasks, "_acquire_lock", return_value=True), \ patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=False), \ patch.object(tasks, "_is_full_scan_done", return_value=False), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \ patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \ patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \ patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
patch.object(tasks.sync_listing_task, "update_state"), \ patch.object(tasks.sync_listing_task, "update_state"), \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=segments): patch("dubizzle_scraper.worker.tasks.parse_listing_segments", return_value=segments):
get_persistence.return_value = MagicMock() get_persistence.return_value = MagicMock()
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.side_effect = lambda key: ( redis_client.get.side_effect = lambda key: (
@@ -211,7 +227,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
scraper_ctx.__exit__.return_value = None scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx): with patch.object(tasks, "DUBIZZLEScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-resume-seg") tasks.sync_listing_task.push_request(id="task-resume-seg")
try: try:
result = tasks.sync_listing_task.run() result = tasks.sync_listing_task.run()
@@ -228,6 +244,22 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
segments = [{"make": "ACURA"}, {"make": "AUDI"}] segments = [{"make": "ACURA"}, {"make": "AUDI"}]
with patch.object(tasks, "_get_persistence") as get_persistence, \ with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \ 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=False,
mode="listing",
),
celery=replace(
base_settings.celery,
parallel_segments=False,
),
),
), \
patch.object(tasks, "_acquire_lock", return_value=True), \ patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", 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, "_start_lock_heartbeat") as start_heartbeat, \
@@ -235,7 +267,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
patch.object(tasks, "_clear_sync_checkpoint") as clear_checkpoint, \ patch.object(tasks, "_clear_sync_checkpoint") as clear_checkpoint, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \ patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
patch.object(tasks.sync_listing_task, "update_state"), \ patch.object(tasks.sync_listing_task, "update_state"), \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=segments): patch("dubizzle_scraper.worker.tasks.parse_listing_segments", return_value=segments):
get_persistence.return_value = MagicMock() get_persistence.return_value = MagicMock()
redis_client = MagicMock() redis_client = MagicMock()
# Оставшийся чекпоинт не должен использоваться. # Оставшийся чекпоинт не должен использоваться.
@@ -254,10 +286,10 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
scraper_ctx.__exit__.return_value = None scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx): with patch.object(tasks, "DUBIZZLEScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-792") tasks.sync_listing_task.push_request(id="task-792")
try: try:
result = tasks.sync_listing_task.run() result = tasks.sync_listing_task.run(limit=1)
finally: finally:
tasks.sync_listing_task.pop_request() tasks.sync_listing_task.pop_request()
@@ -271,13 +303,29 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
segments = [{"make": "ACURA"}, {"make": "AUDI"}] segments = [{"make": "ACURA"}, {"make": "AUDI"}]
with patch.object(tasks, "_get_persistence") as get_persistence, \ with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \ 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=False,
mode="listing",
),
celery=replace(
base_settings.celery,
parallel_segments=False,
),
),
), \
patch.object(tasks, "_acquire_lock", return_value=True), \ patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=False), \ patch.object(tasks, "_is_full_scan_done", return_value=False), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \ patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \ patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \ patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
patch.object(tasks.sync_listing_task, "update_state"), \ patch.object(tasks.sync_listing_task, "update_state"), \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=segments): patch("dubizzle_scraper.worker.tasks.parse_listing_segments", return_value=segments):
get_persistence.return_value = MagicMock() get_persistence.return_value = MagicMock()
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.side_effect = lambda key: ( redis_client.get.side_effect = lambda key: (
@@ -295,7 +343,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
scraper_ctx.__exit__.return_value = None scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx): with patch.object(tasks, "DUBIZZLEScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-beyond") tasks.sync_listing_task.push_request(id="task-beyond")
try: try:
result = tasks.sync_listing_task.run() result = tasks.sync_listing_task.run()
@@ -507,7 +555,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
patch.object(tasks, "_set_full_scan_done") as set_full_scan_done, \ patch.object(tasks, "_set_full_scan_done") as set_full_scan_done, \
patch.object(tasks.sync_listing_task, "update_state"), \ patch.object(tasks.sync_listing_task, "update_state"), \
patch.object(tasks, "_run_browser_job") as run_job, \ patch.object(tasks, "_run_browser_job") as run_job, \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=[]): patch("dubizzle_scraper.worker.tasks.parse_listing_segments", return_value=[]):
get_persistence.return_value = MagicMock() get_persistence.return_value = MagicMock()
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.return_value = None redis_client.get.return_value = None
@@ -554,7 +602,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
patch.object(tasks, "_release_lock_if_owner") as release_lock, \ patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \ patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
patch.object(tasks.sync_listing_task, "update_state"), \ patch.object(tasks.sync_listing_task, "update_state"), \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=segments): patch("dubizzle_scraper.worker.tasks.parse_listing_segments", return_value=segments):
get_persistence.return_value = MagicMock() get_persistence.return_value = MagicMock()
redis_client = MagicMock() redis_client = MagicMock()
redis_client.get.side_effect = lambda key: ( redis_client.get.side_effect = lambda key: (
@@ -579,7 +627,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
scraper_ctx.__enter__.return_value.sync_listing = MagicMock() scraper_ctx.__enter__.return_value.sync_listing = MagicMock()
scraper_ctx.__exit__.return_value = None scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx): with patch.object(tasks, "DUBIZZLEScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-always-full-resume") tasks.sync_listing_task.push_request(id="task-always-full-resume")
try: try:
result = tasks.sync_listing_task.run() result = tasks.sync_listing_task.run()
@@ -591,6 +639,85 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
self.assertEqual(sync_segmented_mock.call_args.kwargs["start_page"], 1) self.assertEqual(sync_segmented_mock.call_args.kwargs["start_page"], 1)
release_lock.assert_called_once() release_lock.assert_called_once()
def test_sync_listing_task_always_full_scan_resets_segment_progress_and_dispatches_again(self) -> None:
segments = [{"make": "TOYOTA"}, {"make": "FORD"}]
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),
celery=replace(base_settings.celery, parallel_segments=True),
),
), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_is_segmented_scan_in_progress", 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, "_reset_segments_progress") as reset_progress, \
patch.object(tasks, "_set_segmented_scan_active") as set_active, \
patch.object(tasks.sync_listing_task, "update_state"):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.smembers.return_value = {"0", "1"}
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, \
patch("dubizzle_scraper.worker.tasks.parse_listing_segments", return_value=segments):
tasks.sync_listing_task.push_request(id="task-always-hourly")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
self.assertEqual(result["segments_dispatched"], 2)
reset_progress.assert_called_once_with(redis_client, len(segments))
set_active.assert_called_once_with(redis_client, total=len(segments))
self.assertEqual(task_app.send_task.call_count, 2)
release_lock.assert_called_once()
def test_sync_listing_task_skips_duplicate_parallel_dispatch_while_segments_active(self) -> None:
segments = [{"make": "TOYOTA"}, {"make": "FORD"}]
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),
celery=replace(base_settings.celery, parallel_segments=True),
),
), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_is_segmented_scan_in_progress", 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"):
get_persistence.return_value = MagicMock()
get_redis.return_value = MagicMock()
start_heartbeat.return_value = (MagicMock(), MagicMock())
with patch.object(tasks.sync_listing_task, "app", new=MagicMock()) as task_app, \
patch("dubizzle_scraper.worker.tasks.parse_listing_segments", return_value=segments):
tasks.sync_listing_task.push_request(id="task-always-skip-duplicate")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "skipped")
self.assertEqual(result["reason"], "segmented_scan_in_progress")
task_app.send_task.assert_not_called()
release_lock.assert_called_once()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

2
uv.lock generated
View File

@@ -342,7 +342,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "iaai-scraper" name = "dubizzle-scraper"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [