Files
mobile.de/iaai_scraper/worker/tasks.py
2026-04-28 22:14:13 +03:00

4393 lines
185 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Задачи Celery для синхронизации автомобилей и листинга IAAI.
import json
import logging
import os
import signal
import hashlib
from threading import Event, Thread
import time
import uuid
from urllib.parse import parse_qsl, urlsplit
from billiard.exceptions import SoftTimeLimitExceeded
from celery import shared_task
from redis import Redis
import requests
from ..core.config import Settings, build_fast_listing_segments_for_makes, build_listing_segments_for_makes, parse_listing_segments
from ..core.runtime_config import RuntimeConfig
from ..mobile_de import MobileDeClient, MobileDeScraper
from ..scraper import IAAIScraper
from ..storage.db import PersistenceService
from ..discovery import SitemapDiscoveryError, discover_vehicle_urls_from_sitemap_with_stats
logger = logging.getLogger("iaai_scraper.worker.tasks")
IAAI_SYNC_QUEUE = "iaai_sync"
MOBILEDE_SYNC_QUEUE = "mobilede_sync"
MOBILEDE_SEARCH_CURSOR_KEY = "mobilede:state:search_next_page"
MOBILEDE_SEGMENT_CURSOR_KEY_FMT = "mobilede:state:search_next_page:{segment_key}"
MOBILEDE_RUNTIME_SEGMENT_INDEX_KEY = "mobilede:state:runtime_segment_index"
MOBILEDE_RUNTIME_SEGMENTS_TASK = "mobilede.sync_runtime_segments"
MOBILEDE_SYNC_TASK_NAME = "mobilede.sync_search"
MOBILEDE_SEGMENT_LOCK_KEY_FMT = "mobilede:locks:segment:{segment_key}"
MOBILEDE_SEGMENT_FOLLOWUP_PENDING_KEY_FMT = "mobilede:state:followup_pending:{segment_key}"
MOBILEDE_PROGRESS_PAGE_COUNTER_KEY_FMT = "mobilede:state:progress_pages:{segment_key}"
MOBILEDE_CONTINUOUS_SYNC_ENABLED = os.getenv("MOBILEDE_CONTINUOUS_SYNC_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS = max(0, int(float(os.getenv("MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS", "15"))))
MOBILEDE_PROGRESS_LOG_EVERY_PAGES = max(1, int(os.getenv("MOBILEDE_PROGRESS_LOG_EVERY_PAGES", "10")))
MOBILEDE_SKIP_EMPTY_WINDOW = os.getenv("MOBILEDE_SKIP_EMPTY_WINDOW", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_ROTATE_RUNTIME_SEGMENTS = os.getenv("MOBILEDE_ROTATE_RUNTIME_SEGMENTS", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_RUNTIME_INITIAL_TASKS = max(1, int(os.getenv("MOBILEDE_RUNTIME_INITIAL_TASKS", "2")))
MOBILEDE_SEGMENT_PAGE_WINDOW = max(1, int(os.getenv("MOBILEDE_SEGMENT_PAGE_WINDOW", "10")))
MOBILEDE_RESULTS_PER_PAGE = max(1, int(os.getenv("MOBILEDE_RESULTS_PER_PAGE", "20")))
MOBILEDE_MAX_PAGE_NUMBER = max(1, int(os.getenv("MOBILEDE_MAX_PAGE_NUMBER", "50")))
MOBILEDE_SEGMENT_TARGET_RESULTS = max(
MOBILEDE_RESULTS_PER_PAGE,
int(os.getenv("MOBILEDE_SEGMENT_TARGET_RESULTS", str(MOBILEDE_RESULTS_PER_PAGE * MOBILEDE_MAX_PAGE_NUMBER))),
)
MOBILEDE_DYNAMIC_SEGMENT_PROBES = os.getenv("MOBILEDE_DYNAMIC_SEGMENT_PROBES", "false").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = os.getenv("MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE", "false").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_SKIP_EMPTY_DYNAMIC_SEGMENTS = os.getenv("MOBILEDE_SKIP_EMPTY_DYNAMIC_SEGMENTS", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_HOT_BASE_SPLIT_ENABLED = os.getenv("MOBILEDE_HOT_BASE_SPLIT_ENABLED", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_HOT_BASE_PRICE_MAX = max(5000, int(os.getenv("MOBILEDE_HOT_BASE_PRICE_MAX", "30000")))
MOBILEDE_HOT_RECENT_YEAR_MIN = max(2000, int(os.getenv("MOBILEDE_HOT_RECENT_YEAR_MIN", "2018")))
MOBILEDE_HOT_MILEAGE_SPLIT_ENABLED = os.getenv("MOBILEDE_HOT_MILEAGE_SPLIT_ENABLED", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_HOT_MILEAGE_PRICE_MIN = max(1, int(os.getenv("MOBILEDE_HOT_MILEAGE_PRICE_MIN", "15001")))
MOBILEDE_HOT_MILEAGE_PRICE_MAX = max(MOBILEDE_HOT_MILEAGE_PRICE_MIN, int(os.getenv("MOBILEDE_HOT_MILEAGE_PRICE_MAX", "30000")))
MOBILEDE_HOT_OLD_CHEAP_PRICE_MAX = max(1, int(os.getenv("MOBILEDE_HOT_OLD_CHEAP_PRICE_MAX", "5000")))
MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED = os.getenv("MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP = os.getenv("MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_INCREMENTAL_PAGE_WINDOW = max(1, int(os.getenv("MOBILEDE_INCREMENTAL_PAGE_WINDOW", "1")))
MOBILEDE_ONLY_NEW_NEWEST_FIRST = os.getenv("MOBILEDE_ONLY_NEW_NEWEST_FIRST", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_INCREMENTAL_STRICT_FIRST_PASS = os.getenv("MOBILEDE_INCREMENTAL_STRICT_FIRST_PASS", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_ONLY_NEW_ZERO_INSERT_STREAK = max(1, int(os.getenv("MOBILEDE_ONLY_NEW_ZERO_INSERT_STREAK", "1")))
MOBILEDE_ONLY_NEW_COOLDOWN_SECONDS = max(60, int(os.getenv("MOBILEDE_ONLY_NEW_COOLDOWN_SECONDS", "3600")))
MOBILEDE_ONLY_NEW_HOT_ONLY = os.getenv("MOBILEDE_ONLY_NEW_HOT_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_ONLY_NEW_HOT_TTL_SECONDS = max(300, int(os.getenv("MOBILEDE_ONLY_NEW_HOT_TTL_SECONDS", "10800")))
MOBILEDE_ONLY_NEW_MIN_INSERT_RATIO = min(1.0, max(0.0, float(os.getenv("MOBILEDE_ONLY_NEW_MIN_INSERT_RATIO", "0.95"))))
MOBILEDE_BOOTSTRAP_DONE_KEY = "mobilede:state:bootstrap_full_scan_done"
MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY = "mobilede:state:bootstrap_segments_total"
MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY = "mobilede:state:bootstrap_segments_done"
MOBILEDE_BOOTSTRAP_LISTINGS_TOTAL_KEY = "mobilede:state:bootstrap_listings_total"
MOBILEDE_BOOTSTRAP_UNIQUE_TOTAL_KEY = "mobilede:state:bootstrap_unique_total"
MOBILEDE_BOOTSTRAP_INSERTED_TOTAL_KEY = "mobilede:state:bootstrap_inserted_total"
MOBILEDE_BOOTSTRAP_UPDATED_TOTAL_KEY = "mobilede:state:bootstrap_updated_total"
MOBILEDE_BOOTSTRAP_IMAGES_TOTAL_KEY = "mobilede:state:bootstrap_images_total"
MOBILEDE_BOOTSTRAP_DISPATCHED_SEGMENTS_KEY = "mobilede:state:bootstrap_dispatched_segments"
MOBILEDE_BOOTSTRAP_INCREMENTAL_TRANSITION_KEY = "mobilede:state:bootstrap_incremental_transition"
MOBILEDE_RUNTIME_SEGMENTS_CACHE_KEY = "mobilede:state:runtime_segments_cache"
MOBILEDE_RUNTIME_SEGMENTS_BUILDING_KEY = "mobilede:state:runtime_segments_building"
MOBILEDE_RUNTIME_SEGMENTS_PENDING_KEY = "mobilede:state:runtime_segments_pending"
MOBILEDE_RUNTIME_SEGMENTS_CACHE_LOCK_KEY = "mobilede:locks:runtime_segments_cache"
MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY = "mobilede:state:overflow_expanded_parents"
MOBILEDE_OVERFLOW_SPLIT_ENABLED = os.getenv("MOBILEDE_OVERFLOW_SPLIT_ENABLED", "true").strip().lower() in {"1", "true", "yes", "on"}
MOBILEDE_OVERFLOW_SPLIT_THRESHOLD_RATIO = min(
1.0,
max(0.5, float(os.getenv("MOBILEDE_OVERFLOW_SPLIT_THRESHOLD_RATIO", "0.98"))),
)
MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS = max(
1,
min(5, int(os.getenv("MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS", "3"))),
)
MOBILEDE_OVERFLOW_MAX_SPLIT_DEPTH = max(
1,
min(6, int(os.getenv("MOBILEDE_OVERFLOW_MAX_SPLIT_DEPTH", "3"))),
)
MOBILEDE_INCREMENTAL_CYCLE_KEY = "mobilede:state:incremental_cycle"
MOBILEDE_INCREMENTAL_CYCLE_SEEN_COUNT_KEY = "mobilede:state:incremental_cycle_seen_count"
MOBILEDE_INCREMENTAL_CYCLE_SEEN_SET_KEY_FMT = "mobilede:state:incremental_cycle_seen:{cycle_id}"
# Минимальная пауза между батчами (секунды) — не давит 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"
SYNC_FULL_SCAN_DONE_KEY = "iaai:state:sync_full_scan_done"
SYNC_LISTING_CHECKPOINT_KEY = "iaai:state:sync_listing_checkpoint"
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_LIMIT = 3
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_LIMIT = max(
1,
int(os.getenv("SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT", "6")),
)
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS = max(
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_LIMIT = 3
HOURLY_FAILURE_STREAK_TTL_SECONDS = 6 * 60 * 60 # сброс через 6 часов
SYNC_LISTING_FOLLOWUP_PENDING_KEY = "iaai:state:sync_listing_followup_pending"
SYNC_LISTING_TASK_NAME = "iaai.sync_cars_feed"
SYNC_SEGMENT_LOCK_KEY_FMT = "iaai:locks:sync_segment:{idx}"
SYNC_SEGMENTS_PROGRESS_KEY = "iaai:state:sync_segments_progress"
SYNC_SEGMENTS_TOTAL_KEY = "iaai:state:sync_segments_total"
SYNC_SEGMENTS_PROGRESS_TTL_SECONDS = 24 * 60 * 60
TASK_PROGRESS_KEY_FMT = "iaai:state:task_progress:{task_id}"
GLOBAL_PROGRESS_TS_KEY = "iaai:state:last_progress_ts"
GLOBAL_DB_PROGRESS_TS_KEY = "iaai:state:last_db_progress_ts"
SITEMAP_HOURLY_LAST_COUNT_KEY = "iaai:state:sitemap_hourly_last_count"
SITEMAP_HOURLY_REFRESH_OFFSET_KEY = "iaai:state:sitemap_hourly_refresh_offset"
STALL_WATCHDOG_NAVIGATION_STAGES = {
"listing_next_page_started",
"listing_resume_progress",
}
STALL_WATCHDOG_LONG_RUNNING_STAGES = {
"fast_listing_collected",
"fast_detail_progress",
}
STALL_WATCHDOG_NAVIGATION_GRACE_SECONDS = max(
300,
int(os.getenv("STALL_WATCHDOG_NAVIGATION_GRACE_SECONDS", "900")),
)
STALL_WATCHDOG_DETAIL_GRACE_SECONDS = max(
600,
int(os.getenv("STALL_WATCHDOG_DETAIL_GRACE_SECONDS", "1200")),
)
DB_IDLE_RESTART_SECONDS = max(60, int(os.getenv("IAAI_DB_IDLE_RESTART_SECONDS", "3600")))
TERMINAL_PROGRESS_STAGES = {
"segment_done",
"segment_failed",
"segment_task_completed",
"segment_task_failed",
"segment_task_soft_timeout",
"sync_done",
"failed",
}
def _retry_with_backoff(func, *, attempts: int = 5, base_delay_s: float = 1.0):
last_exc: Exception | None = None
for attempt in range(1, attempts + 1):
try:
return func()
except Exception as exc:
last_exc = exc
if attempt >= attempts:
break
delay = base_delay_s * (2 ** (attempt - 1))
logger.warning(
"Operation failed (attempt %d/%d): %s. Retrying in %.1fs",
attempt,
attempts,
exc,
delay,
)
time.sleep(delay)
if last_exc is not None:
raise last_exc
def _run_browser_job(func, *args, **kwargs):
# С pool=solo Celery worker работает в одном процессе/потоке.
# Playwright sync API использует greenlets, которые привязаны к потоку.
# Запуск в отдельном потоке вызывает greenlet.error: cannot switch to a different thread.
# Поэтому запускаем напрямую в текущем потоке.
return func(*args, **kwargs)
def _sync_listing_lock_ttl_seconds() -> int:
settings = Settings()
soft = settings.celery.task_soft_time_limit
hard = settings.celery.task_time_limit
# Используем clamped hard limit (soft + 120), а не сырой task_time_limit,
# чтобы lock не висел 11 дней при CELERY_TASK_TIME_LIMIT=999999.
effective_hard = min(hard, soft + 120) if soft else hard
return max(effective_hard + 120, 300)
def _sync_segment_lock_ttl_seconds() -> int:
return 300
def _task_progress_key(task_id: str) -> str:
return TASK_PROGRESS_KEY_FMT.format(task_id=task_id)
def _mobilede_segment_lock_key(segment_key: str) -> str:
return MOBILEDE_SEGMENT_LOCK_KEY_FMT.format(segment_key=segment_key)
def _mobilede_followup_pending_key(segment_key: str) -> str:
return MOBILEDE_SEGMENT_FOLLOWUP_PENDING_KEY_FMT.format(segment_key=segment_key)
def _update_task_progress(
redis_client: Redis,
*,
task_id: str,
stage: str,
ttl_seconds: int,
**payload,
) -> None:
try:
now_ts = int(time.time())
existing_task_started_ts: int | None = None
try:
existing_raw = redis_client.get(_task_progress_key(task_id))
if existing_raw:
existing_payload = json.loads(existing_raw)
existing_task_started_ts = _safe_int(existing_payload.get("task_started_ts"))
except Exception:
existing_task_started_ts = None
payload.setdefault("task_started_ts", existing_task_started_ts or now_ts)
if stage != "fast_db_progress" and "last_db_progress_ts" not in payload:
last_db_progress_ts = _safe_int(redis_client.get(GLOBAL_DB_PROGRESS_TS_KEY))
if last_db_progress_ts is not None:
payload["last_db_progress_ts"] = last_db_progress_ts
data = {
"task_id": task_id,
"stage": stage,
"ts": now_ts,
**payload,
}
ttl = max(60, int(ttl_seconds))
pipe = redis_client.pipeline()
pipe.set(
_task_progress_key(task_id),
json.dumps(data, ensure_ascii=False),
ex=ttl,
)
# Глобальный маркер активности для внешнего guard-процесса.
# Нужен, чтобы контейнер мог самовосстанавливаться при полном зависании воркера
# (когда PID жив, но прогресс по задачам не двигается).
pipe.set(GLOBAL_PROGRESS_TS_KEY, str(now_ts), ex=max(ttl, 7 * 24 * 60 * 60))
if stage == "fast_db_progress":
pipe.set(GLOBAL_DB_PROGRESS_TS_KEY, str(now_ts), ex=max(ttl, 7 * 24 * 60 * 60))
pipe.execute()
except Exception:
logger.warning("Failed to update task progress for %s", task_id, exc_info=True)
def _clear_task_progress(redis_client: Redis, task_id: str) -> None:
try:
redis_client.delete(_task_progress_key(task_id))
except Exception:
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:
if stage in STALL_WATCHDOG_NAVIGATION_STAGES:
return max(int(default_timeout), STALL_WATCHDOG_NAVIGATION_GRACE_SECONDS)
if stage in STALL_WATCHDOG_LONG_RUNNING_STAGES:
return max(int(default_timeout), STALL_WATCHDOG_DETAIL_GRACE_SECONDS)
return int(default_timeout)
def _hourly_sitemap_diff_sync(*, lane: str, limit: int | None, only_new: bool | None, progress_callback=None) -> dict[str, object]:
del limit, only_new
settings = Settings()
persistence = _get_persistence()
persistence.create_tables()
discovery_result = discover_vehicle_urls_from_sitemap_with_stats(settings=settings)
discovered_urls = discovery_result.vehicle_urls
active_urls = set(discovered_urls)
existing_urls = persistence.get_all_active_origin_urls_for_lane("iaai:")
new_urls = [url for url in discovered_urls if url not in existing_urls]
sold_count = 0
if active_urls:
sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane="iaai")
cars_upserted = 0
cars_failed = 0
protection_events = 0
images_upserted = 0
failures: list[dict[str, str]] = []
if new_urls:
with IAAIScraper(settings) as scraper:
if progress_callback:
scraper.set_progress_callback(progress_callback)
batch_size = settings.celery.batch_size
for batch_start in range(0, len(new_urls), batch_size):
batch_urls = new_urls[batch_start:batch_start + batch_size]
batch_result = scraper.sync_batch(batch_urls, lane=lane)
batch_ok = int(batch_result.get("cars_upserted", 0))
batch_fail = int(batch_result.get("cars_failed", 0))
batch_protection = int(batch_result.get("protection_events", 0))
cars_upserted += batch_ok
cars_failed += batch_fail
protection_events += batch_protection
images_upserted += int(batch_result.get("images_upserted", 0))
failures.extend(batch_result.get("failures", []))
# Fail-fast: если слишком много ошибок — IAAI блокирует, не тратим ресурсы.
total_in_batch = batch_ok + batch_fail
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)
break
if batch_start + batch_size < len(new_urls):
time.sleep(_INTER_BATCH_DELAY)
status = "success" if not failures else ("partial_success" if cars_upserted else "failed")
return {
"status": status,
"run_id": None,
"cars_upserted": cars_upserted,
"cars_failed": cars_failed,
"images_upserted": images_upserted,
"skipped_existing": len(discovered_urls) - len(new_urls),
"elapsed_seconds": None,
"failures": failures,
"full_scan_completed": True,
"hourly_mode": "sitemap_diff",
"discovered_urls": len(discovered_urls),
"new_urls": len(new_urls),
"sold_marked": sold_count,
"protection_events": protection_events,
"transport": discovery_result.stats.transport,
}
def _hourly_sitemap_full_refresh_sync(*, lane: str, limit: int | None, only_new: bool | None, progress_callback=None) -> dict[str, object]:
del limit, only_new
settings = Settings()
persistence = _get_persistence()
persistence.create_tables()
discovery_result = discover_vehicle_urls_from_sitemap_with_stats(settings=settings)
discovered_urls = discovery_result.vehicle_urls
active_urls = set(discovered_urls)
sold_count = 0
if active_urls:
sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane="iaai")
cars_upserted = 0
cars_failed = 0
protection_events = 0
images_upserted = 0
failures: list[dict[str, str]] = []
with IAAIScraper(settings) as scraper:
if progress_callback:
scraper.set_progress_callback(progress_callback)
batch_size = settings.celery.batch_size
for batch_start in range(0, len(discovered_urls), batch_size):
batch_urls = discovered_urls[batch_start:batch_start + batch_size]
batch_result = scraper.sync_batch(batch_urls, lane=lane)
batch_ok = int(batch_result.get("cars_upserted", 0))
batch_fail = int(batch_result.get("cars_failed", 0))
batch_protection = int(batch_result.get("protection_events", 0))
cars_upserted += batch_ok
cars_failed += batch_fail
protection_events += batch_protection
images_upserted += int(batch_result.get("images_upserted", 0))
failures.extend(batch_result.get("failures", []))
total_in_batch = batch_ok + batch_fail
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)
break
if batch_start + batch_size < len(discovered_urls):
time.sleep(_INTER_BATCH_DELAY)
status = "success" if not failures else ("partial_success" if cars_upserted else "failed")
return {
"status": status,
"run_id": None,
"cars_upserted": cars_upserted,
"cars_failed": cars_failed,
"images_upserted": images_upserted,
"skipped_existing": 0,
"elapsed_seconds": None,
"failures": failures,
"full_scan_completed": True,
"hourly_mode": "sitemap_full_refresh",
"discovered_urls": len(discovered_urls),
"new_urls": None,
"sold_marked": sold_count,
"protection_events": protection_events,
"transport": discovery_result.stats.transport,
}
def _hourly_sitemap_rolling_refresh_sync(
*,
redis_client: Redis,
lane: str,
limit: int | None,
only_new: bool | None,
progress_callback=None,
) -> dict[str, object]:
del limit, only_new
settings = Settings()
persistence = _get_persistence()
persistence.create_tables()
discovery_result = discover_vehicle_urls_from_sitemap_with_stats(settings=settings)
discovered_urls = discovery_result.vehicle_urls
active_urls = set(discovered_urls)
sold_count = 0
if active_urls:
sold_count = persistence.mark_sold_not_in_listing_by_urls(active_urls, lane="iaai")
existing_urls = persistence.get_all_active_origin_urls_for_lane("iaai:")
new_urls = [url for url in discovered_urls if url not in existing_urls]
total_active = persistence.count_active_cars_for_lane("iaai:")
batch_size = max(1, int(settings.discovery.hourly_refresh_batch_size))
try:
offset = int(redis_client.get(SITEMAP_HOURLY_REFRESH_OFFSET_KEY) or 0)
except Exception:
offset = 0
refresh_urls = persistence.get_active_origin_urls_batch_for_refresh(
prefix="iaai:",
offset=offset,
limit=batch_size,
)
if not refresh_urls and total_active > 0:
offset = 0
refresh_urls = persistence.get_active_origin_urls_batch_for_refresh(
prefix="iaai:",
offset=0,
limit=batch_size,
)
next_offset = 0
if total_active > 0:
next_offset = offset + len(refresh_urls)
if next_offset >= total_active:
next_offset = 0
try:
redis_client.set(SITEMAP_HOURLY_REFRESH_OFFSET_KEY, str(next_offset))
except Exception:
logger.warning("Failed to persist hourly rolling refresh offset", exc_info=True)
seen: set[str] = set()
target_urls: list[str] = []
for url in new_urls + refresh_urls:
if url in seen:
continue
seen.add(url)
target_urls.append(url)
cars_upserted = 0
cars_failed = 0
protection_events = 0
images_upserted = 0
failures: list[dict[str, str]] = []
if target_urls:
with IAAIScraper(settings) as scraper:
if progress_callback:
scraper.set_progress_callback(progress_callback)
worker_batch_size = settings.celery.batch_size
for batch_start in range(0, len(target_urls), worker_batch_size):
batch_urls = target_urls[batch_start:batch_start + worker_batch_size]
batch_result = scraper.sync_batch(batch_urls, lane=lane)
batch_ok = int(batch_result.get("cars_upserted", 0))
batch_fail = int(batch_result.get("cars_failed", 0))
batch_protection = int(batch_result.get("protection_events", 0))
cars_upserted += batch_ok
cars_failed += batch_fail
protection_events += batch_protection
images_upserted += int(batch_result.get("images_upserted", 0))
failures.extend(batch_result.get("failures", []))
total_in_batch = batch_ok + batch_fail
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)
break
if batch_start + worker_batch_size < len(target_urls):
time.sleep(_INTER_BATCH_DELAY)
status = "success" if not failures else ("partial_success" if cars_upserted else "failed")
return {
"status": status,
"run_id": None,
"cars_upserted": cars_upserted,
"cars_failed": cars_failed,
"images_upserted": images_upserted,
"skipped_existing": max(0, len(discovered_urls) - len(new_urls)),
"elapsed_seconds": None,
"failures": failures,
"full_scan_completed": True,
"hourly_mode": "sitemap_rolling_refresh",
"discovered_urls": len(discovered_urls),
"new_urls": len(new_urls),
"refresh_urls": len(refresh_urls),
"sold_marked": sold_count,
"protection_events": protection_events,
"transport": discovery_result.stats.transport,
"refresh_offset": offset,
"refresh_next_offset": next_offset,
"active_total": total_active,
}
def _start_stall_watchdog(
redis_client: Redis,
*,
task_id: str,
stall_timeout_seconds: int,
lock_key: str | None = None,
lock_owner: str | None = None,
db_idle_restart_seconds: int | None = None,
) -> tuple[Event, Thread]:
stop_event = Event()
interval_seconds = max(5.0, min(30.0, stall_timeout_seconds / 3))
def _watchdog() -> None:
key = _task_progress_key(task_id)
no_data_count = 0
# Абсолютный дедлайн: если watchdog работает дольше 3× stall_timeout без прогресса — убиваем.
watchdog_born = time.monotonic()
absolute_deadline = stall_timeout_seconds * 3
while not stop_event.wait(interval_seconds):
db_idle_restart = False
try:
raw = redis_client.get(key)
if not raw:
no_data_count += 1
elapsed_since_born = time.monotonic() - watchdog_born
if no_data_count % 5 == 0:
logger.warning(
"Stall watchdog: no progress data for task %s after %d checks (%.0fs)",
task_id, no_data_count, elapsed_since_born,
)
# Если прогресс-данных нет дольше stall_timeout — считаем задачу мёртвой.
if elapsed_since_born > stall_timeout_seconds:
logger.error(
"Task %s has no progress data for %.0fs (> %ds); treating as stalled",
task_id, elapsed_since_born, stall_timeout_seconds,
)
else:
continue
else:
no_data_count = 0
data = json.loads(raw)
stage = data.get("stage")
last_ts = int(data.get("ts") or 0)
if not last_ts:
continue
db_idle_restart = bool(
db_idle_restart_seconds
and _should_restart_for_db_idle(data, db_idle_restart_seconds)
)
if db_idle_restart:
logger.error(
"Task %s has no DB writes for >%ss at segment=%s/%s stage=%s; full restart required",
task_id,
db_idle_restart_seconds,
data.get("segment_index"),
data.get("segments_total"),
stage,
)
else:
effective_stall_timeout = _stall_timeout_for_progress(stage, stall_timeout_seconds)
age = int(time.time()) - last_ts
if age < effective_stall_timeout:
watchdog_born = time.monotonic() # reset absolute deadline on real progress
continue
logger.error(
"Task %s stalled for %ss at stage=%s payload=%s; cleaning up and restarting",
task_id,
age,
stage,
data,
)
except Exception:
logger.warning("Failed to inspect task progress for stall watchdog", exc_info=True)
# Если Redis тоже не отвечает дольше дедлайна — убиваем.
if time.monotonic() - watchdog_born > absolute_deadline:
logger.error("Stall watchdog: Redis unreachable for %.0fs; forcing kill", time.monotonic() - watchdog_born)
else:
continue
if db_idle_restart:
_restart_bootstrap_from_first_segment(
redis_client,
reason=f"no DB writes for >{db_idle_restart_seconds}s",
)
# ── Pre-SIGTERM cleanup: release lock so next task can run ──
if lock_key and lock_owner:
try:
_release_lock_if_owner(redis_client, lock_key, lock_owner)
logger.info("Stall watchdog: released lock %s before SIGTERM", lock_key)
except Exception:
# Force-delete if owner check fails (process is dying anyway)
try:
redis_client.delete(lock_key)
logger.info("Stall watchdog: force-deleted lock %s", lock_key)
except Exception:
logger.warning("Stall watchdog: failed to release lock %s", lock_key, exc_info=True)
# ── Queue a followup task so parsing resumes after restart ──
try:
followup_ttl = max(180, int(stall_timeout_seconds) + 300)
if _try_set_followup_pending(redis_client, ttl_seconds=followup_ttl):
from ..worker.celery_app import celery_app
celery_app.send_task(
SYNC_LISTING_TASK_NAME,
kwargs={},
queue=IAAI_SYNC_QUEUE,
countdown=15,
expires=followup_ttl,
)
logger.info("Stall watchdog: queued followup sync_listing_task after stall")
else:
logger.info("Stall watchdog: followup already pending, skip duplicate enqueue")
except Exception:
try:
_clear_followup_pending(redis_client)
except Exception:
pass
logger.warning("Stall watchdog: failed to queue followup task", exc_info=True)
# SIGTERM даёт процессу время на cleanup (закрыть DB, browser).
# Celery перехватит SIGTERM и поднимет Terminated / warm shutdown.
try:
os.kill(os.getpid(), signal.SIGTERM)
except OSError:
pass
# Даём 30 секунд на graceful shutdown, потом SIGKILL как последний resort.
stop_event.wait(30)
if not stop_event.is_set():
logger.error("Task %s did not stop after SIGTERM; forcing SIGKILL", task_id)
os.kill(os.getpid(), signal.SIGKILL)
thread = Thread(target=_watchdog, name=f"task-stall-watchdog-{task_id[:8]}", daemon=True)
thread.start()
return stop_event, thread
def _should_restart_for_db_idle(progress: dict, db_idle_restart_seconds: int) -> bool:
stage = str(progress.get("stage") or "")
if stage in TERMINAL_PROGRESS_STAGES:
return False
if stage in STALL_WATCHDOG_LONG_RUNNING_STAGES:
timeout = _stall_timeout_for_progress(stage, db_idle_restart_seconds)
progress_ts = _safe_int(progress.get("ts")) or 0
return progress_ts > 0 and int(time.time()) - progress_ts >= timeout
segments_total = _safe_int(progress.get("segments_total"))
segment_index = _safe_int(progress.get("segment_index"))
if segments_total is None or segment_index is None:
return False
if segments_total <= 0 or segment_index >= segments_total - 1:
return False
now_ts = int(time.time())
progress_ts = _safe_int(progress.get("ts")) or 0
if progress_ts <= 0:
return False
db_progress_ts = _safe_int(progress.get("last_db_progress_ts"))
if db_progress_ts is None:
db_progress_ts = _read_global_db_progress_ts()
task_started_ts = _safe_int(progress.get("task_started_ts")) or progress_ts
last_db_or_start_ts = max(db_progress_ts or 0, task_started_ts)
return now_ts - last_db_or_start_ts >= int(db_idle_restart_seconds)
def _safe_int(value) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _mobilede_should_skip_dynamic_segment(total_results: int | None) -> bool:
return MOBILEDE_DYNAMIC_SEGMENT_PROBES and MOBILEDE_SKIP_EMPTY_DYNAMIC_SEGMENTS and total_results == 0
def _read_global_db_progress_ts() -> int | None:
try:
redis_client = _get_redis()
raw = redis_client.get(GLOBAL_DB_PROGRESS_TS_KEY)
return _safe_int(raw)
except Exception:
return None
def _mobilede_segment_key(segment: dict[str, object] | None) -> str:
if not segment:
return "all"
listing_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
if listing_url:
digest = hashlib.sha1(listing_url.encode("utf-8")).hexdigest()[:16]
return f"url:{digest}"
make_id = str(segment.get("make_id") or segment.get("makeId") or segment.get("make") or "all").strip()
model_id = str(segment.get("model_id") or segment.get("modelId") or segment.get("model") or "all").strip()
return f"{make_id}:{model_id}".replace(" ", "_")
def _mobilede_task_segment_key(
*,
segment: dict[str, object] | None,
search_url: str | None,
make_id: str | None,
model_id: str | None,
price_min: str | None,
price_max: str | None,
year_min: str | None,
year_max: str | None,
mileage_min: str | None,
mileage_max: str | None,
) -> str:
if segment:
return _mobilede_segment_fingerprint(segment)
payload = {
"search_url": str(search_url or "").strip(),
"make_id": str(make_id or "").strip(),
"model_id": str(model_id or "").strip(),
"price_min": str(price_min or "").strip(),
"price_max": str(price_max or "").strip(),
"year_min": str(year_min or "").strip(),
"year_max": str(year_max or "").strip(),
"mileage_min": str(mileage_min or "").strip(),
"mileage_max": str(mileage_max or "").strip(),
}
return hashlib.sha1(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()[:16]
def _try_set_mobilede_followup_pending(redis_client: Redis, *, segment_key: str, ttl_seconds: int) -> bool:
try:
return bool(redis_client.set(_mobilede_followup_pending_key(segment_key), "1", nx=True, ex=max(60, int(ttl_seconds))))
except Exception:
logger.warning("Failed to set mobile.de follow-up pending flag", exc_info=True)
return True
def _clear_mobilede_followup_pending(redis_client: Redis, *, segment_key: str) -> None:
try:
redis_client.delete(_mobilede_followup_pending_key(segment_key))
except Exception:
logger.warning("Failed to clear mobile.de follow-up pending flag", exc_info=True)
def _mobilede_followup_pending_is_stale(redis_client: Redis, *, segment_key: str) -> bool:
try:
pending_key = _mobilede_followup_pending_key(segment_key)
if not redis_client.exists(pending_key):
return False
segment_lock_key = _mobilede_segment_lock_key(segment_key)
if redis_client.exists(segment_lock_key):
return False
return True
except Exception:
logger.warning("Failed to inspect mobile.de follow-up pending flag", exc_info=True)
return False
def _try_reset_stale_mobilede_followup_pending(redis_client: Redis, *, segment_key: str) -> bool:
if not _mobilede_followup_pending_is_stale(redis_client, segment_key=segment_key):
return False
try:
redis_client.delete(_mobilede_followup_pending_key(segment_key))
logger.warning("Reset stale mobile.de follow-up pending flag for segment=%s", segment_key)
return True
except Exception:
logger.warning("Failed to reset stale mobile.de follow-up pending flag", exc_info=True)
return False
def _mobilede_segment_fingerprint(segment: dict[str, object] | None) -> str:
if not segment:
return "all"
payload = json.dumps(segment, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
def _mobilede_cursor_key(segment: dict[str, object] | None) -> str:
if not segment:
return MOBILEDE_SEARCH_CURSOR_KEY
return MOBILEDE_SEGMENT_CURSOR_KEY_FMT.format(segment_key=_mobilede_segment_fingerprint(segment))
def _mobilede_progress_page_counter_key(segment: dict[str, object] | None) -> str:
return MOBILEDE_PROGRESS_PAGE_COUNTER_KEY_FMT.format(segment_key=_mobilede_segment_fingerprint(segment))
def _mobilede_segment_zero_insert_streak_key(segment: dict[str, object] | None) -> str:
return f"mobilede:state:segment_zero_insert_streak:{_mobilede_segment_fingerprint(segment)}"
def _mobilede_segment_cooldown_key(segment: dict[str, object] | None) -> str:
return f"mobilede:state:segment_cooldown:{_mobilede_segment_fingerprint(segment)}"
def _mobilede_segment_hot_key(segment: dict[str, object] | None) -> str:
return f"mobilede:state:segment_hot:{_mobilede_segment_fingerprint(segment)}"
def _mobilede_segment_in_cooldown(redis_client: Redis, segment: dict[str, object] | None) -> bool:
if not segment:
return False
return bool(redis_client.ttl(_mobilede_segment_cooldown_key(segment)) > 0)
def _mobilede_segment_is_hot(redis_client: Redis, segment: dict[str, object] | None) -> bool:
if not segment:
return False
return bool(redis_client.ttl(_mobilede_segment_hot_key(segment)) > 0)
def _mobilede_has_hot_segments(redis_client: Redis, segments: list[dict[str, object]]) -> bool:
for segment in segments:
if _mobilede_segment_is_hot(redis_client, segment):
return True
return False
def _mobilede_update_segment_freshness_state(
redis_client: Redis,
*,
segment: dict[str, object] | None,
only_new: bool | None,
inserted: int,
listings: int,
) -> None:
if not segment or only_new is not True:
return
streak_key = _mobilede_segment_zero_insert_streak_key(segment)
cooldown_key = _mobilede_segment_cooldown_key(segment)
hot_key = _mobilede_segment_hot_key(segment)
listings_count = max(0, int(listings))
if inserted > 0:
ratio = (float(inserted) / float(listings_count)) if listings_count > 0 else 0.0
if ratio >= MOBILEDE_ONLY_NEW_MIN_INSERT_RATIO:
redis_client.delete(streak_key)
redis_client.delete(cooldown_key)
redis_client.set(hot_key, "1", ex=MOBILEDE_ONLY_NEW_HOT_TTL_SECONDS)
return
# Есть новые, но доля слишком низкая: сегмент считается холодным для only_new режима.
redis_client.delete(hot_key)
redis_client.set(cooldown_key, "1", ex=MOBILEDE_ONLY_NEW_COOLDOWN_SECONDS)
logger.info(
"mobile.de segment marked cold by ratio: segment=%s inserted=%s listings=%s ratio=%.3f threshold=%.3f cooldown=%ss",
_mobilede_segment_label(segment),
inserted,
listings_count,
ratio,
MOBILEDE_ONLY_NEW_MIN_INSERT_RATIO,
MOBILEDE_ONLY_NEW_COOLDOWN_SECONDS,
)
return
streak = int(redis_client.incr(streak_key))
redis_client.expire(streak_key, 24 * 60 * 60)
if streak >= MOBILEDE_ONLY_NEW_ZERO_INSERT_STREAK:
redis_client.set(cooldown_key, "1", ex=MOBILEDE_ONLY_NEW_COOLDOWN_SECONDS)
logger.info(
"mobile.de segment cooldown enabled: segment=%s streak=%s cooldown=%ss",
_mobilede_segment_label(segment),
streak,
MOBILEDE_ONLY_NEW_COOLDOWN_SECONDS,
)
def _mobilede_segment_label(segment: dict[str, object] | None) -> str:
if not segment:
return "all"
label = str(segment.get("label") or "").strip()
return label or _mobilede_segment_key(segment)
def _mobilede_short_segment_label(segment: dict[str, object] | None) -> str:
label = _mobilede_segment_label(segment)
if " | " not in label:
return label
parts = [part.strip() for part in label.split(" | ") if part.strip()]
useful_parts = [part for part in parts if part.startswith(("ms=", "price=", "year", "km"))]
return " | ".join(useful_parts) if useful_parts else label
def _mobilede_bootstrap_progress(redis_client: Redis) -> tuple[int, int, int]:
done = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY) or 0)
total = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY) or 0)
if total <= 0:
cached_segments = _get_cached_mobilede_runtime_segments(redis_client) or []
if cached_segments:
total = len(cached_segments)
try:
redis_client.set(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY, str(total))
except Exception:
logger.debug("Failed to backfill bootstrap total segments", exc_info=True)
left = max(0, total - done) if total > 0 else 0
return done, total, left
def _mobilede_bootstrap_percent(done: int, total: int) -> float:
if total <= 0:
return 0.0
return min(100.0, max(0.0, (float(done) / float(total)) * 100.0))
def _mobilede_bootstrap_cars_totals(redis_client: Redis) -> tuple[int, int, int, int, int]:
return (
int(redis_client.get(MOBILEDE_BOOTSTRAP_LISTINGS_TOTAL_KEY) or 0),
int(redis_client.get(MOBILEDE_BOOTSTRAP_UNIQUE_TOTAL_KEY) or 0),
int(redis_client.get(MOBILEDE_BOOTSTRAP_INSERTED_TOTAL_KEY) or 0),
int(redis_client.get(MOBILEDE_BOOTSTRAP_UPDATED_TOTAL_KEY) or 0),
int(redis_client.get(MOBILEDE_BOOTSTRAP_IMAGES_TOTAL_KEY) or 0),
)
def _find_mobilede_runtime_segment(
settings: Settings,
*,
search_url: str | None = None,
make_id: str | None,
model_id: str | None,
) -> dict[str, object] | None:
target_search_url = str(search_url or "").strip()
target_make_id = str(make_id or "").strip()
target_model_id = str(model_id or "").strip()
if not target_search_url and not target_make_id and not target_model_id:
return None
for candidate in _build_mobilede_runtime_segments(settings):
candidate_search_url = str(candidate.get("search_url") or candidate.get("listing_url") or "").strip()
if target_search_url and candidate_search_url == target_search_url:
return candidate
candidate_make_id = str(candidate.get("make_id") or "").strip()
candidate_model_id = str(candidate.get("model_id") or "").strip()
if candidate_make_id == target_make_id and candidate_model_id == target_model_id:
return candidate
return None
def _mobilede_segment_make(segment: dict[str, object] | None, fallback: str | None = None) -> str:
if segment:
listing_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
if listing_url:
return "filtered-url"
value = str(segment.get("make") or "").strip()
if value:
return value
return str(fallback or "all").strip() or "all"
def _mobilede_segment_model(segment: dict[str, object] | None, fallback: str | None = None) -> str:
if segment:
listing_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
if listing_url:
return "filtered-url"
value = str(segment.get("model") or "").strip()
if value:
return value
return str(fallback or "all").strip() or "all"
def _mobilede_segment_uses_url(segment: dict[str, object] | None, search_url: str | None = None) -> bool:
if search_url and str(search_url).strip():
return True
if not segment:
return False
return bool(str(segment.get("search_url") or segment.get("listing_url") or "").strip())
def _mobilede_filter_source(segment: dict[str, object] | None, search_url: str | None = None) -> str:
return "search_url" if _mobilede_segment_uses_url(segment, search_url) else "params"
def _is_mobilede_transient_request_error(exc: Exception) -> bool:
if isinstance(exc, requests.exceptions.HTTPError):
status_code = getattr(getattr(exc, "response", None), "status_code", None)
if status_code in {408, 409, 425, 429, 500, 502, 503, 504}:
return True
if isinstance(
exc,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ProxyError,
requests.exceptions.SSLError,
),
):
return True
text = str(exc).lower()
return any(
marker in text
for marker in (
"nameresolutionerror",
"temporary failure in name resolution",
"max retries exceeded",
"connection refused",
"read timed out",
"connect timeout",
)
)
def _mobilede_task_result_summary(
*,
result: dict[str, object],
segment: dict[str, object] | None,
start_page: int,
end_page: int,
make_name: str,
model_name: str,
) -> dict[str, object]:
upsert = result.get("upsert") if isinstance(result.get("upsert"), dict) else {}
return {
"status": "success",
"source": "mobile.de",
"run_id": result.get("run_id"),
"segment": _mobilede_segment_label(segment),
"make": make_name,
"model": model_name,
"pages": {
"start": start_page,
"end": end_page,
"count": end_page - start_page + 1,
},
"listing_count": int(result.get("listing_count", 0) or 0),
"unique_listing_count": int(result.get("unique_listing_count", 0) or 0),
"upsert": {
"inserted": int(upsert.get("inserted", 0) or 0),
"updated": int(upsert.get("updated", 0) or 0),
"images_upserted": int(upsert.get("images_upserted", 0) or 0),
},
}
def _log_mobilede_progress_threshold(
redis_client: Redis,
*,
task_id: str,
segment: dict[str, object] | None,
delta_pages: int,
delta_cars: int,
delta_images: int,
start_page: int,
end_page: int,
) -> None:
if delta_pages <= 0:
return
try:
counter_key = _mobilede_progress_page_counter_key(segment)
total_pages = int(redis_client.incrby(counter_key, int(delta_pages)))
redis_client.expire(counter_key, 7 * 24 * 60 * 60)
previous_total = total_pages - int(delta_pages)
if previous_total // MOBILEDE_PROGRESS_LOG_EVERY_PAGES == total_pages // MOBILEDE_PROGRESS_LOG_EVERY_PAGES:
return
logger.info(
"mobile.de page progress: segment=%s pages_done=%s (+%s) cars_upserted=%s images=%s last_window=%s-%s task_id=%s",
_mobilede_short_segment_label(segment),
total_pages,
delta_pages,
delta_cars,
delta_images,
start_page,
end_page,
task_id,
)
except Exception:
logger.debug("Failed to update mobile.de aggregated progress", exc_info=True)
def _mobilede_url_query_value(search_url: str, key: str) -> str | None:
for item_key, item_value in parse_qsl(urlsplit(search_url).query, keep_blank_values=True):
if item_key == key and item_value != "":
return item_value
return None
def _mobilede_url_query_values(search_url: str, key: str) -> list[str]:
values: list[str] = []
seen: set[str] = set()
for item_key, item_value in parse_qsl(urlsplit(search_url).query, keep_blank_values=True):
if item_key != key:
continue
value = str(item_value or "").strip()
if not value or value in seen:
continue
seen.add(value)
values.append(value)
return values
def _mobilede_make_segment_url(search_url: str, **params: str | int | None) -> str:
return MobileDeClient.build_search_url_from_existing(search_url, page_number=1, **params)
def _mobilede_apply_newest_sort_to_url(search_url: str | None) -> str | None:
if not search_url:
return search_url
return MobileDeClient.build_search_url_from_existing(search_url, page_number=1, sb="doc", od="down")
def _mobilede_is_strict_first_pass_mode(redis_client: Redis, *, segment: dict[str, object] | None, only_new: bool | None) -> bool:
return bool(
MOBILEDE_INCREMENTAL_STRICT_FIRST_PASS
and only_new is True
and segment is not None
and _mobilede_bootstrap_done(redis_client)
and MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP
)
def _mobilede_reserve_incremental_cycle(redis_client: Redis, *, total_segments: int | None) -> tuple[str, bool]:
total = max(1, int(total_segments or 1))
cycle = str(redis_client.get(MOBILEDE_INCREMENTAL_CYCLE_KEY) or "1")
seen = int(redis_client.incr(MOBILEDE_INCREMENTAL_CYCLE_SEEN_COUNT_KEY))
if seen >= total:
next_cycle = str(int(cycle) + 1)
redis_client.set(MOBILEDE_INCREMENTAL_CYCLE_KEY, next_cycle)
redis_client.set(MOBILEDE_INCREMENTAL_CYCLE_SEEN_COUNT_KEY, "0")
return cycle, seen == 1
def _mobilede_cycle_cursor_key(base_cursor_key: str, cycle_id: str) -> str:
return f"{base_cursor_key}:cycle:{cycle_id}"
def _mobilede_cycle_seen_set_key(cycle_id: str) -> str:
return MOBILEDE_INCREMENTAL_CYCLE_SEEN_SET_KEY_FMT.format(cycle_id=cycle_id)
def _mobilede_try_mark_cycle_segment_seen(
redis_client: Redis,
*,
cycle_id: str,
segment: dict[str, object] | None,
ttl_seconds: int = 24 * 60 * 60,
) -> bool:
fingerprint = _mobilede_segment_fingerprint(segment)
set_key = _mobilede_cycle_seen_set_key(cycle_id)
added = int(redis_client.sadd(set_key, fingerprint))
redis_client.expire(set_key, ttl_seconds)
return added == 1
def _mobilede_try_mark_bootstrap_segment_dispatched(
redis_client: Redis,
segment: dict[str, object] | None,
*,
ttl_seconds: int = 24 * 60 * 60,
) -> bool:
fingerprint = _mobilede_segment_fingerprint(segment)
added = int(redis_client.sadd(MOBILEDE_BOOTSTRAP_DISPATCHED_SEGMENTS_KEY, fingerprint))
redis_client.expire(MOBILEDE_BOOTSTRAP_DISPATCHED_SEGMENTS_KEY, ttl_seconds)
return added == 1
def _mobilede_try_recover_stalled_bootstrap_queue(
redis_client: Redis,
*,
queue_name: str = MOBILEDE_SYNC_QUEUE,
) -> bool:
"""Сбрасывает залипшие bootstrap-dispatched маркеры, если очередь пуста и нет активного прогресса."""
if not MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED or _mobilede_bootstrap_done(redis_client):
return False
try:
queue_len = int(redis_client.llen(queue_name) or 0)
if queue_len > 0:
return False
has_active_progress = bool(redis_client.exists("mobilede:state:active_progress"))
if has_active_progress:
return False
done = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY) or 0)
total = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY) or 0)
if total <= 0 or done >= total:
return False
dispatched = int(redis_client.scard(MOBILEDE_BOOTSTRAP_DISPATCHED_SEGMENTS_KEY) or 0)
if dispatched <= 0:
return False
redis_client.delete(MOBILEDE_BOOTSTRAP_DISPATCHED_SEGMENTS_KEY)
logger.warning(
"mobile.de bootstrap queue stall recovered: queue=0 active=0 progress=%s/%s dispatched=%s -> cleared",
done,
total,
dispatched,
)
return True
except Exception:
logger.debug("Failed to recover stalled mobile.de bootstrap queue", exc_info=True)
return False
def _mobilede_current_cycle_id(redis_client: Redis) -> str:
cycle_id = str(redis_client.get(MOBILEDE_INCREMENTAL_CYCLE_KEY) or "").strip()
if not cycle_id:
cycle_id = "1"
redis_client.set(MOBILEDE_INCREMENTAL_CYCLE_KEY, cycle_id)
return cycle_id
def _mobilede_incremental_cycle_progress(
redis_client: Redis,
*,
cycle_id: str | None,
total_segments: int | None = None,
) -> tuple[str, int, int, int]:
resolved_cycle_id = str(cycle_id or _mobilede_current_cycle_id(redis_client) or "1").strip() or "1"
total = max(0, int(total_segments or 0))
if total <= 0:
total = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY) or 0)
if total <= 0:
total = len(_get_cached_mobilede_runtime_segments(redis_client) or [])
seen = int(redis_client.scard(_mobilede_cycle_seen_set_key(resolved_cycle_id)) or 0)
left = max(0, total - seen) if total > 0 else 0
return resolved_cycle_id, seen, total, left
def _mobilede_reserve_strict_first_pass_segment(
redis_client: Redis,
settings: Settings,
*,
segment: dict[str, object] | None,
segment_index: int | None,
) -> tuple[dict[str, object] | None, int | None, str]:
segments = _get_cached_mobilede_runtime_segments(redis_client) or []
total_segments = len(segments)
cycle_id = _mobilede_current_cycle_id(redis_client)
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
only_new = runtime_config.sync.only_new
def _try_take_current() -> bool:
return segment is not None and _mobilede_try_mark_cycle_segment_seen(
redis_client,
cycle_id=cycle_id,
segment=segment,
)
if _try_take_current():
return segment, segment_index, cycle_id
for _ in range(max(1, total_segments)):
reservation = _reserve_mobilede_runtime_segment(redis_client, settings, only_new=only_new)
if reservation is None:
break
next_index, next_segment = reservation
if _mobilede_try_mark_cycle_segment_seen(
redis_client,
cycle_id=cycle_id,
segment=next_segment,
):
return next_segment, next_index, cycle_id
# Все сегменты цикла пройдены: начинаем новый цикл и берём первый доступный.
cycle_id = str(int(cycle_id) + 1)
redis_client.set(MOBILEDE_INCREMENTAL_CYCLE_KEY, cycle_id)
redis_client.set(MOBILEDE_INCREMENTAL_CYCLE_SEEN_COUNT_KEY, "0")
if segment is not None and _mobilede_try_mark_cycle_segment_seen(
redis_client,
cycle_id=cycle_id,
segment=segment,
):
return segment, segment_index, cycle_id
for _ in range(max(1, total_segments)):
reservation = _reserve_mobilede_runtime_segment(redis_client, settings, only_new=only_new)
if reservation is None:
break
next_index, next_segment = reservation
if _mobilede_try_mark_cycle_segment_seen(
redis_client,
cycle_id=cycle_id,
segment=next_segment,
):
return next_segment, next_index, cycle_id
return segment, segment_index, cycle_id
def _mobilede_price_ranges() -> list[tuple[int, int | None]]:
raw_ranges = os.getenv("MOBILEDE_PRICE_RANGES", "").strip()
if raw_ranges:
parsed: list[tuple[int, int | None]] = []
for raw_item in raw_ranges.split(","):
item = raw_item.strip()
if not item:
continue
left, _, right = item.partition(":")
try:
min_value = int(left.strip()) if left.strip() else 1
max_value = int(right.strip()) if right.strip() else None
parsed.append((min_value, max_value))
except ValueError:
logger.warning("Invalid MOBILEDE_PRICE_RANGES item ignored: %s", item)
if parsed:
return parsed
compact = os.getenv("MOBILEDE_COMPACT_SEGMENTS", "true").strip().lower() in {"1", "true", "yes", "on"}
if compact:
return [
(1, 5000),
(5001, 10000),
(10001, 15000),
(15001, 20000),
(20001, 30000),
(30001, 50000),
(50001, 75000),
(75001, 100000),
(100001, 150000),
(150001, None),
]
return [(1, 500), (500, 1000), (1001, 1500), (1501, 2000), (2001, 2500), (2501, 3000), (3001, 4000), (4001, 5000), (5001, 7500), (7501, 10000), (10001, 12500), (12501, 15000), (15001, 17500), (17501, 20000), (20001, 25000), (25001, 30000), (30001, 40000), (40001, 50000), (50001, 75000), (75001, 100000), (100001, 150000), (150001, None)]
def _mobilede_year_ranges() -> list[tuple[int | None, int | None]]:
compact = os.getenv("MOBILEDE_COMPACT_SEGMENTS", "true").strip().lower() in {"1", "true", "yes", "on"}
if compact:
return [(None, 2009), (2010, 2017), (2018, 2022), (2023, None)]
return [(None, 1999), (2000, 2004), (2005, 2009), (2010, 2014), (2015, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
def _mobilede_hot_year_ranges() -> list[tuple[int | None, int | None]]:
return [(None, 2009), (2010, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
def _mobilede_low_price_hot_year_ranges() -> list[tuple[int | None, int | None]]:
return [(None, 2004), (2005, 2009), (2010, 2013), (2014, 2017), (2018, 2020), (2021, 2022), (2023, 2024), (2025, None)]
def _mobilede_year_ranges_for_price(price_min: int, price_max: int | None) -> list[tuple[int | None, int | None]]:
if not MOBILEDE_HOT_BASE_SPLIT_ENABLED:
return _mobilede_year_ranges()
upper_bound = int(price_max) if price_max is not None else int(price_min)
if upper_bound <= 10000:
return _mobilede_low_price_hot_year_ranges()
if upper_bound <= MOBILEDE_HOT_BASE_PRICE_MAX:
return _mobilede_hot_year_ranges()
return _mobilede_year_ranges()
def _mobilede_mileage_ranges() -> list[tuple[int | None, int | None]]:
compact = os.getenv("MOBILEDE_COMPACT_SEGMENTS", "true").strip().lower() in {"1", "true", "yes", "on"}
if compact:
return [(None, 100000), (100001, 200000), (200001, None)]
return [(None, 50000), (50001, 100000), (100001, 150000), (150001, 200000), (200001, None)]
def _mobilede_should_pre_split_mileage(
price_min: int,
price_max: int | None,
year_min: int | None,
year_max: int | None,
) -> bool:
if not MOBILEDE_HOT_MILEAGE_SPLIT_ENABLED:
return False
# Дешёвый старый сегмент — один из самых плотных для Toyota/Hyundai.
if price_max is not None and price_max <= MOBILEDE_HOT_OLD_CHEAP_PRICE_MAX:
return year_max is not None and year_max <= 2009
if (
price_max is not None
and price_max <= 10000
and year_min is not None
and year_min >= 2010
and year_max is not None
and year_max <= 2017
):
return True
if (
price_min >= 10001
and price_max is not None
and price_max <= 15000
and year_min is not None
and year_min >= 2010
and year_max is not None
and year_max <= 2020
):
return True
if (
price_min >= 15001
and price_max is not None
and price_max <= 30000
and year_min is not None
and year_min >= 2021
):
return True
if (
price_min >= 30001
and price_max is not None
and price_max <= 75000
and year_min is not None
and year_min >= 2023
):
return True
# Самый ликвидный recent-mid-price диапазон сразу режем по пробегу,
# чтобы не терять хвост за лимитом 50x20 и не плодить поздний overflow.
return bool(
year_min is not None
and year_min >= MOBILEDE_HOT_RECENT_YEAR_MIN
and price_min >= MOBILEDE_HOT_MILEAGE_PRICE_MIN
and price_max is not None
and price_max <= MOBILEDE_HOT_MILEAGE_PRICE_MAX
)
def _mobilede_price_subranges_for_hot_year(
price_min: int,
price_max: int | None,
year_min: int | None,
year_max: int | None,
) -> list[tuple[int, int | None]]:
if price_max is None:
return [(price_min, price_max)]
if year_min is not None and year_min >= 2025:
if price_min == 15001 and price_max == 20000:
return [(15001, 17500), (17501, 20000)]
if price_min == 20001 and price_max == 30000:
return [(20001, 25000), (25001, 30000)]
if year_min is not None and year_min >= 2023:
if price_min == 20001 and price_max == 30000:
return [(20001, 25000), (25001, 30000)]
if price_min == 30001 and price_max == 50000:
return [(30001, 40000), (40001, 50000)]
if price_min == 50001 and price_max == 75000:
return [(50001, 62500), (62501, 75000)]
return [(price_min, price_max)]
def _mobilede_range_value(min_value: int | None, max_value: int | None) -> str:
return f"{min_value or ''}:{max_value or ''}"
def _mobilede_price_label(price_min: int, price_max: int | None) -> str:
return f"price={price_min}-{price_max}" if price_max is not None else f"price={price_min}+"
def _mobilede_year_label(year_min: int | None, year_max: int | None) -> str:
if year_min is None:
return f"year<={year_max}"
if year_max is None:
return f"year>={year_min}"
return f"year={year_min}-{year_max}"
def _mobilede_mileage_label(mileage_min: int | None, mileage_max: int | None) -> str:
if mileage_min is None:
return f"km<={mileage_max}"
if mileage_max is None:
return f"km>={mileage_min}"
return f"km={mileage_min}-{mileage_max}"
def _mobilede_overflow_threshold(max_pages: int) -> int:
cap = max(MOBILEDE_RESULTS_PER_PAGE, int(max_pages) * MOBILEDE_RESULTS_PER_PAGE)
threshold = int(float(cap) * MOBILEDE_OVERFLOW_SPLIT_THRESHOLD_RATIO)
return max(MOBILEDE_RESULTS_PER_PAGE, min(cap, threshold))
def _mobilede_parse_optional_int(value: object) -> int | None:
raw = str(value or "").strip()
if not raw:
return None
try:
return int(raw)
except ValueError:
return None
def _mobilede_split_year_ranges_for_overflow(
year_min: int | None,
year_max: int | None,
) -> list[tuple[int | None, int | None]]:
current_year = int(time.gmtime().tm_year)
if year_min is None and year_max is None:
return []
if year_min is None and year_max is not None:
pivot = int(year_max) - 5
if pivot <= 0 or pivot >= int(year_max):
return []
return [(None, pivot), (pivot + 1, int(year_max))]
if year_min is not None and year_max is None:
if int(year_min) >= current_year - 1:
return []
pivot = min(current_year - 2, int(year_min) + 2)
if pivot <= int(year_min):
return []
return [(int(year_min), pivot), (pivot + 1, None)]
# Оба значения заданы.
assert year_min is not None and year_max is not None
span = int(year_max) - int(year_min)
if span < 2:
return []
pivot = int(year_min) + span // 2
if pivot <= int(year_min) or pivot >= int(year_max):
return []
return [(int(year_min), pivot), (pivot + 1, int(year_max))]
def _mobilede_split_price_ranges_for_overflow(
price_min: int | None,
price_max: int | None,
) -> list[tuple[int, int | None]]:
left = int(price_min or 1)
right = price_max
if right is None:
step = max(5000, min(50000, left))
pivot = left + step
return [(left, pivot), (pivot + 1, None)]
span = int(right) - int(left)
if span < 2000:
return []
pivot = int(left) + span // 2
if pivot <= int(left) or pivot >= int(right):
return []
return [(int(left), pivot), (pivot + 1, int(right))]
def _mobilede_make_overflow_child_segment(
segment: dict[str, object],
*,
search_url: str,
max_pages: int,
parent_fingerprint: str,
depth: int,
split_kind: str,
split_label: str,
split_params: dict[str, str],
price_range: tuple[int | None, int | None] | None = None,
year_range: tuple[int | None, int | None] | None = None,
mileage_range: tuple[int | None, int | None] | None = None,
) -> dict[str, object]:
child = dict(segment)
child["search_url"] = _mobilede_make_segment_url(search_url, **split_params)
child["listing_url"] = child["search_url"]
child["start_page"] = 1
child["max_pages"] = max_pages
child["total_results"] = None
child["overflow_parent"] = parent_fingerprint
child["overflow_depth"] = depth + 1
child["overflow_split"] = split_kind
child["label"] = f"{str(segment.get('label') or 'mobile.de overflow')} | {split_label} | overflow:d{depth + 1}"
if price_range is not None:
price_min, price_max = price_range
child["price_min"] = str(price_min) if price_min is not None else None
child["price_max"] = str(price_max) if price_max is not None else None
if year_range is not None:
year_min, year_max = year_range
child["year_min"] = str(year_min) if year_min is not None else None
child["year_max"] = str(year_max) if year_max is not None else None
if mileage_range is not None:
mileage_min, mileage_max = mileage_range
child["mileage_min"] = str(mileage_min) if mileage_min is not None else None
child["mileage_max"] = str(mileage_max) if mileage_max is not None else None
return child
def _mobilede_build_overflow_child_segments(
*,
segment: dict[str, object],
max_pages: int,
) -> list[dict[str, object]]:
if MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS <= 0:
return []
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
if not search_url:
return []
depth = max(0, int(_mobilede_parse_optional_int(segment.get("overflow_depth")) or 0))
if depth >= MOBILEDE_OVERFLOW_MAX_SPLIT_DEPTH:
return []
query_keys = {key for key, _ in parse_qsl(urlsplit(search_url).query, keep_blank_values=True)}
parent_fingerprint = _mobilede_segment_fingerprint(segment)
segment_max_pages = max(1, int(max_pages or segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER))
has_mileage_filter = bool(
str(segment.get("mileage_min") or "").strip()
or str(segment.get("mileage_max") or "").strip()
or "ml" in query_keys
)
# 1) Первый уровень: mileage-разбиение (самый дешёвый и наименее дублящийся).
if not has_mileage_filter:
child_segments: list[dict[str, object]] = []
for mileage_min, mileage_max in _mobilede_mileage_ranges()[:MOBILEDE_OVERFLOW_MAX_CHILD_SEGMENTS]:
child_segments.append(
_mobilede_make_overflow_child_segment(
segment,
search_url=search_url,
max_pages=segment_max_pages,
parent_fingerprint=parent_fingerprint,
depth=depth,
split_kind="mileage",
split_label=_mobilede_mileage_label(mileage_min, mileage_max),
split_params={"ml": _mobilede_range_value(mileage_min, mileage_max)},
mileage_range=(mileage_min, mileage_max),
)
)
return child_segments
# 2) Если mileage уже есть — делим год.
year_min = _mobilede_parse_optional_int(segment.get("year_min"))
year_max = _mobilede_parse_optional_int(segment.get("year_max"))
year_splits = _mobilede_split_year_ranges_for_overflow(year_min, year_max)
if year_splits:
child_segments = []
for split_year_min, split_year_max in year_splits[:2]:
child_segments.append(
_mobilede_make_overflow_child_segment(
segment,
search_url=search_url,
max_pages=segment_max_pages,
parent_fingerprint=parent_fingerprint,
depth=depth,
split_kind="year",
split_label=_mobilede_year_label(split_year_min, split_year_max),
split_params={"fr": _mobilede_range_value(split_year_min, split_year_max)},
year_range=(split_year_min, split_year_max),
)
)
return child_segments
# 3) Fallback: если год уже очень узкий — делим цену.
price_min = _mobilede_parse_optional_int(segment.get("price_min"))
price_max = _mobilede_parse_optional_int(segment.get("price_max"))
price_splits = _mobilede_split_price_ranges_for_overflow(price_min, price_max)
if price_splits:
child_segments = []
for split_price_min, split_price_max in price_splits[:2]:
price_label = _mobilede_price_label(split_price_min, split_price_max)
child_segments.append(
_mobilede_make_overflow_child_segment(
segment,
search_url=search_url,
max_pages=segment_max_pages,
parent_fingerprint=parent_fingerprint,
depth=depth,
split_kind="price",
split_label=price_label,
split_params={"p": _mobilede_range_value(split_price_min, split_price_max)},
price_range=(split_price_min, split_price_max),
)
)
return child_segments
return []
def _mobilede_try_expand_overflow_segment(
redis_client: Redis,
settings: Settings,
*,
segment: dict[str, object] | None,
listing_count: int,
unique_count: int,
max_pages: int,
segment_end_page: int,
) -> int:
if not MOBILEDE_OVERFLOW_SPLIT_ENABLED or not MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED or not segment:
return 0
normalized_max_pages = max(1, int(max_pages or segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER))
if int(segment_end_page) < normalized_max_pages:
return 0
observed = max(int(listing_count or 0), int(unique_count or 0))
threshold = _mobilede_overflow_threshold(normalized_max_pages)
if observed < threshold:
return 0
child_segments = _mobilede_build_overflow_child_segments(
segment=segment,
max_pages=normalized_max_pages,
)
if not child_segments:
return 0
parent_fingerprint = _mobilede_segment_fingerprint(segment)
if int(redis_client.sadd(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, parent_fingerprint)) != 1:
return 0
redis_client.expire(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, 30 * 24 * 60 * 60)
lock_owner = f"overflow:{uuid.uuid4().hex}"
if not _acquire_lock(redis_client, MOBILEDE_RUNTIME_SEGMENTS_CACHE_LOCK_KEY, lock_owner, 120):
redis_client.srem(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, parent_fingerprint)
return 0
committed = False
try:
cached_segments = _get_cached_mobilede_runtime_segments(redis_client)
if cached_segments is None:
cached_segments = _build_mobilede_runtime_segments(settings)
existing_fingerprints = {
_mobilede_segment_fingerprint(item)
for item in cached_segments
if isinstance(item, dict)
}
appended_segments: list[dict[str, object]] = []
for child in child_segments:
child_fingerprint = _mobilede_segment_fingerprint(child)
if child_fingerprint in existing_fingerprints:
continue
existing_fingerprints.add(child_fingerprint)
appended_segments.append(child)
if not appended_segments:
committed = True
return 0
updated_segments = [dict(item) for item in cached_segments if isinstance(item, dict)] + appended_segments
redis_client.set(
MOBILEDE_RUNTIME_SEGMENTS_CACHE_KEY,
json.dumps(updated_segments, ensure_ascii=False),
ex=24 * 60 * 60,
)
redis_client.set(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY, str(len(updated_segments)))
done_segments = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY) or 0)
if done_segments < len(updated_segments):
redis_client.delete(MOBILEDE_BOOTSTRAP_DONE_KEY)
committed = True
logger.info(
"mobile.de overflow split appended: parent=%s observed=%s threshold=%s added=%s total_segments=%s",
_mobilede_short_segment_label(segment),
observed,
threshold,
len(appended_segments),
len(updated_segments),
)
return len(appended_segments)
except Exception:
logger.warning(
"Failed to append mobile.de overflow segments: parent=%s",
_mobilede_segment_label(segment),
exc_info=True,
)
return 0
finally:
if not committed:
try:
redis_client.srem(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY, parent_fingerprint)
except Exception:
logger.debug("Failed to rollback overflow parent marker", exc_info=True)
_release_lock_if_owner(redis_client, MOBILEDE_RUNTIME_SEGMENTS_CACHE_LOCK_KEY, lock_owner)
def _mobilede_probe_total(search_url: str, **params: str | int | None) -> int | None:
try:
client = MobileDeClient.for_worker(delay_seconds=0)
page = client.fetch_search_page(page_number=1, search_url=search_url, **params)
return int(page.total_results or 0)
except Exception as exc:
logger.warning("mobile.de segment probe failed: params=%s error=%s", params, exc)
return None
def _mobilede_segment_pages_for_total(total_results: int | None, fallback_max_pages: int) -> int:
if total_results is None or total_results <= 0:
return min(fallback_max_pages, MOBILEDE_MAX_PAGE_NUMBER)
pages = max(1, min(MOBILEDE_MAX_PAGE_NUMBER, (int(total_results) + MOBILEDE_RESULTS_PER_PAGE - 1) // MOBILEDE_RESULTS_PER_PAGE))
return min(fallback_max_pages, pages)
def _mobilede_make_expanded_segment(
base_segment: dict[str, object],
*,
search_url: str,
base_label: str,
label_parts: list[str],
params: dict[str, str | int | None],
total_results: int | None,
fallback_max_pages: int,
) -> dict[str, object]:
item = dict(base_segment)
item["search_url"] = _mobilede_make_segment_url(search_url, **params)
item["listing_url"] = item["search_url"]
item["start_page"] = 1
item["max_pages"] = _mobilede_segment_pages_for_total(total_results, fallback_max_pages)
item["total_results"] = total_results
item["label"] = f"{base_label} | {' | '.join(label_parts)} | total~{total_results if total_results is not None else '?'}"
return item
def _mobilede_set_segment_range_fields(
item: dict[str, object],
*,
price_min: int,
price_max: int | None,
year_range: tuple[int | None, int | None] | None = None,
mileage_range: tuple[int | None, int | None] | None = None,
) -> None:
item["price_min"] = str(price_min)
item["price_max"] = str(price_max) if price_max is not None else None
if year_range is not None:
year_min, year_max = year_range
item["year_min"] = str(year_min) if year_min is not None else None
item["year_max"] = str(year_max) if year_max is not None else None
if mileage_range is not None:
mileage_min, mileage_max = mileage_range
item["mileage_min"] = str(mileage_min) if mileage_min is not None else None
item["mileage_max"] = str(mileage_max) if mileage_max is not None else None
def _mobilede_split_search_url_segment_by_make(segment: dict[str, object]) -> list[dict[str, object]]:
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
if not search_url:
return [segment]
make_tokens = _mobilede_url_query_values(search_url, "ms")
if len(make_tokens) <= 1:
return [segment]
base_label = str(segment.get("label") or "mobile.de filtered URL").strip() or "mobile.de filtered URL"
split_segments: list[dict[str, object]] = []
for make_token in make_tokens:
item = dict(segment)
item["search_url"] = _mobilede_make_segment_url(search_url, ms=make_token)
item["listing_url"] = item["search_url"]
item["start_page"] = int(segment.get("start_page") or 1)
item["make_id"] = make_token
item["label"] = f"{base_label} | ms={make_token}"
split_segments.append(item)
logger.info(
"mobile.de split multi-make search URL into %s make segment(s): %s",
len(split_segments),
", ".join(str(item.get("label") or "") for item in split_segments),
)
return split_segments
def _expand_mobilede_search_url_segment(segment: dict[str, object]) -> list[dict[str, object]]:
search_url = str(segment.get("search_url") or segment.get("listing_url") or "").strip()
if not search_url:
return [segment]
split_by_make = _mobilede_split_search_url_segment_by_make(segment)
if len(split_by_make) > 1:
expanded: list[dict[str, object]] = []
for split_segment in split_by_make:
expanded.extend(_expand_mobilede_search_url_segment(split_segment))
return expanded
# Если пользователь уже задал узкий price/year/mileage range — не размножаем автоматически.
existing_range_keys = {"p", "fr", "ml"}
query_keys = {key for key, _ in parse_qsl(urlsplit(search_url).query, keep_blank_values=True)}
if query_keys & existing_range_keys:
return [segment]
expanded: list[dict[str, object]] = []
base_label = str(segment.get("label") or "mobile.de segmented URL")
max_pages = int(segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER)
for price_min, price_max in _mobilede_price_ranges():
params: dict[str, str | int | None] = {}
if price_max is not None:
params["p"] = f"{price_min}:{price_max}"
else:
params["p"] = f"{price_min}:"
price_label = _mobilede_price_label(price_min, price_max)
price_total = _mobilede_probe_total(search_url, **params) if MOBILEDE_DYNAMIC_SEGMENT_PROBES else None
if _mobilede_should_skip_dynamic_segment(price_total):
continue
if price_total is not None and price_total <= MOBILEDE_SEGMENT_TARGET_RESULTS:
item = _mobilede_make_expanded_segment(
segment,
search_url=search_url,
base_label=base_label,
label_parts=[price_label],
params=params,
total_results=price_total,
fallback_max_pages=max_pages,
)
_mobilede_set_segment_range_fields(item, price_min=price_min, price_max=price_max)
expanded.append(item)
continue
for year_min, year_max in _mobilede_year_ranges_for_price(price_min, price_max):
year_label = _mobilede_year_label(year_min, year_max)
refined_price_ranges = _mobilede_price_subranges_for_hot_year(price_min, price_max, year_min, year_max)
for refined_price_min, refined_price_max in refined_price_ranges:
refined_price_label = _mobilede_price_label(refined_price_min, refined_price_max)
year_params = dict(params)
year_params["p"] = f"{refined_price_min}:{refined_price_max or ''}"
year_params["fr"] = _mobilede_range_value(year_min, year_max)
year_total = _mobilede_probe_total(search_url, **year_params) if MOBILEDE_DYNAMIC_SEGMENT_PROBES else None
if _mobilede_should_skip_dynamic_segment(year_total):
continue
if year_total is not None and year_total <= MOBILEDE_SEGMENT_TARGET_RESULTS:
item = _mobilede_make_expanded_segment(
segment,
search_url=search_url,
base_label=base_label,
label_parts=[refined_price_label, year_label],
params=year_params,
total_results=year_total,
fallback_max_pages=max_pages,
)
_mobilede_set_segment_range_fields(
item,
price_min=refined_price_min,
price_max=refined_price_max,
year_range=(year_min, year_max),
)
expanded.append(item)
continue
if (
not MOBILEDE_DYNAMIC_SEGMENT_PROBES
and not MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE
and not _mobilede_should_pre_split_mileage(refined_price_min, refined_price_max, year_min, year_max)
):
item = _mobilede_make_expanded_segment(
segment,
search_url=search_url,
base_label=base_label,
label_parts=[refined_price_label, year_label],
params=year_params,
total_results=year_total,
fallback_max_pages=max_pages,
)
_mobilede_set_segment_range_fields(
item,
price_min=refined_price_min,
price_max=refined_price_max,
year_range=(year_min, year_max),
)
expanded.append(item)
continue
for mileage_min, mileage_max in _mobilede_mileage_ranges():
mileage_params = dict(year_params)
mileage_params["ml"] = _mobilede_range_value(mileage_min, mileage_max)
mileage_label = _mobilede_mileage_label(mileage_min, mileage_max)
mileage_total = _mobilede_probe_total(search_url, **mileage_params) if MOBILEDE_DYNAMIC_SEGMENT_PROBES else None
if _mobilede_should_skip_dynamic_segment(mileage_total):
continue
item = _mobilede_make_expanded_segment(
segment,
search_url=search_url,
base_label=base_label,
label_parts=[refined_price_label, year_label, mileage_label],
params=mileage_params,
total_results=mileage_total,
fallback_max_pages=max_pages,
)
_mobilede_set_segment_range_fields(
item,
price_min=refined_price_min,
price_max=refined_price_max,
year_range=(year_min, year_max),
mileage_range=(mileage_min, mileage_max),
)
expanded.append(item)
return expanded
def _build_mobilede_runtime_segments(settings: Settings) -> list[dict[str, object]]:
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
segments = [segment.to_task_kwargs() for segment in runtime_config.mobilede.segments]
expanded: list[dict[str, object]] = []
for segment in segments:
if segment.get("auto_segment") is False:
expanded.append(segment)
continue
expanded.extend(_expand_mobilede_search_url_segment(segment))
if len(expanded) != len(segments):
logger.info("mobile.de segments planned: input=%s total=%s", len(segments), len(expanded))
return expanded
def _get_cached_mobilede_runtime_segments(redis_client: Redis) -> list[dict[str, object]] | None:
try:
cached_raw = redis_client.get(MOBILEDE_RUNTIME_SEGMENTS_CACHE_KEY)
if not cached_raw:
return None
cached = json.loads(cached_raw)
if isinstance(cached, list):
return [dict(item) for item in cached if isinstance(item, dict)]
except Exception:
logger.debug("Failed to read cached mobile.de runtime segments", exc_info=True)
return None
def _mobilede_load_segments_for_reservation(redis_client: Redis, settings: Settings) -> list[dict[str, object]]:
segments = _get_cached_mobilede_runtime_segments(redis_client)
if segments:
return segments
_request_mobilede_runtime_segments_rebuild(redis_client)
if MOBILEDE_DYNAMIC_SEGMENT_PROBES:
return []
return _build_mobilede_runtime_segments(settings)
def _request_mobilede_runtime_segments_rebuild(redis_client: Redis) -> None:
try:
redis_client.set(MOBILEDE_RUNTIME_SEGMENTS_PENDING_KEY, "1", ex=15 * 60)
except Exception:
logger.debug("Failed to request mobile.de runtime segment rebuild", exc_info=True)
def _try_queue_mobilede_incremental_transition(
redis_client: Redis,
*,
lane: str,
delay_seconds: float,
use_cursor: bool,
ttl_seconds: int = 15 * 60,
) -> bool:
try:
if not redis_client.set(MOBILEDE_BOOTSTRAP_INCREMENTAL_TRANSITION_KEY, "1", nx=True, ex=max(60, int(ttl_seconds))):
return False
mobilede_sync_runtime_segments_task.apply_async(
kwargs={
"lane": lane,
"delay_seconds": delay_seconds,
"use_cursor": use_cursor,
"continuous": True,
},
queue=MOBILEDE_SYNC_QUEUE,
countdown=max(0, int(MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS)),
)
return True
except Exception:
logger.debug("Failed to queue mobile.de bootstrap->incremental transition", exc_info=True)
try:
redis_client.delete(MOBILEDE_BOOTSTRAP_INCREMENTAL_TRANSITION_KEY)
except Exception:
logger.debug("Failed to clear mobile.de bootstrap->incremental transition marker", exc_info=True)
return False
def _mobilede_bootstrap_done(redis_client: Redis) -> bool:
if not MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED:
return True
done = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY) or 0)
total = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY) or 0)
if total > 0:
is_done = done >= total
if not is_done and redis_client.get(MOBILEDE_BOOTSTRAP_DONE_KEY):
redis_client.delete(MOBILEDE_BOOTSTRAP_DONE_KEY)
if not is_done and redis_client.get(MOBILEDE_BOOTSTRAP_INCREMENTAL_TRANSITION_KEY):
redis_client.delete(MOBILEDE_BOOTSTRAP_INCREMENTAL_TRANSITION_KEY)
return is_done
return bool(redis_client.get(MOBILEDE_BOOTSTRAP_DONE_KEY))
def _mobilede_force_full_scan_only_new(
only_new: bool | None,
*,
redis_client: Redis | None = None,
) -> bool | None:
"""Принудительный full-pass включён только до завершения bootstrap."""
if only_new is True and redis_client is not None and _mobilede_bootstrap_done(redis_client):
return True
if only_new is True:
return False
return only_new
def _mobilede_segment_scan_complete(redis_client: Redis, segment: dict[str, object] | None) -> bool:
if not segment:
return False
cursor_raw = redis_client.get(_mobilede_cursor_key(segment))
segment_max_pages = int(segment.get("max_pages") or MOBILEDE_MAX_PAGE_NUMBER)
return cursor_raw is not None and int(cursor_raw) >= segment_max_pages
def _mobilede_bootstrap_segment_done(redis_client: Redis, segment: dict[str, object] | None) -> bool:
if not MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED or not segment:
return False
return bool(redis_client.get(f"mobilede:state:bootstrap_segment_done:{_mobilede_segment_fingerprint(segment)}"))
def _mark_mobilede_bootstrap_segment_done(
redis_client: Redis,
segment: dict[str, object] | None,
total_segments: int | None,
*,
listings: int = 0,
unique: int = 0,
inserted: int = 0,
updated: int = 0,
images: int = 0,
) -> tuple[int, int, int, int, int, int, int] | None:
if not MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED or not segment:
return None
segment_done_key = f"mobilede:state:bootstrap_segment_done:{_mobilede_segment_fingerprint(segment)}"
try:
if total_segments is not None:
redis_client.set(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY, str(int(total_segments)))
if redis_client.setnx(segment_done_key, "1"):
redis_client.expire(segment_done_key, 30 * 24 * 60 * 60)
done = int(redis_client.incr(MOBILEDE_BOOTSTRAP_SEGMENTS_DONE_KEY))
total = int(redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY) or total_segments or 0)
total_listings = int(redis_client.incrby(MOBILEDE_BOOTSTRAP_LISTINGS_TOTAL_KEY, max(0, int(listings))))
total_unique = int(redis_client.incrby(MOBILEDE_BOOTSTRAP_UNIQUE_TOTAL_KEY, max(0, int(unique))))
total_inserted = int(redis_client.incrby(MOBILEDE_BOOTSTRAP_INSERTED_TOTAL_KEY, max(0, int(inserted))))
total_updated = int(redis_client.incrby(MOBILEDE_BOOTSTRAP_UPDATED_TOTAL_KEY, max(0, int(updated))))
total_images = int(redis_client.incrby(MOBILEDE_BOOTSTRAP_IMAGES_TOTAL_KEY, max(0, int(images))))
left = max(0, total - done) if total > 0 else 0
percent = _mobilede_bootstrap_percent(done, total)
logger.info(
"mobile.de progress: segment %s/%s done (left=%s, %.1f%%) | segment_cars: listings=%s unique=%s inserted=%s updated=%s images=%s | total_cars: listings=%s unique=%s inserted=%s updated=%s images=%s | segment=%s",
done,
total,
left,
percent,
int(listings),
int(unique),
int(inserted),
int(updated),
int(images),
total_listings,
total_unique,
total_inserted,
total_updated,
total_images,
_mobilede_short_segment_label(segment),
)
if total > 0 and done >= total:
redis_client.set(MOBILEDE_BOOTSTRAP_DONE_KEY, "1")
logger.info(
"mobile.de bootstrap full scan completed: segments=%s/%s total_cars: listings=%s unique=%s inserted=%s updated=%s images=%s",
done,
total,
total_listings,
total_unique,
total_inserted,
total_updated,
total_images,
)
return done, total, left, total_listings, total_unique, total_inserted, total_updated
except Exception:
logger.debug("Failed to mark mobile.de bootstrap segment complete", exc_info=True)
return None
def _reserve_next_mobilede_runtime_segment(
redis_client: Redis,
settings: Settings,
*,
only_new: bool | None = None,
) -> tuple[int, dict[str, object]] | None:
reserved = _reserve_mobilede_runtime_segment(redis_client, settings, only_new=only_new)
if reserved is None:
return None
segment_index, segment = reserved
return segment_index + 1, segment
def _enqueue_mobilede_runtime_segments(
*,
lane: str,
delay_seconds: float,
use_cursor: bool,
continuous: bool,
) -> list[dict[str, object]]:
settings = Settings()
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
redis_client = _get_redis()
only_new = _mobilede_force_full_scan_only_new(runtime_config.sync.only_new, redis_client=redis_client)
full_pass_mode = only_new is not True
effective_use_cursor = use_cursor if only_new is True else False
if runtime_config.sync.only_new is True and only_new is False:
logger.info("mobile.de full-pass mode: forcing only_new=False")
segments = _mobilede_load_segments_for_reservation(redis_client, settings)
if not segments:
return []
try:
redis_client.set(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY, str(len(segments)))
except Exception:
logger.debug("Failed to initialize mobile.de bootstrap segment total", exc_info=True)
initial_reservations: list[tuple[int, dict[str, object]]] = []
dispatch_count = len(segments) if full_pass_mode else min(MOBILEDE_RUNTIME_INITIAL_TASKS, len(segments))
if full_pass_mode:
logger.info(
"mobile.de full-pass dispatch: queueing all segments=%s use_cursor=%s",
len(segments),
effective_use_cursor,
)
for _ in range(dispatch_count):
reservation = _reserve_next_mobilede_runtime_segment(redis_client, settings, only_new=only_new)
if reservation is None:
break
initial_reservations.append(reservation)
for index, segment in initial_reservations:
mobilede_sync_search_task.apply_async(
kwargs={
"start_page": int(segment.get("start_page") or 1),
"max_pages": int(segment.get("max_pages") or 5),
"lane": lane,
"delay_seconds": delay_seconds,
"use_cursor": effective_use_cursor,
"continuous": continuous,
"segment": segment,
"segment_index": index,
"runtime_rotation": True,
},
queue=MOBILEDE_SYNC_QUEUE,
)
return segments
def _reserve_mobilede_runtime_segment(
redis_client: Redis,
settings: Settings,
*,
only_new: bool | None = None,
) -> tuple[int, dict[str, object]] | None:
segments = _mobilede_load_segments_for_reservation(redis_client, settings)
if not segments:
return None
hot_only_active = bool(MOBILEDE_ONLY_NEW_HOT_ONLY and only_new is True)
has_hot_segments = _mobilede_has_hot_segments(redis_client, segments) if hot_only_active else False
skip_completed_bootstrap = MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED and not _mobilede_bootstrap_done(redis_client)
bootstrap_dispatch_dedupe = skip_completed_bootstrap and only_new is not True
def _try_reserve(*, require_hot: bool, respect_cooldown: bool) -> tuple[int, dict[str, object]] | None:
for _ in range(len(segments)):
next_index = int(redis_client.incr(MOBILEDE_RUNTIME_SEGMENT_INDEX_KEY)) - 1
segment_index = next_index % len(segments)
segment = segments[segment_index]
if skip_completed_bootstrap and _mobilede_bootstrap_segment_done(redis_client, segment):
continue
if bootstrap_dispatch_dedupe and not _mobilede_try_mark_bootstrap_segment_dispatched(redis_client, segment):
continue
if respect_cooldown and _mobilede_segment_in_cooldown(redis_client, segment):
continue
if require_hot and hot_only_active and has_hot_segments and not _mobilede_segment_is_hot(redis_client, segment):
continue
return segment_index, segment
return None
reserved = _try_reserve(require_hot=True, respect_cooldown=True)
if reserved is not None:
return reserved
reserved = _try_reserve(require_hot=False, respect_cooldown=True)
if reserved is not None:
return reserved
reserved = _try_reserve(require_hot=False, respect_cooldown=False)
if reserved is not None:
return reserved
return None
def _reserve_mobilede_page_window(
redis_client: Redis,
*,
requested_start_page: int,
page_window_size: int,
use_cursor: bool,
cursor_key: str,
) -> tuple[int, int]:
page_window_size = max(1, int(page_window_size))
requested_start_page = max(1, int(requested_start_page))
if not use_cursor:
return requested_start_page, requested_start_page + page_window_size - 1
redis_client.setnx(cursor_key, str(requested_start_page - 1))
window_end = int(redis_client.incrby(cursor_key, page_window_size))
window_start = max(1, window_end - page_window_size + 1)
return window_start, window_end
def _reset_mobilede_page_cursor(redis_client: Redis, *, cursor_key: str, next_start_page: int = 1) -> None:
redis_client.set(cursor_key, str(max(0, int(next_start_page) - 1)))
_persistence_instance: PersistenceService | None = None
def _get_persistence() -> PersistenceService:
global _persistence_instance
if _persistence_instance is not None:
return _persistence_instance
sett = Settings()
persistence = PersistenceService(sett)
def _ping_db() -> None:
with persistence.engine.connect() as conn:
conn.exec_driver_sql("SELECT 1")
_retry_with_backoff(_ping_db, attempts=5, base_delay_s=1.0)
_persistence_instance = persistence
return persistence
_redis_instance: Redis | None = None
def _get_redis() -> Redis:
global _redis_instance
if _redis_instance is not None:
try:
_redis_instance.ping()
return _redis_instance
except Exception:
_redis_instance = None
sett = Settings()
redis_client = Redis.from_url(
sett.redis.url,
decode_responses=True,
socket_connect_timeout=sett.redis.socket_connect_timeout_seconds,
socket_timeout=sett.redis.socket_timeout_seconds,
health_check_interval=sett.redis.health_check_interval_seconds,
retry_on_timeout=True,
)
def _ping_redis() -> None:
redis_client.ping()
_retry_with_backoff(_ping_redis, attempts=5, base_delay_s=1.0)
_redis_instance = redis_client
return redis_client
def _acquire_lock(redis_client: Redis, key: str, owner_token: str, ttl_seconds: int) -> bool:
try:
acquired = bool(redis_client.set(key, owner_token, nx=True, ex=ttl_seconds))
if acquired:
return True
# Автовосстановление: если lock завис без TTL, считаем stale и пересоздаём.
ttl = redis_client.ttl(key)
if ttl is not None and ttl < 0:
logger.warning("Detected stale lock without TTL, removing: %s", key)
redis_client.delete(key)
return bool(redis_client.set(key, owner_token, nx=True, ex=ttl_seconds))
return False
except Exception as exc:
logger.warning("Failed to acquire lock %s", key, exc_info=True)
return False
def _refresh_lock_if_owner(redis_client: Redis, key: str, owner_token: str, ttl_seconds: int) -> bool | None:
try:
refreshed = redis_client.eval(
"""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
end
return 0
""",
1,
key,
owner_token,
int(ttl_seconds),
)
return bool(refreshed)
except Exception as exc:
logger.warning("Failed to refresh lock %s", key, exc_info=True)
return None
def _release_lock_if_owner(redis_client: Redis, key: str, owner_token: str) -> None:
try:
redis_client.eval(
"""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""",
1,
key,
owner_token,
)
except Exception as exc:
logger.warning("Failed to release lock %s", key, exc_info=True)
def _has_running_sync_listing_tasks(celery_app, *, exclude_task_id: str | None = None) -> bool:
try:
inspector = celery_app.control.inspect(timeout=1.0)
snapshots = [
inspector.active() or {},
inspector.reserved() or {},
inspector.scheduled() or {},
]
except Exception:
logger.warning("Failed to inspect Celery workers for running sync tasks", exc_info=True)
return True
for snapshot in snapshots:
for entries in snapshot.values():
for entry in entries or []:
task_name = str(entry.get("name") or entry.get("request", {}).get("name") or "")
if task_name != SYNC_LISTING_TASK_NAME:
continue
# Исключаем текущую задачу — она не считается "другой запущенной"
entry_id = str(entry.get("id") or entry.get("request", {}).get("id") or "")
if exclude_task_id and entry_id == exclude_task_id:
continue
return True
return False
def _clear_orphan_sync_listing_lock(redis_client: Redis, celery_app, *, current_task_id: str | None = None) -> bool:
try:
owner_token = redis_client.get(SYNC_LISTING_LOCK_KEY)
if not owner_token:
return False
except Exception:
logger.warning("Failed to read sync listing lock before cleanup", exc_info=True)
return False
if _has_running_sync_listing_tasks(celery_app, exclude_task_id=current_task_id):
logger.info("sync_listing lock preserved: active task still detected")
return False
try:
ttl = redis_client.ttl(SYNC_LISTING_LOCK_KEY)
redis_client.delete(SYNC_LISTING_LOCK_KEY)
logger.warning(
"Removed orphan sync_listing lock owner=%s ttl=%s after worker restart",
owner_token,
ttl,
)
return True
except Exception:
logger.warning("Failed to clear orphan sync listing lock", exc_info=True)
return False
def _is_full_scan_done(redis_client: Redis) -> bool:
try:
value = redis_client.get(SYNC_FULL_SCAN_DONE_KEY)
except Exception:
logger.warning("Failed to read full scan state", exc_info=True)
return False
return str(value or "").strip() == "1"
def _set_full_scan_done(redis_client: Redis, done: bool) -> None:
try:
redis_client.set(SYNC_FULL_SCAN_DONE_KEY, "1" if done else "0")
except Exception:
logger.warning("Failed to persist full scan state", exc_info=True)
def _load_last_completed_segment(redis_client: Redis) -> int | None:
# Segment-only checkpoint: хранит индекс последнего ПОЛНОСТЬЮ пройденного сегмента.
try:
raw = redis_client.get(SYNC_LISTING_CHECKPOINT_KEY)
except Exception:
logger.warning("Failed to read sync listing checkpoint", exc_info=True)
return None
if raw is None:
return None
try:
value = int(str(raw).strip())
except (TypeError, ValueError):
logger.warning("Invalid sync listing checkpoint value %r; clearing", raw)
_clear_sync_checkpoint(redis_client)
return None
if value < 0:
_clear_sync_checkpoint(redis_client)
return None
return value
def _save_last_completed_segment(redis_client: Redis, segment_index: int) -> None:
try:
redis_client.set(
SYNC_LISTING_CHECKPOINT_KEY,
str(int(segment_index)),
ex=SYNC_LISTING_CHECKPOINT_TTL_SECONDS,
)
except Exception:
logger.warning("Failed to save sync listing checkpoint", exc_info=True)
def _restart_bootstrap_from_first_segment(redis_client: Redis, *, reason: str) -> None:
"""Clear bootstrap checkpoint so the next full scan starts from segment 1."""
try:
pipe = redis_client.pipeline()
pipe.delete(SYNC_LISTING_CHECKPOINT_KEY)
pipe.delete(SYNC_LISTING_FOLLOWUP_PENDING_KEY)
pipe.delete(GLOBAL_PROGRESS_TS_KEY)
pipe.delete(GLOBAL_DB_PROGRESS_TS_KEY)
pipe.set(SYNC_FULL_SCAN_DONE_KEY, "0")
pipe.execute()
logger.error("Bootstrap restart requested from segment 1: %s", reason)
except Exception:
logger.warning("Failed to reset bootstrap checkpoint for DB-idle restart", exc_info=True)
def _clear_sync_checkpoint(redis_client: Redis) -> None:
try:
redis_client.delete(SYNC_LISTING_CHECKPOINT_KEY)
except Exception:
logger.warning("Failed to clear sync listing checkpoint", exc_info=True)
def _try_set_followup_pending(redis_client: Redis, *, ttl_seconds: int) -> bool:
try:
return bool(redis_client.set(SYNC_LISTING_FOLLOWUP_PENDING_KEY, "1", nx=True, ex=max(60, int(ttl_seconds))))
except Exception:
logger.warning("Failed to set follow-up pending flag", exc_info=True)
return True
def _clear_followup_pending(redis_client: Redis) -> None:
try:
redis_client.delete(SYNC_LISTING_FOLLOWUP_PENDING_KEY)
except Exception:
logger.warning("Failed to clear follow-up pending flag", exc_info=True)
def _bump_bootstrap_failure_streak(
redis_client: Redis,
*,
reason: str,
) -> tuple[int, bool]:
try:
streak = int(redis_client.incr(SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY))
redis_client.expire(
SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY,
SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_TTL_SECONDS,
)
except Exception:
logger.warning("Failed to update bootstrap failure streak", exc_info=True)
return 0, True
should_enqueue = streak < SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT
if should_enqueue:
logger.warning(
"Bootstrap failure streak %d/%d recorded (reason=%s)",
streak,
SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT,
reason,
)
else:
logger.error(
"Bootstrap follow-up circuit breaker opened after %d consecutive failures (reason=%s)",
streak,
reason,
)
return streak, should_enqueue
def _clear_bootstrap_failure_streak(redis_client: Redis) -> None:
try:
redis_client.delete(SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_KEY)
except Exception:
logger.warning("Failed to clear bootstrap failure streak", exc_info=True)
def _bump_bootstrap_continuation_streak(redis_client: Redis) -> tuple[int, bool]:
"""Счётчик подряд идущих bootstrap-followup запусков.
Возвращает (streak, should_continue). Если should_continue=False,
немедленные continuation блокируются до следующего beat-цикла.
"""
try:
streak = int(redis_client.incr(SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_KEY))
redis_client.expire(
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_KEY,
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS,
)
except Exception:
logger.warning("Failed to update bootstrap continuation streak", exc_info=True)
return 0, True
should_continue = streak <= SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT
if not should_continue:
logger.error(
"Bootstrap continuation breaker OPEN: streak=%d limit=%d",
streak,
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT,
)
return streak, should_continue
def _clear_bootstrap_continuation_streak(redis_client: Redis) -> None:
try:
redis_client.delete(SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_KEY)
except Exception:
logger.warning("Failed to clear bootstrap continuation streak", exc_info=True)
def _bump_hourly_failure_streak(redis_client: Redis) -> int:
"""Инкрементирует счётчик ошибок hourly. Возвращает новое значение."""
try:
streak = int(redis_client.incr(HOURLY_FAILURE_STREAK_KEY))
redis_client.expire(HOURLY_FAILURE_STREAK_KEY, HOURLY_FAILURE_STREAK_TTL_SECONDS)
return streak
except Exception:
logger.warning("Failed to update hourly failure streak", exc_info=True)
return 0
def _check_hourly_circuit_breaker(redis_client: Redis) -> tuple[int, bool]:
"""Проверяет открыт ли circuit breaker. Возвращает (streak, is_open)."""
try:
raw = redis_client.get(HOURLY_FAILURE_STREAK_KEY)
streak = int(raw) if raw else 0
except Exception:
return 0, False
is_open = streak >= HOURLY_FAILURE_STREAK_LIMIT
if is_open:
logger.error("Hourly circuit breaker OPEN (%d/%d failures) — skipping", streak, HOURLY_FAILURE_STREAK_LIMIT)
return streak, is_open
def _clear_hourly_failure_streak(redis_client: Redis) -> None:
try:
redis_client.delete(HOURLY_FAILURE_STREAK_KEY)
except Exception:
pass
def _start_lock_heartbeat(
redis_client: Redis,
key: str,
owner_token: str,
ttl_seconds: int,
stop_on_lost: bool = True,
) -> tuple[Event, Thread]:
stop_event = Event()
interval_seconds = max(5.0, min(30.0, ttl_seconds / 3))
def _heartbeat() -> None:
consecutive_failures = 0
while not stop_event.wait(interval_seconds):
refreshed = _refresh_lock_if_owner(redis_client, key, owner_token, ttl_seconds)
if refreshed is False:
logger.warning("Lost sync_listing lock ownership for %s", owner_token)
if stop_on_lost:
return
consecutive_failures += 1
continue
if refreshed is None:
consecutive_failures += 1
if consecutive_failures >= 5:
logger.error("Lock heartbeat failed %d times in a row for %s; giving up", consecutive_failures, owner_token)
return
else:
consecutive_failures = 0
thread = Thread(target=_heartbeat, name="sync-listing-lock-heartbeat", daemon=True)
thread.start()
return stop_event, thread
def _reset_segments_progress(redis_client: Redis, total: int) -> None:
try:
pipe = redis_client.pipeline()
pipe.delete(SYNC_SEGMENTS_PROGRESS_KEY)
pipe.set(SYNC_SEGMENTS_TOTAL_KEY, str(int(total)), ex=SYNC_SEGMENTS_PROGRESS_TTL_SECONDS)
pipe.execute()
except Exception:
logger.warning("Failed to reset segments progress", exc_info=True)
def _mark_segment_completed(redis_client: Redis, segment_index: int) -> tuple[int, int]:
"""Помечает сегмент завершённым. Возвращает (completed_count, total)."""
try:
pipe = redis_client.pipeline()
pipe.sadd(SYNC_SEGMENTS_PROGRESS_KEY, str(int(segment_index)))
pipe.expire(SYNC_SEGMENTS_PROGRESS_KEY, SYNC_SEGMENTS_PROGRESS_TTL_SECONDS)
pipe.scard(SYNC_SEGMENTS_PROGRESS_KEY)
pipe.get(SYNC_SEGMENTS_TOTAL_KEY)
results = pipe.execute()
completed = int(results[2] or 0)
total = int(results[3] or 0) if results[3] else 0
return completed, total
except Exception:
logger.warning("Failed to mark segment %d completed", segment_index, exc_info=True)
return 0, 0
def _build_listing_segments(settings: Settings) -> list[dict[str, str | int | None]]:
"""Возвращает сегменты листинга с учётом runtime_config.
При IAAI_LISTING_SEGMENTS=runtime сегменты строятся из filters.brands / filters.include.brands.
Остальные значения IAAI_LISTING_SEGMENTS сохраняют прежнее поведение: auto или JSON.
"""
filtered_urls = settings.listing.filtered_search_urls
if len(filtered_urls) > 1:
return [
{"make": None, "year_min": None, "year_max": None, "listing_url": url}
for url in filtered_urls
]
if len(filtered_urls) == 1:
return []
raw_segments = settings.listing.listing_segments_json.strip()
if raw_segments.casefold() != "runtime":
return parse_listing_segments(raw_segments)
parsed_override = parse_listing_segments(raw_segments)
if parsed_override:
return parsed_override
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
brands = runtime_config.filters.include.brands
if settings.scraping_profile.http_first and settings.listing.fast_segment_year_splits:
segments = build_fast_listing_segments_for_makes(list(brands))
else:
segments = build_listing_segments_for_makes(list(brands))
if not segments:
logger.warning(
"IAAI_LISTING_SEGMENTS=runtime, but runtime_config filters.brands is empty; segmented listing disabled"
)
return segments
@shared_task(
name="iaai_scraper.worker.tasks.sync_segment_task",
queue=IAAI_SYNC_QUEUE,
bind=True,
max_retries=2,
default_retry_delay=60,
acks_late=True,
)
def sync_segment_task(
self,
segment_index: int,
segment: dict,
lane: str = "iaai_cars",
only_new: bool | None = None,
is_bootstrap: bool = False,
):
"""Обработка одного сегмента листинга. Запускается параллельно несколькими воркерами."""
persistence = _get_persistence()
persistence.create_tables()
task_id = self.request.id or "unknown"
redis_client = _get_redis()
settings = Settings()
# Per-segment lock — защита от случайного дубля
seg_lock_key = SYNC_SEGMENT_LOCK_KEY_FMT.format(idx=int(segment_index))
owner_token = f"{task_id}:{uuid.uuid4().hex}"
lock_ttl = _sync_segment_lock_ttl_seconds()
lock_acquired = _acquire_lock(redis_client, seg_lock_key, owner_token, lock_ttl)
if not lock_acquired:
logger.info("sync_segment_task[%d] skipped: already running", segment_index)
return {"status": "skipped", "segment_index": segment_index, "reason": "duplicate"}
seg_make = segment.get("make")
seg_year_min = segment.get("year_min")
seg_year_max = segment.get("year_max")
seg_listing_url = segment.get("listing_url")
seg_label = f"{seg_make or 'ALL'}"
if seg_listing_url:
seg_label = "FILTERED_SEARCH"
if seg_year_min is not None or seg_year_max is not None:
seg_label += f" ({seg_year_min}-{seg_year_max})"
heartbeat_stop: Event | None = None
heartbeat_thread: Thread | None = None
watchdog_stop: Event | None = None
watchdog_thread: Thread | None = None
stall_timeout = max(120, int(settings.celery.task_stall_timeout_seconds))
progress_ttl = max(lock_ttl + 120, stall_timeout + 120)
_update_task_progress(
redis_client,
task_id=task_id,
stage="segment_task_started",
ttl_seconds=progress_ttl,
segment_index=segment_index,
segment_label=seg_label,
)
heartbeat_stop, heartbeat_thread = _start_lock_heartbeat(redis_client, seg_lock_key, owner_token, lock_ttl)
watchdog_stop, watchdog_thread = _start_stall_watchdog(
redis_client,
task_id=task_id,
stall_timeout_seconds=stall_timeout,
lock_key=seg_lock_key,
lock_owner=owner_token,
)
try:
def _job():
with IAAIScraper() as scraper:
scraper.set_progress_callback(
lambda stage, meta: _update_task_progress(
redis_client,
task_id=task_id,
stage=stage,
ttl_seconds=progress_ttl,
segment_index=segment_index,
segment_label=seg_label,
**meta,
)
)
return scraper.sync_listing(
make=seg_make,
model=None,
lane=lane,
only_new=only_new,
listing_url=str(seg_listing_url) if seg_listing_url else None,
year_min=seg_year_min,
year_max=seg_year_max,
skip_mark_sold=True,
)
result = _run_browser_job(_job)
segment_done = bool(result.get("full_scan_completed", False))
_update_task_progress(
redis_client,
task_id=task_id,
stage="segment_task_completed",
ttl_seconds=progress_ttl,
segment_index=segment_index,
segment_label=seg_label,
status=result.get("status", "success"),
cars_upserted=result.get("cars_upserted", 0),
cars_failed=result.get("cars_failed", 0),
)
if is_bootstrap and segment_done:
completed, total = _mark_segment_completed(redis_client, segment_index)
logger.warning(
"Segment %d (%s) bootstrap done: %d/%d completed",
segment_index, seg_label, completed, total,
)
if total > 0 and completed >= total:
_set_full_scan_done(redis_client, True)
_clear_sync_checkpoint(redis_client)
_clear_bootstrap_failure_streak(redis_client)
logger.warning("All %d segments completed; bootstrap full scan done", total)
return {
"status": result.get("status", "success"),
"segment_index": segment_index,
"segment_label": seg_label,
"cars_upserted": result.get("cars_upserted", 0),
"cars_failed": result.get("cars_failed", 0),
"vehicles_collected": result.get("listing", {}).get("vehicles_collected", 0),
"full_scan_completed": segment_done,
}
except SoftTimeLimitExceeded:
logger.warning("sync_segment_task[%d] soft timeout — partial progress saved", segment_index)
_update_task_progress(
redis_client,
task_id=task_id,
stage="segment_task_soft_timeout",
ttl_seconds=progress_ttl,
segment_index=segment_index,
segment_label=seg_label,
)
return {
"status": "timed_out",
"segment_index": segment_index,
"segment_label": seg_label,
}
except Exception as exc:
logger.error("sync_segment_task[%d] failed: %s%s", segment_index, seg_label, exc, exc_info=True)
_update_task_progress(
redis_client,
task_id=task_id,
stage="segment_task_failed",
ttl_seconds=progress_ttl,
segment_index=segment_index,
segment_label=seg_label,
error=str(exc),
)
try:
raise self.retry(exc=exc)
except self.MaxRetriesExceededError:
return {
"status": "failed",
"segment_index": segment_index,
"segment_label": seg_label,
"error": str(exc),
}
finally:
if watchdog_stop is not None:
watchdog_stop.set()
if watchdog_thread is not None:
watchdog_thread.join(timeout=1)
if heartbeat_stop is not None:
heartbeat_stop.set()
if heartbeat_thread is not None:
heartbeat_thread.join(timeout=1)
_clear_task_progress(redis_client, task_id)
_release_lock_if_owner(redis_client, seg_lock_key, owner_token)
@shared_task(
name="iaai_scraper.worker.tasks.sync_vehicle_task",
queue=IAAI_SYNC_QUEUE,
bind=True,
max_retries=2,
default_retry_delay=30,
acks_late=True,
)
def sync_vehicle_task(self, vehicle_url: str, lane: str = "iaai"):
# Скрапинг и upsert одного автомобиля.
persistence = _get_persistence()
persistence.create_tables()
try:
def _job():
with IAAIScraper() as scraper:
return scraper.sync_vehicle(vehicle_url, lane=lane)
result = _run_browser_job(_job)
logger.info("sync_vehicle_task completed: %s", vehicle_url)
return {
"status": "success",
"vehicle_url": vehicle_url,
"db_action": result.get("db_action"),
"images_upserted": result.get("images_upserted", 0),
}
except Exception as exc:
logger.error("sync_vehicle_task failed: %s%s", vehicle_url, exc, exc_info=True)
raise self.retry(exc=exc)
@shared_task(
name=MOBILEDE_RUNTIME_SEGMENTS_TASK,
queue=MOBILEDE_SYNC_QUEUE,
bind=True,
max_retries=1,
default_retry_delay=30,
acks_late=True,
)
def mobilede_sync_runtime_segments_task(
self,
lane: str = "mobile_de_cars",
delay_seconds: float = 0.7,
use_cursor: bool = True,
continuous: bool = False,
):
redis_client = _get_redis()
owner_token = self.request.id or uuid.uuid4().hex
if not redis_client.set(MOBILEDE_RUNTIME_SEGMENTS_BUILDING_KEY, owner_token, nx=True, ex=30 * 60):
logger.info("mobile.de runtime segment rebuild already running")
return {"status": "building"}
try:
_mobilede_try_recover_stalled_bootstrap_queue(redis_client)
settings = Settings()
cached_segments = _get_cached_mobilede_runtime_segments(redis_client)
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
full_pass_mode = _mobilede_force_full_scan_only_new(runtime_config.sync.only_new, redis_client=redis_client) is not True
if not cached_segments:
cached_segments = _build_mobilede_runtime_segments(settings)
redis_client.set(MOBILEDE_RUNTIME_SEGMENTS_CACHE_KEY, json.dumps(cached_segments, ensure_ascii=False), ex=24 * 60 * 60)
redis_client.set(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY, str(len(cached_segments)))
redis_client.delete(MOBILEDE_OVERFLOW_EXPANDED_PARENTS_KEY)
redis_client.delete(MOBILEDE_RUNTIME_SEGMENTS_PENDING_KEY)
logger.info("mobile.de runtime segments rebuilt: %s", len(cached_segments))
segments = _enqueue_mobilede_runtime_segments(
lane=lane,
delay_seconds=delay_seconds,
use_cursor=use_cursor,
continuous=continuous,
)
if not segments:
logger.info("mobilede_sync_runtime_segments_task: runtime segments not configured, falling back to generic sync")
mobilede_sync_search_task.apply_async(
kwargs={
"lane": lane,
"delay_seconds": delay_seconds,
"use_cursor": use_cursor,
"continuous": continuous,
},
queue=MOBILEDE_SYNC_QUEUE,
)
return {"status": "fallback", "segments": 0}
logger.info(
"mobile.de queue started: total_segments=%s queued_now=%s remaining_after_initial=%s mode=%s first_segments=%s",
len(segments),
len(segments) if full_pass_mode else min(MOBILEDE_RUNTIME_INITIAL_TASKS, len(segments)),
0 if full_pass_mode else max(0, len(segments) - min(MOBILEDE_RUNTIME_INITIAL_TASKS, len(segments))),
"full-pass" if full_pass_mode else "incremental",
", ".join(_mobilede_short_segment_label(item) for item in segments[: min(5, len(segments))]),
)
return {
"status": "queued",
"segments": len(segments),
"labels": [str(item.get("label") or item.get("make_id") or "segment") for item in segments],
}
except Exception as exc:
logger.error("mobilede_sync_runtime_segments_task failed: %s", exc, exc_info=True)
raise self.retry(exc=exc)
finally:
try:
if redis_client.get(MOBILEDE_RUNTIME_SEGMENTS_BUILDING_KEY) == owner_token:
redis_client.delete(MOBILEDE_RUNTIME_SEGMENTS_BUILDING_KEY)
except Exception:
logger.debug("Failed to release mobile.de runtime segment rebuild lock", exc_info=True)
@shared_task(
name="mobilede.sync_detail",
queue=MOBILEDE_SYNC_QUEUE,
bind=True,
max_retries=2,
default_retry_delay=30,
acks_late=True,
)
def mobilede_sync_detail_task(self, listing_id: str, lane: str = "mobile_de_cars"):
try:
scraper = MobileDeScraper(persistence=_get_persistence())
result = scraper.sync_detail(str(listing_id), lane=lane)
logger.info("mobilede_sync_detail_task completed: %s", listing_id)
return {"status": "success", **result}
except Exception as exc:
logger.error("mobilede_sync_detail_task failed: %s%s", listing_id, exc, exc_info=True)
raise self.retry(exc=exc)
@shared_task(
name="mobilede.sync_search",
queue=MOBILEDE_SYNC_QUEUE,
bind=True,
max_retries=2,
default_retry_delay=60,
acks_late=True,
)
def mobilede_sync_search_task(
self,
start_page: int = 1,
max_pages: int = 5,
lane: str = "mobile_de_cars",
only_new: bool | None = None,
search_url: str | None = None,
make_id: str | None = None,
model_id: str | None = None,
price_min: str | None = None,
price_max: str | None = None,
year_min: str | None = None,
year_max: str | None = None,
mileage_min: str | None = None,
mileage_max: str | None = None,
delay_seconds: float = 0.7,
use_cursor: bool = False,
continuous: bool | None = None,
segment: dict | None = None,
segment_index: int | None = None,
runtime_rotation: bool = False,
):
task_id = self.request.id or "unknown"
redis_client = _get_redis()
settings = Settings()
segment_runtime_key = _mobilede_task_segment_key(
segment=segment,
search_url=search_url,
make_id=make_id,
model_id=model_id,
price_min=price_min,
price_max=price_max,
year_min=year_min,
year_max=year_max,
mileage_min=mileage_min,
mileage_max=mileage_max,
)
segment_lock_key = _mobilede_segment_lock_key(segment_runtime_key)
lock_owner = f"{task_id}:{uuid.uuid4().hex}"
lock_ttl = max(300, _sync_listing_lock_ttl_seconds())
lock_acquired = _acquire_lock(redis_client, segment_lock_key, lock_owner, lock_ttl)
if not lock_acquired:
logger.info("mobile.de sync skipped: segment already running key=%s", segment_runtime_key)
return {"status": "skipped", "reason": "segment_already_running", "segment_key": segment_runtime_key}
heartbeat_stop: Event | None = None
heartbeat_thread: Thread | None = None
watchdog_stop: Event | None = None
watchdog_thread: Thread | None = None
stall_timeout = max(120, int(settings.celery.task_stall_timeout_seconds))
progress_ttl = max(lock_ttl + 120, stall_timeout + 120)
runtime_config = RuntimeConfig.from_file(settings.runtime_config_file)
try:
heartbeat_stop, heartbeat_thread = _start_lock_heartbeat(
redis_client,
segment_lock_key,
lock_owner,
lock_ttl,
)
watchdog_stop, watchdog_thread = _start_stall_watchdog(
redis_client,
task_id=task_id,
stall_timeout_seconds=stall_timeout,
lock_key=segment_lock_key,
lock_owner=lock_owner,
)
_clear_mobilede_followup_pending(redis_client, segment_key=segment_runtime_key)
if only_new is None and runtime_config.sync.only_new is not None:
only_new = runtime_config.sync.only_new
bootstrap_done = _mobilede_bootstrap_done(redis_client)
guarded_only_new = _mobilede_force_full_scan_only_new(only_new, redis_client=redis_client)
if only_new is True and guarded_only_new is False:
logger.info("mobile.de full-pass mode: forcing only_new=False in sync task")
only_new = guarded_only_new
runtime_segments_enabled = False
if segment is None and not make_id and not model_id:
reserved_segment = _reserve_mobilede_runtime_segment(redis_client, settings, only_new=only_new)
if reserved_segment is not None:
segment_index, segment = reserved_segment
runtime_segments_enabled = True
else:
if not redis_client.get(MOBILEDE_RUNTIME_SEGMENTS_BUILDING_KEY):
mobilede_sync_runtime_segments_task.apply_async(
kwargs={
"lane": lane,
"delay_seconds": delay_seconds,
"use_cursor": use_cursor,
"continuous": bool(continuous if continuous is not None else MOBILEDE_CONTINUOUS_SYNC_ENABLED),
},
queue=MOBILEDE_SYNC_QUEUE,
)
logger.info("mobile.de runtime segments are not ready; deferring sync task")
raise self.retry(countdown=30)
elif segment is not None:
runtime_segments_enabled = True
if segment:
search_url = search_url or str(segment.get("search_url") or segment.get("listing_url") or "").strip() or None
make_id = make_id or str(segment.get("make_id") or "").strip() or None
model_id = model_id or str(segment.get("model_id") or "").strip() or None
price_min = price_min or str(segment.get("price_min") or "").strip() or None
price_max = price_max or str(segment.get("price_max") or "").strip() or None
year_min = year_min or str(segment.get("year_min") or "").strip() or None
year_max = year_max or str(segment.get("year_max") or "").strip() or None
mileage_min = mileage_min or str(segment.get("mileage_min") or "").strip() or None
mileage_max = mileage_max or str(segment.get("mileage_max") or "").strip() or None
if segment.get("only_new") is not None:
only_new = bool(segment.get("only_new"))
start_page = int(segment.get("start_page") or start_page or 1)
if segment.get("max_pages") is not None:
max_pages = int(segment.get("max_pages") or max_pages)
if only_new and _mobilede_bootstrap_done(redis_client) and MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP:
start_page = 1
max_pages = min(max_pages, MOBILEDE_INCREMENTAL_PAGE_WINDOW)
elif make_id or model_id:
resolved_segment = _find_mobilede_runtime_segment(
settings,
search_url=search_url,
make_id=make_id,
model_id=model_id,
)
if resolved_segment is not None:
segment = resolved_segment
runtime_segments_enabled = True
elif search_url:
resolved_segment = _find_mobilede_runtime_segment(
settings,
search_url=search_url,
make_id=make_id,
model_id=model_id,
)
if resolved_segment is not None:
segment = resolved_segment
runtime_segments_enabled = True
if only_new and segment and _mobilede_bootstrap_done(redis_client) and MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP:
start_page = 1
max_pages = min(max_pages, MOBILEDE_INCREMENTAL_PAGE_WINDOW)
use_cursor = False
only_new = _mobilede_force_full_scan_only_new(only_new, redis_client=redis_client)
strict_first_pass_mode = _mobilede_is_strict_first_pass_mode(redis_client, segment=segment, only_new=only_new)
cycle_id: str | None = None
if strict_first_pass_mode:
segment, segment_index, cycle_id = _mobilede_reserve_strict_first_pass_segment(
redis_client,
settings,
segment=segment,
segment_index=segment_index,
)
start_page = 1
max_pages = min(max_pages, MOBILEDE_INCREMENTAL_PAGE_WINDOW)
use_cursor = False
sort_by: str | None = None
sort_order: str | None = None
if only_new and MOBILEDE_ONLY_NEW_NEWEST_FIRST:
sort_by = "doc"
sort_order = "down"
search_url = _mobilede_apply_newest_sort_to_url(search_url)
cursor_key = _mobilede_cursor_key(segment)
if strict_first_pass_mode and cycle_id:
cursor_key = _mobilede_cycle_cursor_key(cursor_key, cycle_id)
actual_start_page, actual_end_page = _reserve_mobilede_page_window(
redis_client,
requested_start_page=start_page,
page_window_size=max_pages,
use_cursor=use_cursor,
cursor_key=cursor_key,
)
if use_cursor and MOBILEDE_SKIP_EMPTY_WINDOW and actual_start_page > 100:
_reset_mobilede_page_cursor(redis_client, cursor_key=cursor_key, next_start_page=1)
actual_start_page, actual_end_page = _reserve_mobilede_page_window(
redis_client,
requested_start_page=1,
page_window_size=max_pages,
use_cursor=use_cursor,
cursor_key=cursor_key,
)
logger.info(
"mobile.de cursor wrapped before empty window: runtime=%s pages=%s-%s",
_mobilede_segment_label(segment),
actual_start_page,
actual_end_page,
)
_update_task_progress(
redis_client,
task_id=task_id,
stage="mobilede_sync_started",
ttl_seconds=progress_ttl,
start_page=actual_start_page,
end_page=actual_end_page,
requested_start_page=start_page,
max_pages=max_pages,
use_cursor=use_cursor,
segment_index=segment_index,
segment_label=(segment or {}).get("label") if segment else None,
)
if continuous is None:
continuous = MOBILEDE_CONTINUOUS_SYNC_ENABLED
segment_label = _mobilede_segment_label(segment)
make_name = _mobilede_segment_make(segment, make_id)
model_name = _mobilede_segment_model(segment, model_id)
incremental_progress_mode = bool(strict_first_pass_mode and cycle_id)
if incremental_progress_mode:
progress_cycle_id, progress_done, progress_total, progress_left = _mobilede_incremental_cycle_progress(
redis_client,
cycle_id=cycle_id,
)
progress_scope = f"incremental cycle {progress_cycle_id}"
else:
progress_done, progress_total, progress_left = _mobilede_bootstrap_progress(redis_client)
progress_scope = "bootstrap"
progress_percent = _mobilede_bootstrap_percent(progress_done, progress_total)
total_listings, total_unique, total_inserted, total_updated, _total_images = _mobilede_bootstrap_cars_totals(redis_client)
logger.info(
"mobile.de segment start: segment=%s pages=%s-%s max_pages=%s mode=%s progress=%s/%s left=%s (%.1f%%) total_cars: listings=%s unique=%s inserted=%s updated=%s filter=%s sort=%s:%s",
_mobilede_short_segment_label(segment),
actual_start_page,
actual_end_page,
max_pages,
progress_scope,
progress_done,
progress_total,
progress_left,
progress_percent,
total_listings,
total_unique,
total_inserted,
total_updated,
_mobilede_filter_source(segment, search_url),
sort_by,
sort_order,
)
logger.debug(
"mobilede_sync_search_task started: task_id=%s segment=%s start_page=%s end_page=%s max_pages=%s use_cursor=%s continuous=%s only_new=%s search_url=%s make_id=%s model_id=%s year=%s-%s price=%s-%s mileage=%s-%s",
task_id,
segment_label,
actual_start_page,
actual_end_page,
max_pages,
use_cursor,
continuous,
only_new,
bool(search_url),
make_id,
model_id,
year_min,
year_max,
price_min,
price_max,
mileage_min,
mileage_max,
)
window_pages_collected = 0
def _progress(stage: str, meta: dict[str, object]) -> None:
nonlocal window_pages_collected
_update_task_progress(
redis_client,
task_id=task_id,
stage=stage,
ttl_seconds=3600,
start_page=actual_start_page,
end_page=actual_end_page,
use_cursor=use_cursor,
segment_index=segment_index,
segment_label=(segment or {}).get("label") if segment else None,
**meta,
)
if stage == "search_collection_done":
window_pages_collected = int(meta.get("pages_collected", 0) or 0)
elif stage == "db_upsert_done":
_log_mobilede_progress_threshold(
redis_client,
task_id=task_id,
segment=segment,
delta_pages=window_pages_collected,
delta_cars=int(meta.get("inserted", 0) or 0) + int(meta.get("updated", 0) or 0),
delta_images=int(meta.get("images_upserted", 0) or 0),
start_page=actual_start_page,
end_page=actual_end_page,
)
scraper = MobileDeScraper(
client=MobileDeClient.for_worker(delay_seconds=delay_seconds),
persistence=_get_persistence(),
)
result = scraper.sync_search(
start_page=actual_start_page,
max_pages=max_pages,
lane=lane,
only_new=only_new,
sort_by=sort_by,
sort_order=sort_order,
search_url=search_url,
make_id=make_id,
model_id=model_id,
price_min=price_min,
price_max=price_max,
year_min=year_min,
year_max=year_max,
mileage_min=mileage_min,
mileage_max=mileage_max,
progress_callback=_progress,
)
inserted_count = int(result.get("upsert", {}).get("inserted", 0) or 0)
_mobilede_update_segment_freshness_state(
redis_client,
segment=segment,
only_new=only_new,
inserted=inserted_count,
listings=int(result.get("listing_count", 0) or 0),
)
listing_count = int(result.get("listing_count", 0) or 0)
unique_count = int(result.get("unique_listing_count", 0) or 0)
updated_count = int(result.get("upsert", {}).get("updated", 0) or 0)
images_count = int(result.get("upsert", {}).get("images_upserted", 0) or 0)
bootstrap_progress: tuple[int, int, int, int, int, int, int] | None = None
overflow_segments_added = 0
segment_max_pages = int((segment or {}).get("max_pages") or max_pages or MOBILEDE_MAX_PAGE_NUMBER)
segment_scan_complete = bool(
(use_cursor and _mobilede_segment_scan_complete(redis_client, segment))
or (
not use_cursor
and segment is not None
and actual_start_page <= 1
and actual_end_page >= segment_max_pages
)
)
if segment_scan_complete:
overflow_segments_added = _mobilede_try_expand_overflow_segment(
redis_client,
settings,
segment=segment,
listing_count=listing_count,
unique_count=unique_count,
max_pages=max_pages,
segment_end_page=actual_end_page,
)
if segment_scan_complete:
total_segments_raw = redis_client.get(MOBILEDE_BOOTSTRAP_SEGMENTS_TOTAL_KEY)
total_segments = int(total_segments_raw) if total_segments_raw else None
bootstrap_progress = _mark_mobilede_bootstrap_segment_done(
redis_client,
segment,
total_segments,
listings=listing_count,
unique=unique_count,
inserted=inserted_count,
updated=updated_count,
images=images_count,
)
if bootstrap_progress is not None:
try:
redis_client.srem(MOBILEDE_BOOTSTRAP_DISPATCHED_SEGMENTS_KEY, _mobilede_segment_fingerprint(segment))
except Exception:
logger.debug("Failed to release bootstrap dispatched marker for segment", exc_info=True)
if use_cursor and listing_count == 0:
_reset_mobilede_page_cursor(redis_client, cursor_key=cursor_key, next_start_page=1)
logger.debug(
"mobile.de cursor reset after empty window: task_id=%s segment=%s start_page=%s end_page=%s",
task_id,
(segment or {}).get("label") if segment else None,
actual_start_page,
actual_end_page,
)
_update_task_progress(
redis_client,
task_id=task_id,
stage="sync_done",
ttl_seconds=3600,
cars_upserted=result.get("upsert", {}).get("inserted", 0) + result.get("upsert", {}).get("updated", 0),
listing_count=result.get("listing_count", 0),
start_page=actual_start_page,
end_page=actual_end_page,
use_cursor=use_cursor,
segment_index=segment_index,
segment_label=(segment or {}).get("label") if segment else None,
)
logger.debug(
"mobilede_sync_search_task completed: task_id=%s segment=%s start_page=%s end_page=%s listings=%s inserted=%s updated=%s",
task_id,
segment_label,
actual_start_page,
actual_end_page,
result.get("listing_count"),
result.get("upsert", {}).get("inserted", 0),
result.get("upsert", {}).get("updated", 0),
)
if incremental_progress_mode:
progress_cycle_id, progress_done, progress_total, progress_left = _mobilede_incremental_cycle_progress(
redis_client,
cycle_id=cycle_id,
)
progress_scope = f"incremental cycle {progress_cycle_id}"
else:
progress_done, progress_total, progress_left = (
bootstrap_progress[:3] if bootstrap_progress else _mobilede_bootstrap_progress(redis_client)
)
progress_scope = "bootstrap"
logger.info(
"mobile.de segment result: segment=%s pages=%s-%s cars: listings=%s unique=%s inserted=%s updated=%s images=%s mode=%s progress=%s/%s left=%s run_id=%s overflow_added=%s",
_mobilede_short_segment_label(segment),
actual_start_page,
actual_end_page,
listing_count,
unique_count,
inserted_count,
updated_count,
images_count,
progress_scope,
progress_done,
progress_total,
progress_left,
result.get("run_id"),
overflow_segments_added,
)
if continuous:
progress_done_now, progress_total_now, progress_left_now = _mobilede_bootstrap_progress(redis_client)
incremental_mode = bool(only_new and segment and _mobilede_bootstrap_done(redis_client) and MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP)
if (
MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED
and progress_total_now > 0
and progress_done_now >= progress_total_now
and not incremental_mode
):
should_start_incremental = bool(
runtime_config.sync.only_new is True
and MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP
)
if should_start_incremental:
if _try_queue_mobilede_incremental_transition(
redis_client,
lane=lane,
delay_seconds=delay_seconds,
use_cursor=False,
):
logger.info(
"mobile.de bootstrap->incremental transition queued: bootstrap=%s/%s runtime=%s",
progress_done_now,
progress_total_now,
segment_label,
)
else:
logger.info(
"mobile.de bootstrap->incremental transition already pending: bootstrap=%s/%s runtime=%s",
progress_done_now,
progress_total_now,
segment_label,
)
else:
logger.info(
"mobile.de follow-up stopped: bootstrap completed (%s/%s), runtime=%s",
progress_done_now,
progress_total_now,
segment_label,
)
return _mobilede_task_result_summary(
result=result,
segment=segment,
start_page=actual_start_page,
end_page=actual_end_page,
make_name=make_name,
model_name=model_name,
)
bootstrap_rotation_mode = bool(segment and runtime_rotation and MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED and not _mobilede_bootstrap_done(redis_client))
next_start_page = 1 if incremental_mode else (actual_end_page + 1 if listing_count > 0 else 1)
next_max_pages = min(max_pages, MOBILEDE_INCREMENTAL_PAGE_WINDOW) if incremental_mode else max_pages
followup_kwargs = {
"start_page": next_start_page,
"max_pages": next_max_pages,
"lane": lane,
"search_url": search_url,
"make_id": make_id,
"model_id": model_id,
"price_min": price_min,
"price_max": price_max,
"year_min": year_min,
"year_max": year_max,
"mileage_min": mileage_min,
"mileage_max": mileage_max,
"delay_seconds": delay_seconds,
"use_cursor": use_cursor,
"only_new": only_new,
"continuous": True,
"runtime_rotation": runtime_rotation,
}
if runtime_rotation and MOBILEDE_ROTATE_RUNTIME_SEGMENTS:
next_segment_reservation = _reserve_next_mobilede_runtime_segment(redis_client, settings, only_new=only_new)
if next_segment_reservation is not None:
next_segment_index, next_segment = next_segment_reservation
followup_kwargs.update(
{
"start_page": int(next_segment.get("start_page") or 1),
"max_pages": int(next_segment.get("max_pages") or max_pages),
"search_url": str(next_segment.get("search_url") or next_segment.get("listing_url") or "").strip() or None,
"make_id": str(next_segment.get("make_id") or "").strip() or None,
"model_id": str(next_segment.get("model_id") or "").strip() or None,
"price_min": str(next_segment.get("price_min") or "").strip() or None,
"price_max": str(next_segment.get("price_max") or "").strip() or None,
"year_min": str(next_segment.get("year_min") or "").strip() or None,
"year_max": str(next_segment.get("year_max") or "").strip() or None,
"mileage_min": str(next_segment.get("mileage_min") or "").strip() or None,
"mileage_max": str(next_segment.get("mileage_max") or "").strip() or None,
"segment": next_segment,
"segment_index": next_segment_index,
}
)
if only_new and _mobilede_bootstrap_done(redis_client) and MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP:
followup_kwargs["start_page"] = 1
followup_kwargs["max_pages"] = min(int(followup_kwargs["max_pages"]), MOBILEDE_INCREMENTAL_PAGE_WINDOW)
followup_kwargs["use_cursor"] = False
next_start_page = int(followup_kwargs["start_page"])
logger.info(
"mobile.de runtime rotation queued: current=%s next=%s next_pages=%s-%s",
segment_label,
_mobilede_segment_label(next_segment),
next_start_page,
next_start_page + int(followup_kwargs["max_pages"]) - 1,
)
elif segment is not None and int(result.get("listing_count", 0) or 0) > 0:
followup_kwargs["segment"] = segment
followup_kwargs["segment_index"] = segment_index
elif segment is not None and runtime_segments_enabled:
followup_kwargs["segment"] = None
followup_kwargs["segment_index"] = None
if bootstrap_rotation_mode and "segment" not in followup_kwargs:
logger.info("mobile.de bootstrap follow-up skipped: no fresh runtime segment available after %s", segment_label)
mobilede_sync_runtime_segments_task.apply_async(
kwargs={
"lane": lane,
"delay_seconds": delay_seconds,
"use_cursor": use_cursor,
"continuous": True,
},
queue=MOBILEDE_SYNC_QUEUE,
countdown=5,
)
logger.info(
"mobile.de bootstrap recovery queued: runtime=%s delay=%ss",
segment_label,
5,
)
return _mobilede_task_result_summary(
result=result,
segment=segment,
start_page=actual_start_page,
end_page=actual_end_page,
make_name=make_name,
model_name=model_name,
)
followup_segment = followup_kwargs.get("segment")
followup_segment_key = _mobilede_segment_fingerprint(followup_segment) if isinstance(followup_segment, dict) else segment_runtime_key
if _try_set_mobilede_followup_pending(
redis_client,
segment_key=followup_segment_key,
ttl_seconds=max(lock_ttl, MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS + 300),
):
mobilede_sync_search_task.apply_async(
kwargs=followup_kwargs,
queue=MOBILEDE_SYNC_QUEUE,
countdown=MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS,
)
logger.debug(
"mobilede_sync_search_task queued follow-up: segment=%s next_start_page=%s delay=%ss use_cursor=%s",
(followup_kwargs.get("segment") or {}).get("label") if isinstance(followup_kwargs.get("segment"), dict) else None,
next_start_page,
MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS,
use_cursor,
)
logger.info(
"mobile.de sync next window queued: runtime=%s filter=%s next_pages=%s-%s delay=%ss",
segment_label,
_mobilede_filter_source(segment, search_url),
next_start_page,
next_start_page + int(followup_kwargs["max_pages"]) - 1,
MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS,
)
else:
if _try_reset_stale_mobilede_followup_pending(redis_client, segment_key=followup_segment_key) and _try_set_mobilede_followup_pending(
redis_client,
segment_key=followup_segment_key,
ttl_seconds=max(lock_ttl, MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS + 300),
):
mobilede_sync_search_task.apply_async(
kwargs=followup_kwargs,
queue=MOBILEDE_SYNC_QUEUE,
countdown=MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS,
)
logger.warning(
"mobile.de stale follow-up recovered: runtime=%s next_pages=%s-%s delay=%ss",
segment_label,
next_start_page,
next_start_page + int(followup_kwargs["max_pages"]) - 1,
MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS,
)
else:
logger.info("mobile.de follow-up already pending for segment=%s", followup_segment_key)
return _mobilede_task_result_summary(
result=result,
segment=segment,
start_page=actual_start_page,
end_page=actual_end_page,
make_name=make_name,
model_name=model_name,
)
except Exception as exc:
_update_task_progress(
redis_client,
task_id=task_id,
stage="failed",
ttl_seconds=3600,
error=str(exc),
start_page=actual_start_page,
end_page=actual_end_page,
use_cursor=use_cursor,
)
is_transient_request_error = _is_mobilede_transient_request_error(exc)
max_retries = int(getattr(self, "max_retries", 0) or 0)
current_retries = int(getattr(self.request, "retries", 0) or 0)
if is_transient_request_error:
logger.warning(
"mobile.de network issue: runtime=%s filter=%s pages=%s-%s retry=%s/%s error=%s",
_mobilede_segment_label(segment),
_mobilede_filter_source(segment, search_url),
actual_start_page,
actual_end_page,
current_retries + 1,
max_retries,
exc,
)
if current_retries < max_retries:
raise self.retry(exc=exc, countdown=max(60, MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS))
if continuous is None:
continuous = MOBILEDE_CONTINUOUS_SYNC_ENABLED
if continuous:
followup_kwargs = {
"start_page": actual_start_page,
"max_pages": max_pages,
"lane": lane,
"search_url": search_url,
"make_id": make_id,
"model_id": model_id,
"price_min": price_min,
"price_max": price_max,
"year_min": year_min,
"year_max": year_max,
"mileage_min": mileage_min,
"mileage_max": mileage_max,
"delay_seconds": delay_seconds,
"use_cursor": use_cursor,
"continuous": True,
}
if segment is not None:
followup_kwargs["segment"] = segment
followup_kwargs["segment_index"] = segment_index
delayed_retry = max(300, MOBILEDE_CONTINUOUS_SYNC_DELAY_SECONDS * 4)
if _try_set_mobilede_followup_pending(
redis_client,
segment_key=segment_runtime_key,
ttl_seconds=max(lock_ttl, delayed_retry + 300),
):
mobilede_sync_search_task.apply_async(
kwargs=followup_kwargs,
queue=MOBILEDE_SYNC_QUEUE,
countdown=delayed_retry,
)
logger.warning(
"mobile.de delayed retry queued after network issue: runtime=%s filter=%s pages=%s-%s delay=%ss",
_mobilede_segment_label(segment),
_mobilede_filter_source(segment, search_url),
actual_start_page,
actual_end_page,
delayed_retry,
)
else:
logger.info("mobile.de delayed retry already pending for segment=%s", segment_runtime_key)
return {
"status": "network_error_deferred",
"runtime": _mobilede_segment_label(segment),
"make": _mobilede_segment_make(segment, make_id),
"model": _mobilede_segment_model(segment, model_id),
"pages": {
"start": actual_start_page,
"end": actual_end_page,
"count": actual_end_page - actual_start_page + 1,
},
"error": str(exc),
}
logger.error(
"mobile.de sync failed: runtime=%s filter=%s pages=%s-%s error=%s",
_mobilede_segment_label(segment),
_mobilede_filter_source(segment, search_url),
actual_start_page,
actual_end_page,
exc,
exc_info=True,
)
raise self.retry(exc=exc)
finally:
if watchdog_stop is not None:
watchdog_stop.set()
if watchdog_thread is not None:
watchdog_thread.join(timeout=5)
if heartbeat_stop is not None:
heartbeat_stop.set()
if heartbeat_thread is not None:
heartbeat_thread.join(timeout=max(1.0, min(5.0, lock_ttl / 10)))
_clear_task_progress(redis_client, task_id)
_release_lock_if_owner(redis_client, segment_lock_key, lock_owner)
@shared_task(
name=SYNC_LISTING_TASK_NAME,
queue=IAAI_SYNC_QUEUE,
bind=True,
max_retries=3,
default_retry_delay=120,
acks_late=True,
)
def sync_listing_task(
self,
make: str | None = None,
model: str | None = None,
lane: str = "iaai_cars",
limit: int | None = None,
only_new: bool | None = None,
):
# Полный цикл: листинг + sync всех найденных машин.
persistence = _get_persistence()
persistence.create_tables()
task_id = self.request.id or "unknown"
owner_token = f"{task_id}:{uuid.uuid4().hex}"
redis_client = _get_redis()
lock_acquired = False
lock_ttl = _sync_listing_lock_ttl_seconds()
heartbeat_stop: Event | None = None
heartbeat_thread: Thread | None = None
watchdog_stop: Event | None = None
watchdog_thread: Thread | None = None
force_bootstrap_full_scan = False
def _enqueue_bootstrap_followup(
reason: str,
delay_seconds: int = 5,
*,
count_as_failure: bool = False,
) -> None:
flag_ttl = max(lock_ttl, delay_seconds + 300)
if not _try_set_followup_pending(redis_client, ttl_seconds=flag_ttl):
logger.info(
"Bootstrap follow-up already pending; skip enqueue (reason=%s)",
reason,
)
return
if count_as_failure:
_, should_enqueue = _bump_bootstrap_failure_streak(redis_client, reason=reason)
if not should_enqueue:
_clear_followup_pending(redis_client)
return
else:
_clear_bootstrap_failure_streak(redis_client)
continuation_streak, should_continue = _bump_bootstrap_continuation_streak(redis_client)
if not should_continue:
_clear_followup_pending(redis_client)
# Переключаемся на часовой beat-режим и останавливаем
# немедленные bootstrap continuation, чтобы не зациклиться.
if not always_full_scan:
_set_full_scan_done(redis_client, True)
_clear_sync_checkpoint(redis_client)
logger.error(
"Bootstrap continuation stopped after %d immediate runs; switching to hourly schedule",
continuation_streak,
)
else:
logger.error(
"Bootstrap continuation stopped after %d immediate runs (always_full_scan=true)",
continuation_streak,
)
return
try:
followup_expires = max(int(lock_ttl), int(delay_seconds) + 300)
self.app.send_task(
SYNC_LISTING_TASK_NAME,
kwargs={
"make": make,
"model": model,
"lane": lane,
"limit": limit,
"only_new": only_new,
},
queue=IAAI_SYNC_QUEUE,
countdown=max(0, int(delay_seconds)),
expires=followup_expires,
)
logger.info(
"Bootstrap follow-up sync queued in %ss (reason=%s)",
delay_seconds,
reason,
)
except Exception:
_clear_followup_pending(redis_client)
logger.warning("Failed to enqueue bootstrap follow-up sync", exc_info=True)
lock_acquired = _acquire_lock(redis_client, SYNC_LISTING_LOCK_KEY, owner_token, lock_ttl)
if not lock_acquired:
orphan_cleared = _clear_orphan_sync_listing_lock(redis_client, self.app, current_task_id=task_id)
if orphan_cleared:
lock_acquired = _acquire_lock(redis_client, SYNC_LISTING_LOCK_KEY, owner_token, lock_ttl)
if not lock_acquired:
logger.info("sync_listing_task skipped: another sync is already running")
return {
"status": "skipped",
"reason": "sync_already_running",
"task_id": task_id,
}
try:
settings = Settings()
# Любой реально стартовавший sync_listing снимает pending-флаг followup,
# чтобы watchdog/continuation могли корректно планировать следующий run
# только при новой проблеме, а не копить дубликаты в очереди.
_clear_followup_pending(redis_client)
# Start heartbeat + stall watchdog immediately after lock acquisition
# so ALL code paths (hourly, bootstrap, segmented) are protected.
heartbeat_stop, heartbeat_thread = _start_lock_heartbeat(
redis_client,
SYNC_LISTING_LOCK_KEY,
owner_token,
lock_ttl,
stop_on_lost=False,
)
stall_timeout = max(120, int(settings.celery.task_stall_timeout_seconds))
progress_ttl = max(lock_ttl + 120, stall_timeout + 120)
_update_task_progress(
redis_client,
task_id=task_id,
stage="sync_listing_started",
ttl_seconds=progress_ttl,
)
full_scan_done_before_run = _is_full_scan_done(redis_client)
always_full_scan = bool(settings.discovery.always_full_scan)
explicit_filtered_run = bool(
make is not None
or model is not None
or (limit is not None and int(limit or 0) > 0)
or only_new is True
)
force_bootstrap_full_scan = (always_full_scan or (not full_scan_done_before_run)) and not explicit_filtered_run
if explicit_filtered_run and not full_scan_done_before_run:
logger.info(
"Explicit sync_listing request detected; honoring make/model/limit/only_new before bootstrap full scan is complete",
)
effective_limit = None if force_bootstrap_full_scan else limit
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 = (
not force_bootstrap_full_scan
and make is None
and model is None
and effective_limit is None
and not effective_only_new
and not always_full_scan
)
# Определяем сегменты из конфига/env или из runtime_config при IAAI_LISTING_SEGMENTS=runtime.
filtered_listing_urls = settings.listing.filtered_search_urls
filtered_listing_url = filtered_listing_urls[0] if filtered_listing_urls else None
segments = _build_listing_segments(settings)
watchdog_stop, watchdog_thread = _start_stall_watchdog(
redis_client,
task_id=task_id,
stall_timeout_seconds=stall_timeout,
lock_key=SYNC_LISTING_LOCK_KEY,
lock_owner=owner_token,
db_idle_restart_seconds=(DB_IDLE_RESTART_SECONDS if segments else None),
)
use_hourly_sitemap_sync = (
(not always_full_scan)
and full_scan_done_before_run
and prefer_sitemap_mainline
and not segments
)
# Segment-level checkpoint: хранит индекс последнего ПОЛНОСТЬЮ пройденного сегмента.
# Используется только во время bootstrap для пропуска уже обработанных сегментов.
# Никаких page-level resume — внутри сегмента всегда стартуем с page 1.
last_completed_segment: int | None = None
if force_bootstrap_full_scan and (always_full_scan or not full_scan_done_before_run):
last_completed_segment = _load_last_completed_segment(redis_client)
else:
# После завершения bootstrap чекпоинт не нужен никогда.
_clear_sync_checkpoint(redis_client)
if force_bootstrap_full_scan:
logger.info(
"Bootstrap mode: forcing full scan (only_new=False, limit=None) until first complete run",
)
if always_full_scan:
logger.info(
"Always full scan mode enabled (IAAI_ALWAYS_FULL_SCAN=true): using listing resume path",
)
logger.info(
"sync_listing options: only_new=%s, limit=%s",
effective_only_new,
effective_limit,
)
if use_hourly_sitemap_sync:
# Circuit breaker: если N подряд hourly-запусков фейлили, пропускаем.
_hourly_streak, _cb_open = _check_hourly_circuit_breaker(redis_client)
if _cb_open:
return {
"status": "circuit_breaker_open",
"task_id": task_id,
"hourly_failure_streak": _hourly_streak,
}
_progress_cb = lambda stage, meta: _update_task_progress(
redis_client,
task_id=task_id,
stage=stage,
ttl_seconds=progress_ttl,
**meta,
)
try:
if hourly_mode == "diff":
logger.info("Hourly mode: running sitemap diff sync instead of full listing traversal")
result = _hourly_sitemap_diff_sync(
lane=lane,
limit=effective_limit,
only_new=effective_only_new,
progress_callback=_progress_cb,
)
elif hourly_mode == "full_refresh":
logger.info("Hourly mode: running sitemap full refresh of all active vehicles")
result = _hourly_sitemap_full_refresh_sync(
lane=lane,
limit=effective_limit,
only_new=effective_only_new,
progress_callback=_progress_cb,
)
else:
logger.info("Hourly mode: running sitemap rolling refresh of active vehicles")
result = _hourly_sitemap_rolling_refresh_sync(
redis_client=redis_client,
lane=lane,
limit=effective_limit,
only_new=effective_only_new,
progress_callback=_progress_cb,
)
except SitemapDiscoveryError as exc:
logger.warning(
"Sitemap discovery failed (%s); falling back to listing traversal for hourly sync",
exc,
)
use_hourly_sitemap_sync = False # fall through to listing traversal below
prefer_sitemap_mainline = False # allow segmented fallback
discovery_mode = "listing" # override so use_segmented check passes
if use_hourly_sitemap_sync:
try:
redis_client.set(SITEMAP_HOURLY_LAST_COUNT_KEY, str(result.get("discovered_urls", 0)))
except Exception:
logger.warning("Failed to persist hourly sitemap count", exc_info=True)
# Anti-bot guard: при массовом protection/failed не считаем запуск успешным,
# открываем hourly circuit breaker и уходим в controlled retry по beat.
hourly_failures = len(result.get("failures") or [])
hourly_discovered = int(result.get("discovered_urls") or 0)
hourly_failed = int(result.get("cars_failed") or 0)
hourly_protection = int(result.get("protection_events") or 0)
if hourly_discovered > 0:
fail_ratio = hourly_failed / max(1, hourly_discovered)
protection_ratio = hourly_protection / max(1, hourly_discovered)
anti_bot_suspected = (
(hourly_protection >= 30 and protection_ratio >= 0.10)
or fail_ratio >= 0.30
)
if anti_bot_suspected:
_streak = _bump_hourly_failure_streak(redis_client)
logger.error(
"Hourly anti-bot guard triggered: discovered=%d failed=%d protection=%d fail_ratio=%.2f protection_ratio=%.2f streak=%d",
hourly_discovered,
hourly_failed,
hourly_protection,
fail_ratio,
protection_ratio,
_streak,
)
return {
"status": "anti_bot_detected",
"task_id": task_id,
"hourly_mode": result.get("hourly_mode"),
"discovered_urls": hourly_discovered,
"cars_failed": hourly_failed,
"protection_events": hourly_protection,
"hourly_failure_streak": _streak,
}
summary = {
"task_id": task_id,
"run_id": result.get("run_id"),
"status": result.get("status", "success"),
"cars_upserted": result.get("cars_upserted", 0),
"cars_failed": result.get("cars_failed", 0),
"images_upserted": result.get("images_upserted", 0),
"skipped_existing": result.get("skipped_existing", 0),
"elapsed_seconds": result.get("elapsed_seconds"),
"failures_count": len(result.get("failures") or []),
"hourly_mode": result.get("hourly_mode"),
"discovered_urls": result.get("discovered_urls", 0),
"new_urls": result.get("new_urls", 0),
"sold_marked": result.get("sold_marked", 0),
}
logger.info(
"sync_listing_task hourly diff completed: status=%s, new=%d, sold=%d, skipped=%d",
summary["status"],
summary["new_urls"],
summary["sold_marked"],
summary["skipped_existing"],
)
# Hourly успешно — сбрасываем circuit breaker streak.
_clear_hourly_failure_streak(redis_client)
return summary
_clear_followup_pending(redis_client)
self.update_state(state="STARTED", meta={"stage": "sync_listing_started", "task_id": task_id})
use_segmented = (
bool(segments)
and make is None
and model is None
and not use_hourly_sitemap_sync
)
if prefer_sitemap_mainline:
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")
# --- Параллельный диспатч сегментов: dispatch & exit ---
if use_segmented and settings.celery.parallel_segments:
# Сегменты уже завершённые (для bootstrap resume) пропускаем по Redis SET.
already_completed: set[int] = set()
if force_bootstrap_full_scan:
try:
raw = redis_client.smembers(SYNC_SEGMENTS_PROGRESS_KEY) or set()
already_completed = {int(x) for x in raw if str(x).strip().lstrip("-").isdigit()}
except Exception:
already_completed = set()
pending = [
(idx, seg) for idx, seg in enumerate(segments)
if idx not in already_completed
]
if not pending:
# Всё уже сделано — фиксируем bootstrap done.
if force_bootstrap_full_scan:
_set_full_scan_done(redis_client, True)
_clear_sync_checkpoint(redis_client)
_clear_bootstrap_failure_streak(redis_client)
logger.warning("Parallel segments: nothing to dispatch (all completed)")
return {
"status": "success",
"task_id": task_id,
"mode": "parallel_segments",
"segments_total": len(segments),
"segments_dispatched": 0,
"segments_already_completed": len(already_completed),
}
# При первом запуске bootstrap фиксируем total, чтобы знать когда остановиться.
if force_bootstrap_full_scan and not already_completed:
_reset_segments_progress(redis_client, len(segments))
dispatched = 0
for idx, seg in pending:
try:
self.app.send_task(
"iaai_scraper.worker.tasks.sync_segment_task",
kwargs={
"segment_index": idx,
"segment": seg,
"lane": lane,
"only_new": effective_only_new,
"is_bootstrap": force_bootstrap_full_scan,
},
queue=IAAI_SYNC_QUEUE,
)
dispatched += 1
except Exception:
logger.warning("Failed to dispatch segment %d", idx, exc_info=True)
logger.warning(
"Parallel segments dispatched: %d/%d (already_completed=%d, bootstrap=%s)",
dispatched, len(segments), len(already_completed), force_bootstrap_full_scan,
)
return {
"status": "success",
"task_id": task_id,
"mode": "parallel_segments",
"segments_total": len(segments),
"segments_dispatched": dispatched,
"segments_already_completed": len(already_completed),
}
# --- конец параллельной ветки ---
resume_from_segment = 0
if force_bootstrap_full_scan and use_segmented and last_completed_segment is not None:
resume_from_segment = max(0, last_completed_segment + 1)
if resume_from_segment >= len(segments):
# Все сегменты уже пройдены — чекпоинт устарел, начинаем заново.
logger.info(
"Stored checkpoint segment=%d is beyond configured segments (%d); restarting bootstrap from segment 0",
last_completed_segment, len(segments),
)
resume_from_segment = 0
_clear_sync_checkpoint(redis_client)
elif resume_from_segment > 0:
logger.warning(
"Resuming segmented bootstrap from segment=%d (last completed=%d)",
resume_from_segment, last_completed_segment,
)
def _progress_cb_main(stage, meta):
_update_task_progress(
redis_client,
task_id=task_id,
stage=stage,
ttl_seconds=progress_ttl,
**meta,
)
def _job():
with IAAIScraper() as scraper:
scraper.set_progress_callback(_progress_cb_main)
if use_segmented:
return scraper.sync_listing_segmented(
segments=segments,
lane=lane,
only_new=effective_only_new,
start_segment=resume_from_segment,
start_page=1,
progress_callback=(
(lambda seg_idx: _save_last_completed_segment(redis_client, seg_idx))
if force_bootstrap_full_scan and (always_full_scan or not full_scan_done_before_run) else None
),
)
return scraper.sync_listing(
make=make,
model=model,
lane=lane,
limit=effective_limit,
only_new=effective_only_new,
listing_url=filtered_listing_url,
)
result = _run_browser_job(_job)
if force_bootstrap_full_scan:
bootstrap_completed = bool(result.get("full_scan_completed"))
if bootstrap_completed:
_set_full_scan_done(redis_client, False if always_full_scan else True)
_clear_sync_checkpoint(redis_client)
_clear_bootstrap_failure_streak(redis_client)
_clear_bootstrap_continuation_streak(redis_client)
if always_full_scan:
logger.info("Full scan completed; keeping bootstrap mode for next run (always full scan enabled)")
else:
logger.info("Bootstrap full scan completed; hourly schedule continues")
else:
_set_full_scan_done(redis_client, False)
listing_payload = result.get("listing") if isinstance(result.get("listing"), dict) else {}
anti_bot_detected = bool(result.get("anti_bot_detected"))
had_progress = any(
int(result.get(key) or 0) > 0
for key in ("cars_upserted", "images_upserted", "skipped_existing", "total_discovered")
) or int(listing_payload.get("vehicles_collected") or 0) > 0
count_as_failure = anti_bot_detected or (str(result.get("status") or "") == "failed" and not had_progress)
followup_delay = 5
followup_reason = "bootstrap_not_completed"
if anti_bot_detected:
followup_delay = 180
followup_reason = "bootstrap_anti_bot_detected"
logger.warning(
"Bootstrap anti-bot guard: protection_events=%s fail_ratio=%s protection_ratio=%s; scheduling delayed continuation",
result.get("protection_events"),
result.get("fail_ratio"),
result.get("protection_ratio"),
)
else:
logger.info("Bootstrap full scan not complete yet; queuing immediate continuation")
_enqueue_bootstrap_followup(
followup_reason,
delay_seconds=followup_delay,
count_as_failure=count_as_failure,
)
else:
_clear_sync_checkpoint(redis_client)
summary = {
"task_id": task_id,
"run_id": result.get("run_id"),
"status": result.get("status", "success"),
"cars_upserted": result.get("cars_upserted", 0),
"cars_failed": result.get("cars_failed", 0),
"protection_events": result.get("protection_events", 0),
"images_upserted": result.get("images_upserted", 0),
"skipped_existing": result.get("skipped_existing", 0),
"elapsed_seconds": result.get("elapsed_seconds"),
"failures_count": len(result.get("failures") or []),
}
if not force_bootstrap_full_scan:
discovered = int(result.get("total_discovered") or result.get("total") or 0)
failed = int(summary["cars_failed"] or 0)
protection = int(summary["protection_events"] or 0)
if discovered > 0:
fail_ratio = failed / max(1, discovered)
protection_ratio = protection / max(1, discovered)
anti_bot_suspected = (
(protection >= 30 and protection_ratio >= 0.10)
or fail_ratio >= 0.30
)
if anti_bot_suspected:
streak = _bump_hourly_failure_streak(redis_client)
logger.error(
"Hourly anti-bot guard triggered (listing fallback): discovered=%d failed=%d protection=%d fail_ratio=%.2f protection_ratio=%.2f streak=%d",
discovered,
failed,
protection,
fail_ratio,
protection_ratio,
streak,
)
summary["status"] = "anti_bot_detected"
summary["hourly_failure_streak"] = streak
return summary
logger.info(
"sync_listing_task completed: status=%s, %d upserted, %d failed, failures=%d",
summary["status"],
summary["cars_upserted"],
summary["cars_failed"],
summary["failures_count"],
)
return summary
except SoftTimeLimitExceeded:
logger.warning(
"sync_listing_task soft timeout exceeded — partial progress already saved to DB"
)
if force_bootstrap_full_scan:
_set_full_scan_done(redis_client, False)
_enqueue_bootstrap_followup("soft_time_limit_exceeded", count_as_failure=False)
else:
# Hourly: ставим продолжение, но только если circuit breaker не открыт.
_bump_hourly_failure_streak(redis_client)
_streak, _cb_open = _check_hourly_circuit_breaker(redis_client)
if not _cb_open:
followup_ttl = max(600, int(lock_ttl))
if _try_set_followup_pending(redis_client, ttl_seconds=followup_ttl):
try:
self.app.send_task(
SYNC_LISTING_TASK_NAME,
kwargs={
"make": make,
"model": model,
"lane": lane,
"limit": limit,
"only_new": only_new,
},
queue=IAAI_SYNC_QUEUE,
countdown=10,
expires=followup_ttl,
)
logger.info("Queued immediate continuation after soft timeout")
except Exception:
_clear_followup_pending(redis_client)
logger.warning("Failed to queue continuation after soft timeout", exc_info=True)
else:
logger.info("Continuation after soft timeout already pending; skip duplicate enqueue")
else:
logger.warning("Skipping continuation: hourly circuit breaker open (%d failures)", _streak)
return {
"status": "timed_out",
"task_id": task_id,
"reason": "soft_time_limit_exceeded",
"note": "partial progress saved to DB; continuation queued",
}
except Exception as exc:
logger.error("sync_listing_task failed: %s", exc, exc_info=True)
# Hourly circuit breaker: фиксируем ошибку.
if not force_bootstrap_full_scan:
_bump_hourly_failure_streak(redis_client)
try:
if force_bootstrap_full_scan:
_set_full_scan_done(redis_client, False)
raise self.retry(exc=exc, countdown=5)
raise self.retry(exc=exc)
except self.MaxRetriesExceededError:
logger.error("sync_listing_task max retries exceeded, giving up")
if force_bootstrap_full_scan:
_set_full_scan_done(redis_client, False)
_enqueue_bootstrap_followup("max_retries_exceeded", count_as_failure=True)
# Non-bootstrap: НЕ ставим continuation — beat поставит новую задачу
# через beat_sync_interval_minutes. Бесконечный retry при ошибках
# приводит к молотилке запросов и бану.
return {
"status": "failed",
"task_id": task_id,
"error": str(exc),
}
finally:
if watchdog_stop is not None:
watchdog_stop.set()
if watchdog_thread is not None:
watchdog_thread.join(timeout=5)
if heartbeat_stop is not None:
heartbeat_stop.set()
if heartbeat_thread is not None:
heartbeat_thread.join(timeout=max(1.0, min(5.0, lock_ttl / 10)))
if lock_acquired:
_release_lock_if_owner(redis_client, SYNC_LISTING_LOCK_KEY, owner_token)