refactor mobile.de parser, fix country mapping, update README

This commit is contained in:
qananasikq
2026-05-04 18:12:10 +03:00
parent edddfc884c
commit 800867b4e6
46 changed files with 2887 additions and 10900 deletions

View File

@@ -1,4 +1,4 @@
import json
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -12,7 +12,7 @@ load_dotenv()
TRUE_VALUES = {"1", "true", "yes", "on"}
# Хелперы для чтения env-переменных с приведением типов
# Хелперы env.
def _env_str(name: str, default: str) -> str:
value = os.getenv(name)
@@ -47,80 +47,13 @@ def _env_float(name: str, default: float) -> float:
return float(_env_str(name, str(default)).strip())
# Конфиг браузерного отпечатка (User-Agent, viewport, timezone)
@dataclass(slots=True)
class FingerprintConfig:
user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
)
viewport_presets: tuple[dict[str, int], ...] = (
{"width": 1920, "height": 1080},
{"width": 1600, "height": 900},
{"width": 1536, "height": 864},
{"width": 1440, "height": 900},
{"width": 1366, "height": 768},
)
timezone_candidates: tuple[str, ...] = (
"America/New_York",
"America/Chicago",
"America/Los_Angeles",
)
locale: str = "en-US"
sec_ch_ua: str = '"Google Chrome";v="135", "Chromium";v="135", "Not.A/Brand";v="24"'
# Конфиг перехвата сетевых запросов (лимиты на кол-во)
@dataclass(slots=True)
class CaptureConfig:
capture_same_origin_only: bool = _env_bool("MOBILEDE_CAPTURE_SAME_ORIGIN_ONLY", True)
max_requests: int = _env_int("MOBILEDE_MAX_CAPTURED_REQUESTS", 40)
max_json_responses: int = _env_int("MOBILEDE_MAX_CAPTURED_JSON_RESPONSES", 30)
# Конфиг пауз между действиями (имитация человека)
@dataclass(slots=True)
class HumanPaceConfig:
enabled: bool = _env_bool("MOBILEDE_HUMAN_PACE_ENABLED", True)
after_listing_open_min_s: float = _env_float("MOBILEDE_AFTER_LISTING_OPEN_MIN_S", 0.5)
after_listing_open_max_s: float = _env_float("MOBILEDE_AFTER_LISTING_OPEN_MAX_S", 1.2)
after_filter_action_min_s: float = _env_float("MOBILEDE_AFTER_FILTER_ACTION_MIN_S", 0.5)
after_filter_action_max_s: float = _env_float("MOBILEDE_AFTER_FILTER_ACTION_MAX_S", 1.2)
before_vehicle_open_min_s: float = _env_float("MOBILEDE_BEFORE_VEHICLE_OPEN_MIN_S", 0.1)
before_vehicle_open_max_s: float = _env_float("MOBILEDE_BEFORE_VEHICLE_OPEN_MAX_S", 0.3)
after_vehicle_open_min_s: float = _env_float("MOBILEDE_AFTER_VEHICLE_OPEN_MIN_S", 0.05)
after_vehicle_open_max_s: float = _env_float("MOBILEDE_AFTER_VEHICLE_OPEN_MAX_S", 0.15)
between_vehicles_min_s: float = _env_float("MOBILEDE_BETWEEN_VEHICLES_MIN_S", 0.05)
between_vehicles_max_s: float = _env_float("MOBILEDE_BETWEEN_VEHICLES_MAX_S", 0.15)
after_page_change_min_s: float = _env_float("MOBILEDE_AFTER_PAGE_CHANGE_MIN_S", 0.8)
after_page_change_max_s: float = _env_float("MOBILEDE_AFTER_PAGE_CHANGE_MAX_S", 1.8)
# Конфиг сбора листинга (URL, лимиты страниц и машин)
# Конфиг ссылок.
@dataclass(slots=True)
class ListingConfig:
cars_url: str = _env_str("MOBILEDE_CARS_LISTING_URL", "https://www.MOBILEDE.com/Vehiclelisting/Cars")
cars_url: str = _env_str("MOBILEDE_CARS_LISTING_URL", "https://www.mobile.de/Vehiclelisting/Cars")
filtered_search_url: str | None = _env_optional_str("MOBILEDE_FILTERED_SEARCH_URL")
filtered_search_urls_raw: str | None = _env_optional_str("MOBILEDE_FILTERED_SEARCH_URLS")
max_pages_per_run: int = _env_int("MOBILEDE_MAX_PAGES_PER_RUN", 9999)
max_vehicles_per_run: int = _env_int("MOBILEDE_MAX_VEHICLES_PER_RUN", 50000)
page_link_limit: int = _env_int("MOBILEDE_PAGE_LINK_LIMIT", 500)
include_pagination: bool = _env_bool("MOBILEDE_INCLUDE_PAGINATION", True)
collect_current_page_only: bool = _env_bool("MOBILEDE_COLLECT_CURRENT_PAGE_ONLY", False)
# Порог раннего останова: если доля уже известных машин на странице >= этого значения,
# прекращаем листать — все новые машины уже найдены. 0 = отключено.
early_stop_threshold: float = _env_float("MOBILEDE_EARLY_STOP_THRESHOLD", 0.8)
# Сегментация листинга по брендам для обхода лимита пагинации MOBILEDE (~22 600 машин).
# JSON-массив объектов: [{"make":"TOYOTA"},{"make":"FORD"},...], "auto" для авто-списка
# или "runtime" для построения по runtime_config.filters.brands.
# Пустая строка = без сегментации (backward compatible).
listing_segments_json: str = _env_str("MOBILEDE_LISTING_SEGMENTS", "")
fast_segment_year_splits: bool = _env_bool("MOBILEDE_FAST_SEGMENT_YEAR_SPLITS", True)
@property
def filtered_search_urls(self) -> list[str]:
@@ -143,106 +76,7 @@ class ListingConfig:
return urls
# Список брендов MOBILEDE для автоматической сегментации.
# Покрывает >99% автомобилей на сайте. Порядок: от крупных к мелким.
MOBILEDE_DEFAULT_MAKES: tuple[str, ...] = (
"TOYOTA", "FORD", "CHEVROLET", "HONDA", "NISSAN", "HYUNDAI",
"KIA", "DODGE", "JEEP", "BMW", "MERCEDES-BENZ", "SUBARU",
"VOLKSWAGEN", "GMC", "MAZDA", "LEXUS", "CHRYSLER", "AUDI",
"RAM", "BUICK", "CADILLAC", "ACURA", "INFINITI", "LINCOLN",
"MITSUBISHI", "VOLVO", "JAGUAR", "LAND ROVER", "PORSCHE",
"MINI", "TESLA", "GENESIS", "FIAT", "ALFA ROMEO", "MASERATI",
"SCION", "PONTIAC", "SATURN", "MERCURY", "SAAB", "SUZUKI",
"OLDSMOBILE", "ISUZU", "HUMMER", "PLYMOUTH", "SMART",
"RIVIAN", "LUCID", "POLESTAR", "FERRARI", "LAMBORGHINI",
"BENTLEY", "ROLLS-ROYCE", "ASTON MARTIN", "MCLAREN", "LOTUS",
"MAYBACH", "FISKER", "GEO", "DAEWOO", "EAGLE",
)
# Пагинационный потолок mobilede: ~226 страниц × 100 = 22 600 результатов.
MOBILEDE_PAGINATION_CEILING = 22_600
# Бренды, потенциально превышающие потолок пагинации — разбиваем по годам.
_LARGE_MAKES: frozenset[str] = frozenset({
"TOYOTA", "FORD", "CHEVROLET", "HONDA", "NISSAN", "HYUNDAI",
"KIA", "DODGE", "JEEP",
})
_YEAR_SPLITS: tuple[tuple[int, int], ...] = (
(1900, 2012),
(2013, 2019),
(2020, 2027),
)
def build_listing_segments_for_makes(makes: tuple[str, ...] | list[str]) -> list[dict[str, str | int | None]]:
"""Строит сегменты по переданному списку брендов.
Крупные бренды режутся по годовым диапазонам так же, как в ``auto``.
"""
segments: list[dict[str, str | int | None]] = []
seen: set[str] = set()
for raw_make in makes:
make = str(raw_make or "").strip().upper()
if not make or make in seen:
continue
seen.add(make)
if make in _LARGE_MAKES:
for yr_min, yr_max in _YEAR_SPLITS:
segments.append({"make": make, "year_min": yr_min, "year_max": yr_max})
else:
segments.append({"make": make, "year_min": None, "year_max": None})
return segments
def build_fast_listing_segments_for_makes(makes: tuple[str, ...] | list[str]) -> list[dict[str, str | int | None]]:
"""Строит HTTP-first сегменты без UI-фильтров годов.
Это ближе к MOBILEDE-fast: один бренд = один hidden-payload HTTP обход.
Годовые split-сегменты требуют браузерный UI-фильтр и ломают стабильность fast-профиля.
"""
segments: list[dict[str, str | int | None]] = []
seen: set[str] = set()
for raw_make in makes:
make = str(raw_make or "").strip().upper()
if not make or make in seen:
continue
seen.add(make)
segments.append({"make": make, "year_min": None, "year_max": None})
return segments
def parse_listing_segments(raw: str) -> list[dict[str, str | int | None]]:
"""Парсит MOBILEDE_LISTING_SEGMENTS в список сегментов.
Каждый сегмент — dict с ключами: make (str), year_min/year_max (int|None).
Специальное значение ``"auto"`` генерирует сегменты из MOBILEDE_DEFAULT_MAKES.
Крупные бренды автоматически разбиваются по диапазонам годов.
"""
raw = raw.strip()
if not raw:
return []
if raw.lower() == "auto":
return build_listing_segments_for_makes(list(MOBILEDE_DEFAULT_MAKES))
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return []
if not isinstance(data, list):
return []
segments = []
for item in data:
if isinstance(item, str):
segments.append({"make": item.upper(), "year_min": None, "year_max": None})
elif isinstance(item, dict):
segments.append({
"make": str(item.get("make") or "").upper() or None,
"year_min": int(item["year_min"]) if item.get("year_min") is not None else None,
"year_max": int(item["year_max"]) if item.get("year_max") is not None else None,
})
return segments
# - Конфиг PostgreSQL (URL, пул соединений, pool_recycle)
# Конфиг PostgreSQL.
@dataclass(slots=True)
class DatabaseConfig:
@@ -254,7 +88,7 @@ class DatabaseConfig:
auto_create_tables: bool = _env_bool("MOBILEDE_DATABASE_AUTO_CREATE_TABLES", False)
# --- Конфиг Redis (URL для Celery broker) ---
# Конфиг Redis.
@dataclass(slots=True)
class RedisConfig:
@@ -264,17 +98,7 @@ class RedisConfig:
health_check_interval_seconds: int = _env_int("MOBILEDE_REDIS_HEALTH_CHECK_INTERVAL_SECONDS", 30)
# --- Конфиг Discovery (режим обнаружения, hourly batch) ---
@dataclass(slots=True)
class DiscoveryConfig:
mode: str = _env_str("MOBILEDE_DISCOVERY_MODE", "sitemap")
hourly_mode: str = _env_str("MOBILEDE_HOURLY_MODE", "rolling_refresh")
hourly_refresh_batch_size: int = _env_int("MOBILEDE_HOURLY_REFRESH_BATCH_SIZE", 500)
always_full_scan: bool = _env_bool("MOBILEDE_ALWAYS_FULL_SCAN", False)
# --- Конфиг Celery (лимиты задач, concurrency, beat-расписание) ---
# Конфиг Celery.
@dataclass(slots=True)
class CeleryConfig:
@@ -334,7 +158,7 @@ class ScrapingProfileConfig:
self.listing_retries = None
# --- Конфиг прокси (server, username, password) ---
# Конфиг прокси.
@dataclass(slots=True)
class ProxyConfig:
@@ -383,11 +207,11 @@ class ProxyConfig:
return {"http": proxy_url, "https": proxy_url}
# Главный объект настроек: собирает все блоки конфигурации
# Общие настройки.
@dataclass(slots=True)
class Settings:
home_url: str = "https://www.MOBILEDE.com/"
home_url: str = "https://www.mobile.de/"
default_timeout_ms: int = _env_int("MOBILEDE_TIMEOUT_MS", 45000)
network_settle_ms: int = _env_int("MOBILEDE_NETWORK_SETTLE_MS", 400)
fast_path_timeout_ms: int = _env_int("MOBILEDE_FAST_PATH_TIMEOUT_MS", 15000)
@@ -407,15 +231,11 @@ class Settings:
tokens_file: str | None = _env_path_str("MOBILEDE_TOKENS_FILE")
runtime_config_file: str | None = _env_path_str("MOBILEDE_RUNTIME_CONFIG_FILE")
scheduler_interval_minutes: int = _env_int("MOBILEDE_SCHEDULER_INTERVAL_MINUTES", 60)
fingerprint: FingerprintConfig = field(default_factory=FingerprintConfig)
capture: CaptureConfig = field(default_factory=CaptureConfig)
pace: HumanPaceConfig = field(default_factory=HumanPaceConfig)
listing: ListingConfig = field(default_factory=ListingConfig)
database: DatabaseConfig = field(default_factory=DatabaseConfig)
redis: RedisConfig = field(default_factory=RedisConfig)
celery: CeleryConfig = field(default_factory=CeleryConfig)
proxy: ProxyConfig = field(default_factory=ProxyConfig)
discovery: DiscoveryConfig = field(default_factory=DiscoveryConfig)
scraping_profile: ScrapingProfileConfig = field(default_factory=ScrapingProfileConfig)
@property
@@ -430,5 +250,5 @@ class Settings:
def block_resources(self) -> bool:
return self.celery.block_resources
# Глобальный синглтон — используется по умолчанию во всех модулях.
# Глобальные настройки.
settings = Settings()

View File

@@ -39,7 +39,7 @@ def setup_logging(level: str = "INFO", log_file: str | None = None) -> None:
root.addHandler(handler)
root.propagate = False
fmt = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | trace=%(trace_id)s | %(message)s"
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
for handler in root.handlers:
handler.setFormatter(fmt)