|
|
|
@@ -13,6 +13,7 @@ from urllib.request import Request, urlopen
|
|
|
|
import urllib3
|
|
|
|
import urllib3
|
|
|
|
|
|
|
|
|
|
|
|
from .core.config import Settings
|
|
|
|
from .core.config import Settings
|
|
|
|
|
|
|
|
from .core.runtime_config import FiltersConfig
|
|
|
|
from .storage.db import PersistenceService
|
|
|
|
from .storage.db import PersistenceService
|
|
|
|
from .storage.schemas import CarRecord, ImageRecord
|
|
|
|
from .storage.schemas import CarRecord, ImageRecord
|
|
|
|
from .translations import (
|
|
|
|
from .translations import (
|
|
|
|
@@ -132,6 +133,23 @@ class EncarFilters:
|
|
|
|
return "(And." + "._." .join(parts) + ".)"
|
|
|
|
return "(And." + "._." .join(parts) + ".)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(slots=True, frozen=True)
|
|
|
|
|
|
|
|
class RuntimeFilterSpec:
|
|
|
|
|
|
|
|
price_min: int | None = None
|
|
|
|
|
|
|
|
price_max: int | None = None
|
|
|
|
|
|
|
|
mileage_min: int | None = None
|
|
|
|
|
|
|
|
mileage_max: int | None = None
|
|
|
|
|
|
|
|
models: frozenset[str] = frozenset()
|
|
|
|
|
|
|
|
years: frozenset[int] = frozenset()
|
|
|
|
|
|
|
|
body_types: frozenset[str] = frozenset()
|
|
|
|
|
|
|
|
colors: frozenset[str] = frozenset()
|
|
|
|
|
|
|
|
drives: frozenset[str] = frozenset()
|
|
|
|
|
|
|
|
gearboxes: frozenset[str] = frozenset()
|
|
|
|
|
|
|
|
exclude_models: frozenset[str] = frozenset()
|
|
|
|
|
|
|
|
exclude_years: frozenset[int] = frozenset()
|
|
|
|
|
|
|
|
exclude_body_types: frozenset[str] = frozenset()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(slots=True)
|
|
|
|
@dataclass(slots=True)
|
|
|
|
class EncarMapper:
|
|
|
|
class EncarMapper:
|
|
|
|
def map_to_car_record(self, vehicle_url: str, payload: dict[str, Any], probe_all_photos: bool = False) -> CarRecord:
|
|
|
|
def map_to_car_record(self, vehicle_url: str, payload: dict[str, Any], probe_all_photos: bool = False) -> CarRecord:
|
|
|
|
@@ -502,6 +520,99 @@ class EncarScraper:
|
|
|
|
)
|
|
|
|
)
|
|
|
|
return self._batch_pool
|
|
|
|
return self._batch_pool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
|
|
def _compile_runtime_filters(runtime_filters: FiltersConfig | None) -> RuntimeFilterSpec | None:
|
|
|
|
|
|
|
|
if runtime_filters is None:
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
spec = RuntimeFilterSpec(
|
|
|
|
|
|
|
|
price_min=runtime_filters.price_min,
|
|
|
|
|
|
|
|
price_max=runtime_filters.price_max,
|
|
|
|
|
|
|
|
mileage_min=runtime_filters.mileage_min,
|
|
|
|
|
|
|
|
mileage_max=runtime_filters.mileage_max,
|
|
|
|
|
|
|
|
models=frozenset(m for m in runtime_filters.models if m),
|
|
|
|
|
|
|
|
years=frozenset(int(y) for y in runtime_filters.years if y is not None),
|
|
|
|
|
|
|
|
body_types=frozenset(b for b in runtime_filters.body_types if b),
|
|
|
|
|
|
|
|
colors=frozenset(c for c in runtime_filters.colors if c),
|
|
|
|
|
|
|
|
drives=frozenset(d for d in runtime_filters.drives if d),
|
|
|
|
|
|
|
|
gearboxes=frozenset(g for g in runtime_filters.gearboxes if g),
|
|
|
|
|
|
|
|
exclude_models=frozenset(m for m in runtime_filters.exclude_models if m),
|
|
|
|
|
|
|
|
exclude_years=frozenset(int(y) for y in runtime_filters.exclude_years if y is not None),
|
|
|
|
|
|
|
|
exclude_body_types=frozenset(b for b in runtime_filters.exclude_body_types if b),
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not any((
|
|
|
|
|
|
|
|
spec.price_min is not None,
|
|
|
|
|
|
|
|
spec.price_max is not None,
|
|
|
|
|
|
|
|
spec.mileage_min is not None,
|
|
|
|
|
|
|
|
spec.mileage_max is not None,
|
|
|
|
|
|
|
|
spec.models,
|
|
|
|
|
|
|
|
spec.years,
|
|
|
|
|
|
|
|
spec.body_types,
|
|
|
|
|
|
|
|
spec.colors,
|
|
|
|
|
|
|
|
spec.drives,
|
|
|
|
|
|
|
|
spec.gearboxes,
|
|
|
|
|
|
|
|
spec.exclude_models,
|
|
|
|
|
|
|
|
spec.exclude_years,
|
|
|
|
|
|
|
|
spec.exclude_body_types,
|
|
|
|
|
|
|
|
)):
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return spec
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
|
|
def _matches_any_token(value: str, tokens: frozenset[str]) -> bool:
|
|
|
|
|
|
|
|
if not tokens:
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
return any(token in value for token in tokens if token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _record_passes_runtime_filters(
|
|
|
|
|
|
|
|
self,
|
|
|
|
|
|
|
|
record: CarRecord,
|
|
|
|
|
|
|
|
runtime_filters: RuntimeFilterSpec | None,
|
|
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
|
|
if runtime_filters is None:
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
model_lower = (record.model or "").lower()
|
|
|
|
|
|
|
|
body_type_lower = (record.body_type or "").lower()
|
|
|
|
|
|
|
|
color_lower = (record.color or "").lower()
|
|
|
|
|
|
|
|
drive_lower = (record.drive or "").lower()
|
|
|
|
|
|
|
|
gearbox_lower = (record.gearbox or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if runtime_filters.price_min is not None and (record.price is None or record.price < runtime_filters.price_min):
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.price_max is not None and (record.price is None or record.price > runtime_filters.price_max):
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.mileage_min is not None and record.mileage < runtime_filters.mileage_min:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.mileage_max is not None and record.mileage > runtime_filters.mileage_max:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if runtime_filters.models and not self._matches_any_token(model_lower, runtime_filters.models):
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.exclude_models and self._matches_any_token(model_lower, runtime_filters.exclude_models):
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if runtime_filters.years and (record.year is None or record.year not in runtime_filters.years):
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.exclude_years and record.year in runtime_filters.exclude_years:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if runtime_filters.body_types and body_type_lower not in runtime_filters.body_types:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.exclude_body_types and body_type_lower in runtime_filters.exclude_body_types:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if runtime_filters.colors and color_lower not in runtime_filters.colors:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.drives and drive_lower not in runtime_filters.drives:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
if runtime_filters.gearboxes and gearbox_lower not in runtime_filters.gearboxes:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def collect_listing(
|
|
|
|
def collect_listing(
|
|
|
|
self,
|
|
|
|
self,
|
|
|
|
limit: int | None = None,
|
|
|
|
limit: int | None = None,
|
|
|
|
@@ -745,6 +856,7 @@ class EncarScraper:
|
|
|
|
redis_client: Any | None = None,
|
|
|
|
redis_client: Any | None = None,
|
|
|
|
allowed_brands: set[str] | None = None,
|
|
|
|
allowed_brands: set[str] | None = None,
|
|
|
|
excluded_brands: set[str] | None = None,
|
|
|
|
excluded_brands: set[str] | None = None,
|
|
|
|
|
|
|
|
runtime_filters: FiltersConfig | None = None,
|
|
|
|
probe_all_photos: bool = False,
|
|
|
|
probe_all_photos: bool = False,
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
"""Полная синхронизация листинга Encar.
|
|
|
|
"""Полная синхронизация листинга Encar.
|
|
|
|
@@ -763,6 +875,7 @@ class EncarScraper:
|
|
|
|
self.persistence.create_tables()
|
|
|
|
self.persistence.create_tables()
|
|
|
|
filters = filters or EncarFilters()
|
|
|
|
filters = filters or EncarFilters()
|
|
|
|
page_size = self.DEFAULT_PAGE_SIZE
|
|
|
|
page_size = self.DEFAULT_PAGE_SIZE
|
|
|
|
|
|
|
|
compiled_runtime_filters = self._compile_runtime_filters(runtime_filters)
|
|
|
|
|
|
|
|
|
|
|
|
# --- Генерация шардов ---
|
|
|
|
# --- Генерация шардов ---
|
|
|
|
if limit:
|
|
|
|
if limit:
|
|
|
|
@@ -886,7 +999,9 @@ class EncarScraper:
|
|
|
|
if len(batch) >= batch_size:
|
|
|
|
if len(batch) >= batch_size:
|
|
|
|
s, f, fids = self._flush_batch(
|
|
|
|
s, f, fids = self._flush_batch(
|
|
|
|
batch, all_origin_ids, allowed_brands,
|
|
|
|
batch, all_origin_ids, allowed_brands,
|
|
|
|
excluded_brands, probe_all_photos=probe_all_photos,
|
|
|
|
excluded_brands,
|
|
|
|
|
|
|
|
runtime_filters=compiled_runtime_filters,
|
|
|
|
|
|
|
|
probe_all_photos=probe_all_photos,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
synced += s
|
|
|
|
synced += s
|
|
|
|
failed += f
|
|
|
|
failed += f
|
|
|
|
@@ -922,7 +1037,9 @@ class EncarScraper:
|
|
|
|
if batch:
|
|
|
|
if batch:
|
|
|
|
s, f, fids = self._flush_batch(
|
|
|
|
s, f, fids = self._flush_batch(
|
|
|
|
batch, all_origin_ids, allowed_brands,
|
|
|
|
batch, all_origin_ids, allowed_brands,
|
|
|
|
excluded_brands, probe_all_photos=probe_all_photos,
|
|
|
|
excluded_brands,
|
|
|
|
|
|
|
|
runtime_filters=compiled_runtime_filters,
|
|
|
|
|
|
|
|
probe_all_photos=probe_all_photos,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
synced += s
|
|
|
|
synced += s
|
|
|
|
failed += f
|
|
|
|
failed += f
|
|
|
|
@@ -1002,6 +1119,7 @@ class EncarScraper:
|
|
|
|
all_origin_ids: set[str],
|
|
|
|
all_origin_ids: set[str],
|
|
|
|
allowed_brands: set[str] | None = None,
|
|
|
|
allowed_brands: set[str] | None = None,
|
|
|
|
excluded_brands: set[str] | None = None,
|
|
|
|
excluded_brands: set[str] | None = None,
|
|
|
|
|
|
|
|
runtime_filters: RuntimeFilterSpec | None = None,
|
|
|
|
probe_all_photos: bool = False,
|
|
|
|
probe_all_photos: bool = False,
|
|
|
|
) -> tuple[int, int, list[str]]:
|
|
|
|
) -> tuple[int, int, list[str]]:
|
|
|
|
"""Маппит и upsert'ит батч items в БД. Возвращает (synced, failed, failed_ids)."""
|
|
|
|
"""Маппит и upsert'ит батч items в БД. Возвращает (synced, failed, failed_ids)."""
|
|
|
|
@@ -1011,6 +1129,7 @@ class EncarScraper:
|
|
|
|
failed = 0
|
|
|
|
failed = 0
|
|
|
|
failed_ids: list[str] = []
|
|
|
|
failed_ids: list[str] = []
|
|
|
|
skipped_brands = 0
|
|
|
|
skipped_brands = 0
|
|
|
|
|
|
|
|
skipped_runtime = 0
|
|
|
|
|
|
|
|
|
|
|
|
# Фаза 1: маппинг без пробинга + фильтр брендов
|
|
|
|
# Фаза 1: маппинг без пробинга + фильтр брендов
|
|
|
|
for item in items:
|
|
|
|
for item in items:
|
|
|
|
@@ -1026,6 +1145,9 @@ class EncarScraper:
|
|
|
|
if excluded_brands and brand_lower in excluded_brands:
|
|
|
|
if excluded_brands and brand_lower in excluded_brands:
|
|
|
|
skipped_brands += 1
|
|
|
|
skipped_brands += 1
|
|
|
|
continue
|
|
|
|
continue
|
|
|
|
|
|
|
|
if not self._record_passes_runtime_filters(record, runtime_filters):
|
|
|
|
|
|
|
|
skipped_runtime += 1
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
records.append(record)
|
|
|
|
records.append(record)
|
|
|
|
probe_tasks.append((len(records) - 1, item))
|
|
|
|
probe_tasks.append((len(records) - 1, item))
|
|
|
|
@@ -1063,8 +1185,10 @@ class EncarScraper:
|
|
|
|
synced = 0
|
|
|
|
synced = 0
|
|
|
|
|
|
|
|
|
|
|
|
total_images = sum(len(r.images) for r in records)
|
|
|
|
total_images = sum(len(r.images) for r in records)
|
|
|
|
logger.info("Batch stats: %d items → %d after brand filter (-%d), %d synced, %d total images",
|
|
|
|
logger.info(
|
|
|
|
len(items), len(records), skipped_brands, synced, total_images)
|
|
|
|
"Batch stats: %d items → %d after runtime filter (-%d brands, -%d runtime), %d synced, %d total images",
|
|
|
|
|
|
|
|
len(items), len(records), skipped_brands, skipped_runtime, synced, total_images,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return synced, failed, failed_ids
|
|
|
|
return synced, failed, failed_ids
|
|
|
|
|
|
|
|
|
|
|
|
|