Update model translations
This commit is contained in:
1
encar_scraper/__init__.py
Normal file
1
encar_scraper/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__: list[str] = []
|
||||
3
encar_scraper/api/__init__.py
Normal file
3
encar_scraper/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
40
encar_scraper/api/app.py
Normal file
40
encar_scraper/api/app.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Создание FastAPI-приложения и настройка его жизненного цикла.
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ..core.config import Settings
|
||||
from ..storage.db import PersistenceService
|
||||
from .routes import cars, health, tasks
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Жизненный цикл.
|
||||
# Таблицы создаются через Alembic-миграции (сервис migrate).
|
||||
yield
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
_settings = settings or Settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Encar Scraper API",
|
||||
description="REST API для управления задачами скрапинга Encar и просмотра данных",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.state.settings = _settings
|
||||
app.state.persistence = PersistenceService(_settings)
|
||||
|
||||
app.include_router(health.router, tags=["health"])
|
||||
app.include_router(cars.router, prefix="/api/v1", tags=["cars"])
|
||||
app.include_router(tasks.router, prefix="/api/v1", tags=["tasks"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Экземпляр приложения для запуска через uvicorn.
|
||||
app = create_app()
|
||||
9
encar_scraper/api/deps.py
Normal file
9
encar_scraper/api/deps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Вспомогательные зависимости для FastAPI-роутов.
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from ..storage.db import PersistenceService
|
||||
|
||||
|
||||
def get_persistence(request: Request) -> PersistenceService:
|
||||
return request.app.state.persistence
|
||||
0
encar_scraper/api/routes/__init__.py
Normal file
0
encar_scraper/api/routes/__init__.py
Normal file
115
encar_scraper/api/routes/cars.py
Normal file
115
encar_scraper/api/routes/cars.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# Роуты для просмотра автомобилей и агрегированной статистики.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..deps import get_persistence
|
||||
from ...storage.db import PersistenceService
|
||||
from ...storage.models import Car, Image
|
||||
from ...storage.schemas import CarRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/cars")
|
||||
def list_cars(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
brand: str | None = None,
|
||||
model: str | None = None,
|
||||
year_min: int | None = None,
|
||||
year_max: int | None = None,
|
||||
is_sold: bool | None = None,
|
||||
persistence: PersistenceService = Depends(get_persistence),
|
||||
):
|
||||
# Список автомобилей с пагинацией и фильтрами
|
||||
with persistence.session_scope() as session:
|
||||
query = select(Car)
|
||||
count_query = select(func.count(Car.id))
|
||||
|
||||
if brand:
|
||||
escaped_brand = brand.replace("%", r"\%").replace("_", r"\_")
|
||||
cond = Car.brand.ilike(f"%{escaped_brand}%", escape="\\")
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
if model:
|
||||
escaped_model = model.replace("%", r"\%").replace("_", r"\_")
|
||||
cond = Car.model.ilike(f"%{escaped_model}%", escape="\\")
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
if year_min is not None:
|
||||
query = query.where(Car.year >= year_min)
|
||||
count_query = count_query.where(Car.year >= year_min)
|
||||
if year_max is not None:
|
||||
query = query.where(Car.year <= year_max)
|
||||
count_query = count_query.where(Car.year <= year_max)
|
||||
if is_sold is not None:
|
||||
query = query.where(Car.is_sold == is_sold)
|
||||
count_query = count_query.where(Car.is_sold == is_sold)
|
||||
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
cars = session.execute(
|
||||
query.options(selectinload(Car.images)).order_by(Car.last_seen_at.desc()).offset(offset).limit(per_page)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if per_page else 0,
|
||||
"items": [CarRead.model_validate(car).model_dump(mode="json") for car in cars],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}")
|
||||
def get_car(
|
||||
car_id: int,
|
||||
persistence: PersistenceService = Depends(get_persistence),
|
||||
):
|
||||
# Детальная информация об автомобиле с изображениями
|
||||
with persistence.session_scope() as session:
|
||||
car = session.execute(
|
||||
select(Car).options(selectinload(Car.images)).where(Car.id == car_id)
|
||||
).scalars().first()
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
return CarRead.model_validate(car).model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/cars/by-origin/{origin_id}")
|
||||
def get_car_by_origin(
|
||||
origin_id: str,
|
||||
persistence: PersistenceService = Depends(get_persistence),
|
||||
):
|
||||
# Поиск автомобиля по origin_id
|
||||
with persistence.session_scope() as session:
|
||||
car = session.execute(
|
||||
select(Car).options(selectinload(Car.images)).where(Car.origin_id == origin_id)
|
||||
).scalars().first()
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
return CarRead.model_validate(car).model_dump(mode="json")
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(persistence: PersistenceService = Depends(get_persistence)):
|
||||
# Общая статистика по БД
|
||||
with persistence.session_scope() as session:
|
||||
total_cars = session.execute(select(func.count(Car.id))).scalar() or 0
|
||||
total_images = session.execute(select(func.count(Image.id))).scalar() or 0
|
||||
brands = session.execute(
|
||||
select(Car.brand, func.count(Car.id))
|
||||
.group_by(Car.brand)
|
||||
.order_by(func.count(Car.id).desc())
|
||||
.limit(20)
|
||||
).all()
|
||||
|
||||
return {
|
||||
"total_cars": total_cars,
|
||||
"total_images": total_images,
|
||||
"top_brands": [{"brand": b, "count": c} for b, c in brands],
|
||||
}
|
||||
|
||||
30
encar_scraper/api/routes/health.py
Normal file
30
encar_scraper/api/routes/health.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Роут проверки доступности сервиса и соединения с БД.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..deps import get_persistence
|
||||
from ...storage.db import PersistenceService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("encar_scraper.api.health")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check(persistence: PersistenceService = Depends(get_persistence)):
|
||||
# Проверка API и БД
|
||||
db_ok = False
|
||||
try:
|
||||
with persistence.session_scope() as session:
|
||||
session.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
logger.warning("Health DB check failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"status": "ok" if db_ok else "degraded",
|
||||
"service": "encar-scraper-api",
|
||||
"database": "connected" if db_ok else "unavailable",
|
||||
}
|
||||
121
encar_scraper/api/routes/tasks.py
Normal file
121
encar_scraper/api/routes/tasks.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# Роуты запуска задач синхронизации Encar и просмотра истории sync-runs.
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from ..deps import get_persistence
|
||||
from ...storage.db import PersistenceService
|
||||
from ...storage.models import SyncRun
|
||||
from ...worker.celery_app import celery_app
|
||||
from ...worker.tasks import encar_sync_listing_task, encar_sync_vehicle_task
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SyncVehicleRequest(BaseModel):
|
||||
vehicle_url: str
|
||||
lane: str = "encar"
|
||||
|
||||
|
||||
class SyncListingRequest(BaseModel):
|
||||
car_type: str = "all" # all, domestic, import
|
||||
manufacturer: str | None = None
|
||||
year_from: int | None = None
|
||||
year_to: int | None = None
|
||||
price_max: int | None = None
|
||||
lane: str = "encar"
|
||||
limit: int | None = Field(default=None, le=500_000)
|
||||
|
||||
|
||||
@router.post("/tasks/sync-vehicle")
|
||||
def start_sync_vehicle(body: SyncVehicleRequest):
|
||||
"""Синхронизация одного авто Encar."""
|
||||
result = encar_sync_vehicle_task.apply_async(
|
||||
kwargs={"vehicle_url": body.vehicle_url, "lane": body.lane},
|
||||
queue="encar",
|
||||
)
|
||||
return {
|
||||
"task_id": result.id,
|
||||
"status": "queued",
|
||||
"vehicle_url": body.vehicle_url,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/sync-listing")
|
||||
def start_sync_listing(body: SyncListingRequest):
|
||||
"""Синхронизация листинга Encar через публичный API."""
|
||||
result = encar_sync_listing_task.apply_async(
|
||||
kwargs={
|
||||
"car_type": body.car_type,
|
||||
"manufacturer": body.manufacturer,
|
||||
"year_from": body.year_from,
|
||||
"year_to": body.year_to,
|
||||
"price_max": body.price_max,
|
||||
"lane": body.lane,
|
||||
"limit": body.limit,
|
||||
},
|
||||
queue="encar",
|
||||
)
|
||||
return {
|
||||
"task_id": result.id,
|
||||
"status": "queued",
|
||||
"car_type": body.car_type,
|
||||
"manufacturer": body.manufacturer,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
"""Статус задачи Celery."""
|
||||
result = celery_app.AsyncResult(task_id)
|
||||
|
||||
payload: dict = {
|
||||
"task_id": task_id,
|
||||
"state": result.state,
|
||||
}
|
||||
|
||||
if result.successful():
|
||||
payload["result"] = result.result
|
||||
elif result.failed():
|
||||
payload["error"] = str(result.result)
|
||||
elif result.info is not None:
|
||||
payload["meta"] = result.info
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/sync-runs")
|
||||
def list_sync_runs(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
persistence: PersistenceService = Depends(get_persistence),
|
||||
):
|
||||
"""История запусков синхронизации."""
|
||||
with persistence.session_scope() as session:
|
||||
total = session.execute(select(func.count(SyncRun.id))).scalar() or 0
|
||||
offset = (page - 1) * per_page
|
||||
runs = session.execute(
|
||||
select(SyncRun).order_by(SyncRun.started_at.desc()).offset(offset).limit(per_page)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"items": [
|
||||
{
|
||||
"id": run.id,
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||
"status": run.status,
|
||||
"lane": run.lane,
|
||||
"ids_fetched": run.ids_fetched,
|
||||
"cars_upserted": run.cars_upserted,
|
||||
"cars_failed": run.cars_failed,
|
||||
"images_upserted": run.images_upserted,
|
||||
"error_summary": run.error_summary,
|
||||
}
|
||||
for run in runs
|
||||
],
|
||||
}
|
||||
103
encar_scraper/cli.py
Normal file
103
encar_scraper/cli.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from .core.config import Settings
|
||||
from .core.utils import save_to_json
|
||||
from .encar import EncarScraper, EncarFilters, CAR_TYPE_ALL, CAR_TYPE_DOMESTIC, CAR_TYPE_IMPORT
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Encar scraper CLI")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
default_output_dir = Path("artifacts/json")
|
||||
|
||||
subparsers.add_parser("init-db", help="Create DB tables")
|
||||
|
||||
# collect-listing
|
||||
collect_parser = subparsers.add_parser("collect-listing", help="Collect Encar listing")
|
||||
collect_parser.add_argument("--limit", type=int, default=None, help="Max items to collect")
|
||||
collect_parser.add_argument("--car-type", choices=["all", "domestic", "import"], default="all",
|
||||
help="all=все, domestic=корейские, import=импорт")
|
||||
collect_parser.add_argument("--manufacturer", default=None, help="Filter by manufacturer (e.g. BMW, 현대)")
|
||||
collect_parser.add_argument("--year-from", type=int, default=None, help="Min year")
|
||||
collect_parser.add_argument("--year-to", type=int, default=None, help="Max year")
|
||||
collect_parser.add_argument("--price-max", type=int, default=None, help="Max price in 만원 (10k KRW)")
|
||||
collect_parser.add_argument("--output", default=str(default_output_dir / "encar_listing.json"))
|
||||
|
||||
# scrape-vehicle
|
||||
scrape_parser = subparsers.add_parser("scrape-vehicle", help="Fetch single vehicle data")
|
||||
scrape_parser.add_argument("vehicle_url")
|
||||
scrape_parser.add_argument("--output", default=str(default_output_dir / "encar_vehicle_detail.json"))
|
||||
|
||||
# sync-vehicle
|
||||
sync_parser = subparsers.add_parser("sync-vehicle", help="Sync single vehicle into DB")
|
||||
sync_parser.add_argument("vehicle_url")
|
||||
sync_parser.add_argument("--lane", default="encar")
|
||||
sync_parser.add_argument("--output", default=str(default_output_dir / "encar_sync_vehicle.json"))
|
||||
|
||||
# sync-listing
|
||||
sync_listing_parser = subparsers.add_parser("sync-listing", help="Collect listing and sync all to DB")
|
||||
sync_listing_parser.add_argument("--limit", type=int, default=None, help="Max items to sync")
|
||||
sync_listing_parser.add_argument("--car-type", choices=["all", "domestic", "import"], default="all")
|
||||
sync_listing_parser.add_argument("--manufacturer", default=None, help="Filter by manufacturer")
|
||||
sync_listing_parser.add_argument("--year-from", type=int, default=None)
|
||||
sync_listing_parser.add_argument("--year-to", type=int, default=None)
|
||||
sync_listing_parser.add_argument("--price-max", type=int, default=None)
|
||||
sync_listing_parser.add_argument("--lane", default="encar")
|
||||
sync_listing_parser.add_argument("--only-new", choices=["true", "false"], default="true")
|
||||
sync_listing_parser.add_argument("--output", default=str(default_output_dir / "encar_sync_listing.json"))
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _build_filters_from_args(args) -> EncarFilters:
|
||||
"""Строит фильтры из аргументов CLI."""
|
||||
car_type_map = {"all": CAR_TYPE_ALL, "domestic": CAR_TYPE_DOMESTIC, "import": CAR_TYPE_IMPORT}
|
||||
return EncarFilters(
|
||||
car_type=car_type_map.get(getattr(args, "car_type", "all"), CAR_TYPE_ALL),
|
||||
manufacturer=getattr(args, "manufacturer", None),
|
||||
year_from=getattr(args, "year_from", None),
|
||||
year_to=getattr(args, "year_to", None),
|
||||
price_to=getattr(args, "price_max", None),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import sys
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
scraper = EncarScraper()
|
||||
|
||||
if args.command == "init-db":
|
||||
result = scraper.init_db()
|
||||
print(f"DB initialized: {result}")
|
||||
return
|
||||
elif args.command == "collect-listing":
|
||||
filters = _build_filters_from_args(args)
|
||||
result = scraper.collect_listing(limit=args.limit, filters=filters)
|
||||
elif args.command == "scrape-vehicle":
|
||||
result = scraper.scrape_vehicle_detail(args.vehicle_url)
|
||||
elif args.command == "sync-vehicle":
|
||||
result = scraper.sync_vehicle(args.vehicle_url, lane=args.lane)
|
||||
elif args.command == "sync-listing":
|
||||
filters = _build_filters_from_args(args)
|
||||
only_new = args.only_new == "true"
|
||||
result = scraper.sync_listing(limit=args.limit, filters=filters, lane=args.lane, only_new=only_new)
|
||||
else:
|
||||
raise SystemExit(f"Unknown command: {args.command}")
|
||||
|
||||
save_to_json(result, Path(args.output))
|
||||
print(f"Saved to {Path(args.output).resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
encar_scraper/core/__init__.py
Normal file
1
encar_scraper/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__: list[str] = []
|
||||
106
encar_scraper/core/config.py
Normal file
106
encar_scraper/core/config.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# Хелперы для чтения env-переменных с приведением типов
|
||||
|
||||
def _env_str(name: str, default: str) -> str:
|
||||
value = os.getenv(name)
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def _env_optional_str(name: str) -> str | None:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _env_path_str(name: str) -> str | None:
|
||||
value = _env_optional_str(name)
|
||||
if value is None:
|
||||
return None
|
||||
return str(Path(value).expanduser())
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
fallback = "true" if default else "false"
|
||||
return _env_str(name, fallback).strip().lower() in TRUE_VALUES
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
return int(_env_str(name, str(default)).strip())
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
return float(_env_str(name, str(default)).strip())
|
||||
|
||||
|
||||
# Конфиг браузерного отпечатка (User-Agent для HTTP запросов)
|
||||
|
||||
@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"
|
||||
)
|
||||
|
||||
|
||||
# - Конфиг PostgreSQL (URL, пул соединений, pool_recycle)
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DatabaseConfig:
|
||||
url: str = _env_str("ENCAR_DATABASE_URL", "postgresql+psycopg2://encar:encar@localhost:5434/encar_db")
|
||||
echo: bool = _env_bool("ENCAR_DATABASE_ECHO", False)
|
||||
pool_size: int = _env_int("ENCAR_DATABASE_POOL_SIZE", 5)
|
||||
max_overflow: int = _env_int("ENCAR_DATABASE_MAX_OVERFLOW", 10)
|
||||
pool_recycle_seconds: int = _env_int("ENCAR_DATABASE_POOL_RECYCLE_SECONDS", 1800)
|
||||
auto_create_tables: bool = _env_bool("ENCAR_DATABASE_AUTO_CREATE_TABLES", False)
|
||||
|
||||
|
||||
# --- Конфиг Redis (URL для Celery broker) ---
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RedisConfig:
|
||||
url: str = _env_str("ENCAR_REDIS_URL", "redis://localhost:6380/1")
|
||||
|
||||
|
||||
# --- Конфиг Celery (лимиты задач, concurrency, beat-расписание) ---
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CeleryConfig:
|
||||
broker_url: str = _env_str("CELERY_BROKER_URL", "")
|
||||
result_backend: str = _env_str("CELERY_RESULT_BACKEND", "")
|
||||
task_soft_time_limit: int = _env_int("CELERY_TASK_SOFT_TIME_LIMIT", 14400)
|
||||
task_time_limit: int = _env_int("CELERY_TASK_TIME_LIMIT", 14520)
|
||||
worker_concurrency: int = _env_int("CELERY_WORKER_CONCURRENCY", 4)
|
||||
worker_max_tasks_per_child: int = _env_int("CELERY_WORKER_MAX_TASKS_PER_CHILD", 50)
|
||||
broker_visibility_timeout: int = _env_int("CELERY_BROKER_VISIBILITY_TIMEOUT", 7200)
|
||||
# Encar settings
|
||||
encar_beat_interval_minutes: int = _env_int("ENCAR_BEAT_INTERVAL_MINUTES", 60)
|
||||
encar_beat_limit: int | None = _env_int("ENCAR_BEAT_LIMIT", 0) or None
|
||||
|
||||
|
||||
# --- Главный объект настроек: собирает все блоки конфигурации ---
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Settings:
|
||||
log_level: str = _env_str("ENCAR_LOG_LEVEL", "INFO")
|
||||
log_file: str | None = _env_optional_str("ENCAR_LOG_FILE")
|
||||
fingerprint: FingerprintConfig = field(default_factory=FingerprintConfig)
|
||||
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
||||
redis: RedisConfig = field(default_factory=RedisConfig)
|
||||
celery: CeleryConfig = field(default_factory=CeleryConfig)
|
||||
|
||||
# Глобальный синглтон — используется по умолчанию во всех модулях.
|
||||
settings = Settings()
|
||||
2
encar_scraper/core/exceptions.py
Normal file
2
encar_scraper/core/exceptions.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class ScraperError(Exception):
|
||||
"""Базовое исключение скрапера."""
|
||||
32
encar_scraper/core/logs.py
Normal file
32
encar_scraper/core/logs.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
|
||||
# Храним trace_id текущего потока/корутины.
|
||||
TRACE_ID: ContextVar[str] = ContextVar("trace_id", default="-")
|
||||
|
||||
|
||||
class TraceIdFilter(logging.Filter):
|
||||
# Добавляет trace_id в каждую запись лога для сквозной трассировки.
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.trace_id = TRACE_ID.get()
|
||||
return True
|
||||
|
||||
|
||||
def set_trace_id(trace_id: str) -> None:
|
||||
TRACE_ID.set(trace_id)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", log_file: str | None = None) -> None:
|
||||
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
|
||||
if log_file:
|
||||
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
|
||||
trace_filter = TraceIdFilter()
|
||||
for handler in handlers:
|
||||
handler.addFilter(trace_filter)
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | trace=%(trace_id)s | %(message)s",
|
||||
handlers=handlers,
|
||||
force=True,
|
||||
)
|
||||
97
encar_scraper/core/runtime_config.py
Normal file
97
encar_scraper/core/runtime_config.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Загрузчик runtime_config.json для управления фильтрами синхронизации."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("encar_scraper.runtime_config")
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent.parent / "runtime_config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncConfig:
|
||||
name: str | None = None
|
||||
lane: str = "encar_cars"
|
||||
only_new: bool = False
|
||||
limit: int | None = None
|
||||
probe_all_photos: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FiltersConfig:
|
||||
price_min: int | None = None
|
||||
price_max: int | None = None
|
||||
mileage_min: int | None = None
|
||||
mileage_max: int | None = None
|
||||
brands: list[str] = field(default_factory=list)
|
||||
models: list[str] = field(default_factory=list)
|
||||
years: list[int] = field(default_factory=list)
|
||||
body_types: list[str] = field(default_factory=list)
|
||||
colors: list[str] = field(default_factory=list)
|
||||
drives: list[str] = field(default_factory=list)
|
||||
gearboxes: list[str] = field(default_factory=list)
|
||||
exclude_brands: list[str] = field(default_factory=list)
|
||||
exclude_models: list[str] = field(default_factory=list)
|
||||
exclude_years: list[int] = field(default_factory=list)
|
||||
exclude_body_types: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
sync: SyncConfig = field(default_factory=SyncConfig)
|
||||
filters: FiltersConfig = field(default_factory=FiltersConfig)
|
||||
|
||||
|
||||
def load_runtime_config(path: str | Path | None = None) -> RuntimeConfig:
|
||||
"""Загружает runtime_config.json. Если файл не найден — возвращает дефолт."""
|
||||
config_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
if not config_path.exists():
|
||||
logger.info("runtime_config.json not found at %s, using defaults", config_path)
|
||||
return RuntimeConfig()
|
||||
|
||||
try:
|
||||
raw: dict[str, Any] = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse runtime_config.json: %s, using defaults", exc)
|
||||
return RuntimeConfig()
|
||||
|
||||
sync_raw = raw.get("sync") or {}
|
||||
filters_raw = raw.get("filters") or {}
|
||||
price_raw = filters_raw.get("price") or {}
|
||||
mileage_raw = filters_raw.get("mileage") or {}
|
||||
|
||||
sync = SyncConfig(
|
||||
name=sync_raw.get("name"),
|
||||
lane=sync_raw.get("lane", "encar_cars"),
|
||||
only_new=bool(sync_raw.get("only_new", False)),
|
||||
limit=sync_raw.get("limit"),
|
||||
probe_all_photos=bool(sync_raw.get("probe_all_photos", False)),
|
||||
)
|
||||
|
||||
filters = FiltersConfig(
|
||||
price_min=price_raw.get("min"),
|
||||
price_max=price_raw.get("max"),
|
||||
mileage_min=mileage_raw.get("min"),
|
||||
mileage_max=mileage_raw.get("max"),
|
||||
brands=[b.lower() for b in (filters_raw.get("brands") or [])],
|
||||
models=[m.lower() for m in (filters_raw.get("models") or [])],
|
||||
years=filters_raw.get("years") or [],
|
||||
body_types=[b.lower() for b in (filters_raw.get("body_types") or [])],
|
||||
colors=[c.lower() for c in (filters_raw.get("colors") or [])],
|
||||
drives=[d.lower() for d in (filters_raw.get("drives") or [])],
|
||||
gearboxes=[g.lower() for g in (filters_raw.get("gearboxes") or [])],
|
||||
exclude_brands=[b.lower() for b in (filters_raw.get("exclude_brands") or [])],
|
||||
exclude_models=[m.lower() for m in (filters_raw.get("exclude_models") or [])],
|
||||
exclude_years=filters_raw.get("exclude_years") or [],
|
||||
exclude_body_types=[b.lower() for b in (filters_raw.get("exclude_body_types") or [])],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"runtime_config loaded: lane=%s, limit=%s, brands=%d, exclude_brands=%d",
|
||||
sync.lane, sync.limit, len(filters.brands), len(filters.exclude_brands),
|
||||
)
|
||||
return RuntimeConfig(sync=sync, filters=filters)
|
||||
72
encar_scraper/core/utils.py
Normal file
72
encar_scraper/core/utils.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def save_to_json(data: Any, filename: str | Path) -> None:
|
||||
path = Path(filename)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def first_non_empty(values: Iterable[Any]) -> Any | None:
|
||||
for value in values:
|
||||
if value not in (None, "", [], {}, ()):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
# Регулярные выражения для VIN, lot и price.
|
||||
VIN_RE = re.compile(r"\b([A-HJ-NPR-Z0-9]{17})\b", re.IGNORECASE)
|
||||
LOT_RE = re.compile(r"\b(\d{7,10})\b")
|
||||
PRICE_RE = re.compile(r"\$\s?([\d,]+(?:\.\d{1,2})?)")
|
||||
|
||||
|
||||
def deep_find_key(obj, target_keys: set[str], max_depth: int = 64, _depth: int = 0) -> list:
|
||||
# Рекурсивно ищет значения по набору ключей в произвольном JSON-дереве.
|
||||
found = []
|
||||
if _depth >= max_depth:
|
||||
return found
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if key.lower() in target_keys:
|
||||
found.append(value)
|
||||
found.extend(deep_find_key(value, target_keys, max_depth=max_depth, _depth=_depth + 1))
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
found.extend(deep_find_key(item, target_keys, max_depth=max_depth, _depth=_depth + 1))
|
||||
return found
|
||||
|
||||
|
||||
def deep_find_all_keys(
|
||||
payloads: list,
|
||||
field_map: dict[str, set[str]],
|
||||
max_depth: int = 64,
|
||||
) -> dict[str, list]:
|
||||
"""Извлекает все нужные поля за один проход по JSON."""
|
||||
# Готовим обратную карту: нормализованный ключ -> имя поля.
|
||||
reverse: dict[str, str] = {}
|
||||
for field_name, keys in field_map.items():
|
||||
for k in keys:
|
||||
reverse[k.lower()] = field_name
|
||||
|
||||
result: dict[str, list] = {f: [] for f in field_map}
|
||||
|
||||
def _recurse(obj: Any, depth: int) -> None:
|
||||
if depth >= max_depth:
|
||||
return
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
field = reverse.get(k.lower())
|
||||
if field is not None:
|
||||
result[field].append(v)
|
||||
_recurse(v, depth + 1)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_recurse(item, depth + 1)
|
||||
|
||||
for payload in payloads:
|
||||
_recurse(payload, 0)
|
||||
|
||||
return result
|
||||
1659
encar_scraper/encar.py
Normal file
1659
encar_scraper/encar.py
Normal file
File diff suppressed because it is too large
Load Diff
326
encar_scraper/partner_dictionary.py
Normal file
326
encar_scraper/partner_dictionary.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Korean -> English translation tables for EnCar (partner-provided basis).
|
||||
|
||||
Источник переводов от партнёра. Этот файл — эталон: бренды и модели берём
|
||||
отсюда в первую очередь. Держим секционные словари плюс один плоский для
|
||||
простых поисков.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
ENCAR_KO_EN_TABLES: dict[str, dict[str, str]] = {
|
||||
"makers": {
|
||||
"포르쉐": "Porsche",
|
||||
"메르세데스-벤츠": "Mercedes-Benz",
|
||||
"메르세데스 벤츠": "Mercedes-Benz",
|
||||
"벤츠": "Mercedes-Benz",
|
||||
"메르세데스-AMG": "Mercedes-AMG",
|
||||
"메르세데스 AMG": "Mercedes-AMG",
|
||||
"AMG": "AMG",
|
||||
"BMW": "BMW",
|
||||
"아우디": "Audi",
|
||||
"폭스바겐": "Volkswagen",
|
||||
"렉서스": "Lexus",
|
||||
"토요타": "Toyota",
|
||||
"닛산": "Nissan",
|
||||
"인피니티": "Infiniti",
|
||||
"혼다": "Honda",
|
||||
"마쯔다": "Mazda",
|
||||
"스바루": "Subaru",
|
||||
"미쓰비시": "Mitsubishi",
|
||||
"랜드로버": "Land Rover",
|
||||
"재규어": "Jaguar",
|
||||
"지프": "Jeep",
|
||||
"볼보": "Volvo",
|
||||
"푸조": "Peugeot",
|
||||
"르노": "Renault",
|
||||
"미니": "MINI",
|
||||
"로터스": "Lotus",
|
||||
"벤틀리": "Bentley",
|
||||
"롤스로이스": "Rolls-Royce",
|
||||
"람보르기니": "Lamborghini",
|
||||
"페라리": "Ferrari",
|
||||
"맥라렌": "McLaren",
|
||||
"애스턴마틴": "Aston Martin",
|
||||
"애스톤마틴": "Aston Martin",
|
||||
"마세라티": "Maserati",
|
||||
"코닉세그": "Koenigsegg",
|
||||
"파가니": "Pagani",
|
||||
"부가티": "Bugatti",
|
||||
"캐딜락": "Cadillac",
|
||||
"쉐보레": "Chevrolet",
|
||||
"포드": "Ford",
|
||||
"링컨": "Lincoln",
|
||||
"크라이슬러": "Chrysler",
|
||||
"닷지": "Dodge",
|
||||
"램": "Ram",
|
||||
"테슬라": "Tesla",
|
||||
"제네시스": "Genesis",
|
||||
"현대": "Hyundai",
|
||||
"기아": "Kia",
|
||||
"KG모빌리티": "KG Mobility",
|
||||
"쌍용": "SsangYong",
|
||||
"알파로메오": "Alfa Romeo",
|
||||
"피아트": "Fiat",
|
||||
"시트로엥": "Citroen",
|
||||
"스마트": "Smart",
|
||||
"폴스타": "Polestar",
|
||||
"리비안": "Rivian",
|
||||
"루시드": "Lucid",
|
||||
"BYD": "BYD",
|
||||
"지리": "Geely",
|
||||
"홍치": "Hongqi",
|
||||
"이스즈": "Isuzu",
|
||||
},
|
||||
"models": {
|
||||
"718 박스터": "718 Boxster",
|
||||
"718 카이맨": "718 Cayman",
|
||||
"911": "911",
|
||||
"911 카브리올레": "911 Cabriolet",
|
||||
"카이엔": "Cayenne",
|
||||
"카이엔 쿠페": "Cayenne Coupe",
|
||||
"파나메라": "Panamera",
|
||||
"파나메라 스포츠 투리스모": "Panamera Sport Turismo",
|
||||
"마칸": "Macan",
|
||||
"타이칸": "Taycan",
|
||||
"타이칸 크로스 투리스모": "Taycan Cross Turismo",
|
||||
"918 스파이더": "918 Spyder",
|
||||
"카레라 GT": "Carrera GT",
|
||||
"718 스파이더": "718 Spyder",
|
||||
"박스터": "Boxster",
|
||||
"카이맨": "Cayman",
|
||||
"4.0 스파이더": "4.0 Spyder",
|
||||
"C 클래스": "C-Class",
|
||||
"E 클래스": "E-Class",
|
||||
"S 클래스": "S-Class",
|
||||
"A 클래스": "A-Class",
|
||||
"B 클래스": "B-Class",
|
||||
"CLA": "CLA",
|
||||
"CLS": "CLS",
|
||||
"SL": "SL",
|
||||
"SLK": "SLK",
|
||||
"G 클래스": "G-Class",
|
||||
"GLA": "GLA",
|
||||
"GLB": "GLB",
|
||||
"GLC": "GLC",
|
||||
"GLE": "GLE",
|
||||
"GLS": "GLS",
|
||||
"AMG GT": "AMG GT",
|
||||
"AMG GT 4도어 쿠페": "AMG GT 4-Door Coupe",
|
||||
"V 클래스": "V-Class",
|
||||
"1 시리즈": "1 Series",
|
||||
"2 시리즈": "2 Series",
|
||||
"3 시리즈": "3 Series",
|
||||
"4 시리즈": "4 Series",
|
||||
"5 시리즈": "5 Series",
|
||||
"6 시리즈": "6 Series",
|
||||
"7 시리즈": "7 Series",
|
||||
"8 시리즈": "8 Series",
|
||||
"M2": "M2",
|
||||
"M3": "M3",
|
||||
"M4": "M4",
|
||||
"M5": "M5",
|
||||
"M8": "M8",
|
||||
"X1": "X1",
|
||||
"X2": "X2",
|
||||
"X3": "X3",
|
||||
"X4": "X4",
|
||||
"X5": "X5",
|
||||
"X6": "X6",
|
||||
"X7": "X7",
|
||||
"Z4": "Z4",
|
||||
"A3": "A3",
|
||||
"A4": "A4",
|
||||
"A5": "A5",
|
||||
"A6": "A6",
|
||||
"A7": "A7",
|
||||
"A8": "A8",
|
||||
"Q3": "Q3",
|
||||
"Q5": "Q5",
|
||||
"Q7": "Q7",
|
||||
"Q8": "Q8",
|
||||
"R8": "R8",
|
||||
"TT": "TT",
|
||||
"고스트": "Ghost",
|
||||
"팬텀": "Phantom",
|
||||
"레이스": "Wraith",
|
||||
"던": "Dawn",
|
||||
"컬리넌": "Cullinan",
|
||||
"스펙터": "Spectre",
|
||||
"벤테이가": "Bentayga",
|
||||
"컨티넨탈 GT": "Continental GT",
|
||||
"플라잉스퍼": "Flying Spur",
|
||||
"우라칸": "Huracan",
|
||||
"아벤타도르": "Aventador",
|
||||
"우루스": "Urus",
|
||||
"레부엘토": "Revuelto",
|
||||
"로마": "Roma",
|
||||
"포르토피노": "Portofino",
|
||||
"SF90 스트라달레": "SF90 Stradale",
|
||||
"F8 트리뷰토": "F8 Tributo",
|
||||
"콰트로포르테": "Quattroporte",
|
||||
"기블리": "Ghibli",
|
||||
"르반떼": "Levante",
|
||||
"그란투리스모": "GranTurismo",
|
||||
"반퀴시": "Vanquish",
|
||||
"밴티지": "Vantage",
|
||||
"DB11": "DB11",
|
||||
"DB12": "DB12",
|
||||
"DBS": "DBS",
|
||||
},
|
||||
"body_types": {
|
||||
"세단": "Sedan",
|
||||
"쿠페": "Coupe",
|
||||
"컨버터블": "Convertible",
|
||||
"카브리올레": "Cabriolet",
|
||||
"오픈카": "Open Top",
|
||||
"해치백": "Hatchback",
|
||||
"왜건": "Wagon",
|
||||
"스테이션왜건": "Station Wagon",
|
||||
"SUV": "SUV",
|
||||
"크로스오버": "Crossover",
|
||||
"픽업트럭": "Pickup Truck",
|
||||
"밴": "Van",
|
||||
"미니밴": "Minivan",
|
||||
"트럭": "Truck",
|
||||
"쿠페형 SUV": "Coupe SUV",
|
||||
"기타": "Other",
|
||||
},
|
||||
"fuel_types": {
|
||||
"가솔린": "Gasoline",
|
||||
"디젤": "Diesel",
|
||||
"경유": "Diesel",
|
||||
"하이브리드": "Hybrid",
|
||||
"가솔린 하이브리드": "Gasoline Hybrid",
|
||||
"플러그인 하이브리드": "Plug-in Hybrid",
|
||||
"전기": "Electric",
|
||||
"전기차": "Electric",
|
||||
"수소": "Hydrogen",
|
||||
"LPG": "LPG",
|
||||
"LNG": "LNG",
|
||||
"가솔린+전기": "Gasoline + Electric",
|
||||
"가솔린 + 전기": "Gasoline + Electric",
|
||||
},
|
||||
"transmissions": {
|
||||
"수동": "Manual",
|
||||
"오토": "Automatic",
|
||||
"자동": "Automatic",
|
||||
"자동변속": "Automatic",
|
||||
"세미오토": "Semi-automatic",
|
||||
"CVT": "CVT",
|
||||
"듀얼클러치": "Dual-clutch",
|
||||
},
|
||||
"sell_types": {
|
||||
"일반": "General",
|
||||
"리스": "Lease",
|
||||
"렌트": "Rent",
|
||||
"할부": "Installment",
|
||||
},
|
||||
"lease_types": {
|
||||
"운용리스": "Operating Lease",
|
||||
"금융리스": "Financial Lease",
|
||||
},
|
||||
"regions": {
|
||||
"서울": "Seoul",
|
||||
"부산": "Busan",
|
||||
"인천": "Incheon",
|
||||
"대구": "Daegu",
|
||||
"대전": "Daejeon",
|
||||
"광주": "Gwangju",
|
||||
"울산": "Ulsan",
|
||||
"세종": "Sejong",
|
||||
"경기": "Gyeonggi",
|
||||
"강원": "Gangwon",
|
||||
"충북": "Chungbuk",
|
||||
"충남": "Chungnam",
|
||||
"전북": "Jeonbuk",
|
||||
"전남": "Jeonnam",
|
||||
"경북": "Gyeongbuk",
|
||||
"경남": "Gyeongnam",
|
||||
"제주": "Jeju",
|
||||
},
|
||||
"dealer_companies": {
|
||||
"(주)기억": "Gieok Co., Ltd.",
|
||||
"(주)네바퀴": "Four Wheels Co., Ltd.",
|
||||
"(주)에이스타모터스": "A-Star Motors Co., Ltd.",
|
||||
"(주)우진 오토모빌": "Woojin Automobile Co., Ltd.",
|
||||
"(주)지온컴퍼니": "Zion Company Co., Ltd.",
|
||||
"(주)플로시모터스": "Flossy Motors Co., Ltd.",
|
||||
"㈜오토핸즈": "Auto Hands Co., Ltd.",
|
||||
"GD카": "GD Car",
|
||||
"MK모터스": "MK Motors",
|
||||
"꿈차": "Dream Car",
|
||||
"마이카4": "My Car 4",
|
||||
"블루오토": "Blue Auto",
|
||||
"세영모빌리티(주)": "Seyoung Mobility Co., Ltd.",
|
||||
"스마일자동차": "Smile Motors",
|
||||
"오토링크": "Auto Link",
|
||||
"오토핸즈": "Auto Hands",
|
||||
"우리들모터스": "Woorideul Motors",
|
||||
"주식회사 모터바겐": "Motorwagen Co., Ltd.",
|
||||
"주식회사 아이오토스": "iAutos Co., Ltd.",
|
||||
"주식회사이들컴퍼니": "Ideul Company Co., Ltd.",
|
||||
"카메이트": "Car Mate",
|
||||
"카톡모터스": "Katok Motors",
|
||||
"카피디": "Car PD",
|
||||
"포르쉐 인증 중고차 센터 성수": "Porsche Certified Used Car Center Seongsu",
|
||||
"포르쉐인증중고차센터성수": "Porsche Certified Used Car Center Seongsu",
|
||||
},
|
||||
"dealer_names": {
|
||||
"고준용": "Go Jun-yong",
|
||||
"공민중": "Gong Min-jung",
|
||||
"김경진": "Kim Gyeong-jin",
|
||||
"김명진": "Kim Myeong-jin",
|
||||
"김민수": "Kim Min-su",
|
||||
"김성언": "Kim Seong-eon",
|
||||
"김성진": "Kim Seong-jin",
|
||||
"김영균": "Kim Yeong-gyun",
|
||||
"김완섭": "Kim Wan-seop",
|
||||
"김현진": "Kim Hyeon-jin",
|
||||
"김형진": "Kim Hyeong-jin",
|
||||
"김호영": "Kim Ho-yeong",
|
||||
"문명기": "Moon Myeong-gi",
|
||||
"박원석": "Park Won-seok",
|
||||
"박재현": "Park Jae-hyeon",
|
||||
"방지윤": "Bang Ji-yun",
|
||||
"송슬기": "Song Seul-gi",
|
||||
"신영철": "Shin Yeong-cheol",
|
||||
"신호진": "Shin Ho-jin",
|
||||
"양시언": "Yang Si-eon",
|
||||
"양태규": "Yang Tae-gyu",
|
||||
"윤지완": "Yoon Ji-wan",
|
||||
"이경민": "Lee Gyeong-min",
|
||||
"이재욱": "Lee Jae-uk",
|
||||
"임종현": "Lim Jong-hyeon",
|
||||
"임형욱": "Lim Hyeong-uk",
|
||||
"전태준": "Jeon Tae-jun",
|
||||
"정상필": "Jeong Sang-pil",
|
||||
"조경식": "Jo Gyeong-sik",
|
||||
"조승재": "Jo Seung-jae",
|
||||
"조환성": "Jo Hwan-seong",
|
||||
"차상훈": "Cha Sang-hun",
|
||||
"최경욱": "Choi Gyeong-uk",
|
||||
"하상열": "Ha Sang-yeol",
|
||||
"허지훈": "Heo Ji-hun",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_flat_map() -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for section in ENCAR_KO_EN_TABLES.values():
|
||||
result.update(section)
|
||||
return result
|
||||
|
||||
|
||||
ENCAR_KO_EN_TRANSLATIONS: dict[str, str] = _build_flat_map()
|
||||
|
||||
|
||||
def translate_encar_ko_to_en(value: str, section: str | None = None) -> str:
|
||||
"""Перевод корейского термина Encar на английский.
|
||||
|
||||
Если задан `section`, используется только словарь этой секции. Иначе —
|
||||
плоский объединённый словарь.
|
||||
"""
|
||||
if section is not None:
|
||||
return ENCAR_KO_EN_TABLES.get(section, {}).get(value, value)
|
||||
return ENCAR_KO_EN_TRANSLATIONS.get(value, value)
|
||||
1
encar_scraper/storage/__init__.py
Normal file
1
encar_scraper/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__: list[str] = []
|
||||
497
encar_scraper/storage/db.py
Normal file
497
encar_scraper/storage/db.py
Normal file
@@ -0,0 +1,497 @@
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy import create_engine, delete, or_, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from ..core.config import Settings
|
||||
from .models import Base, Car, Image, SyncRun
|
||||
from .schemas import CarRecord
|
||||
|
||||
logger = logging.getLogger("encar_scraper.db")
|
||||
|
||||
|
||||
CAR_DB_FIELDS = {
|
||||
col.key for col in Car.__table__.columns
|
||||
if col.key not in ("id",)
|
||||
}
|
||||
|
||||
_IN_CHUNK_SIZE = 5000
|
||||
|
||||
|
||||
class PersistenceService:
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
engine_kwargs = {
|
||||
"echo": settings.database.echo,
|
||||
"future": True,
|
||||
}
|
||||
if "postgresql" in settings.database.url:
|
||||
engine_kwargs["pool_size"] = settings.database.pool_size
|
||||
engine_kwargs["max_overflow"] = settings.database.max_overflow
|
||||
engine_kwargs["pool_pre_ping"] = True
|
||||
engine_kwargs["pool_recycle"] = settings.database.pool_recycle_seconds
|
||||
self.engine = create_engine(settings.database.url, **engine_kwargs)
|
||||
self.session_factory = sessionmaker(bind=self.engine, expire_on_commit=False, future=True)
|
||||
|
||||
def create_tables(self) -> None:
|
||||
# В тестах/локально на SQLite разрешаем create_all; для non-SQLite в проде — только через миграции.
|
||||
is_sqlite = self.settings.database.url.startswith("sqlite")
|
||||
if not is_sqlite and not self.settings.database.auto_create_tables:
|
||||
return
|
||||
try:
|
||||
Base.metadata.create_all(self.engine)
|
||||
except Exception:
|
||||
# Не глотаем: таблицы могут уже существовать (ок), либо есть проблема
|
||||
# доступа — пусть вышестоящий код видит её при первой операции, но
|
||||
# сигнализируем в warning чтобы упростить диагностику.
|
||||
logger.warning("create_tables failed (continuing — schema may already exist)", exc_info=True)
|
||||
|
||||
@contextmanager
|
||||
def session_scope(self) -> Iterator[Session]:
|
||||
session = self.session_factory()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def start_sync_run(self, lane: str) -> int:
|
||||
with self.session_scope() as session:
|
||||
now = datetime.now(timezone.utc)
|
||||
stale_runs = session.execute(select(SyncRun).where(SyncRun.status == "running")).scalars().all()
|
||||
for stale in stale_runs:
|
||||
stale.status = "failed"
|
||||
stale.finished_at = now
|
||||
if not stale.error_summary:
|
||||
stale.error_summary = "Recovered stale running sync run before starting a new run"
|
||||
|
||||
run = SyncRun(status="running", lane=lane, ids_fetched=0, cars_upserted=0, cars_failed=0, images_upserted=0)
|
||||
session.add(run)
|
||||
session.flush()
|
||||
return int(run.id)
|
||||
|
||||
def finish_sync_run(self, run_id: int, *, status: str, ids_fetched: int, cars_upserted: int, cars_failed: int, images_upserted: int, error_summary: str | None = None) -> None:
|
||||
with self.session_scope() as session:
|
||||
run = session.get(SyncRun, run_id)
|
||||
if run is None:
|
||||
return
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
run.status = status
|
||||
run.ids_fetched = ids_fetched
|
||||
run.cars_upserted = cars_upserted
|
||||
run.cars_failed = cars_failed
|
||||
run.images_upserted = images_upserted
|
||||
run.error_summary = error_summary
|
||||
|
||||
@staticmethod
|
||||
def _add_images(session: Session, car_id: int, images: list[dict[str, object]]) -> None:
|
||||
if not images:
|
||||
return
|
||||
session.add_all([
|
||||
Image(
|
||||
fullres_image=str(img["fullres_image"]),
|
||||
preview_image=str(img["preview_image"]),
|
||||
order_index=int(img.get("order_index", 0)),
|
||||
car_id=car_id,
|
||||
)
|
||||
for img in images
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def _car_payload(record: CarRecord) -> dict[str, object]:
|
||||
payload = record.model_dump(mode="python")
|
||||
return {key: value for key, value in payload.items() if key in CAR_DB_FIELDS}
|
||||
|
||||
def _is_postgres(self) -> bool:
|
||||
return self.engine.dialect.name == "postgresql"
|
||||
|
||||
def _load_existing_cars(
|
||||
self,
|
||||
session: Session,
|
||||
origin_ids: list[str],
|
||||
origin_urls: list[str],
|
||||
) -> tuple[dict[str, Car], dict[str, Car]]:
|
||||
existing_by_id: dict[str, Car] = {}
|
||||
existing_by_url: dict[str, Car] = {}
|
||||
if not origin_ids and not origin_urls:
|
||||
return existing_by_id, existing_by_url
|
||||
|
||||
existing_cars: list[Car] = []
|
||||
max_len = max(len(origin_ids), len(origin_urls), 1)
|
||||
for i in range(0, max_len, _IN_CHUNK_SIZE):
|
||||
id_chunk = origin_ids[i:i + _IN_CHUNK_SIZE]
|
||||
url_chunk = origin_urls[i:i + _IN_CHUNK_SIZE]
|
||||
conditions = []
|
||||
if id_chunk:
|
||||
conditions.append(Car.origin_id.in_(id_chunk))
|
||||
if url_chunk:
|
||||
conditions.append(Car.origin_url.in_(url_chunk))
|
||||
if not conditions:
|
||||
continue
|
||||
rows = session.execute(
|
||||
select(Car).where(or_(*conditions))
|
||||
).scalars().all()
|
||||
existing_cars.extend(rows)
|
||||
|
||||
for car in existing_cars:
|
||||
if car.origin_id:
|
||||
existing_by_id[car.origin_id] = car
|
||||
if car.origin_url:
|
||||
existing_by_url[car.origin_url] = car
|
||||
return existing_by_id, existing_by_url
|
||||
|
||||
def _load_existing_image_urls(self, session: Session, car_ids: set[int]) -> dict[int, set[str]]:
|
||||
existing_images_map: dict[int, set[str]] = {}
|
||||
if not car_ids:
|
||||
return existing_images_map
|
||||
|
||||
car_id_list = list(car_ids)
|
||||
for i in range(0, len(car_id_list), _IN_CHUNK_SIZE):
|
||||
chunk = car_id_list[i:i + _IN_CHUNK_SIZE]
|
||||
img_rows = session.execute(
|
||||
select(Image.car_id, Image.fullres_image).where(Image.car_id.in_(chunk))
|
||||
).all()
|
||||
for cid, furl in img_rows:
|
||||
existing_images_map.setdefault(int(cid), set()).add(str(furl))
|
||||
return existing_images_map
|
||||
|
||||
@staticmethod
|
||||
def _postgres_upsert_set_map(insert_stmt) -> dict[str, object]:
|
||||
return {
|
||||
key: getattr(insert_stmt.excluded, key)
|
||||
for key in CAR_DB_FIELDS
|
||||
}
|
||||
|
||||
def _replace_images_for_car(
|
||||
self,
|
||||
session: Session,
|
||||
car_id: int,
|
||||
images: list[dict[str, object]],
|
||||
origin_id: str,
|
||||
) -> int:
|
||||
nested = session.begin_nested()
|
||||
try:
|
||||
session.execute(delete(Image).where(Image.car_id == car_id))
|
||||
self._add_images(session, car_id, images)
|
||||
session.flush()
|
||||
nested.commit()
|
||||
return len(images)
|
||||
except Exception:
|
||||
nested.rollback()
|
||||
logger.warning("Image replacement failed for car %s, keeping old images", origin_id, exc_info=True)
|
||||
return 0
|
||||
|
||||
def _upsert_cars_batch_postgres(self, records: list[CarRecord]) -> dict[str, int]:
|
||||
inserted = 0
|
||||
updated = 0
|
||||
images_total = 0
|
||||
|
||||
with self.session_scope() as session:
|
||||
origin_ids = [r.origin_id for r in records if r.origin_id]
|
||||
origin_urls = [r.origin_url for r in records if r.origin_url]
|
||||
existing_by_id, existing_by_url = self._load_existing_cars(session, origin_ids, origin_urls)
|
||||
|
||||
entries: list[dict[str, object]] = []
|
||||
upsert_payloads: list[dict[str, object]] = []
|
||||
for record in records:
|
||||
payload = self._car_payload(record)
|
||||
images = [image.model_dump(mode="python") for image in record.images]
|
||||
car_by_id = existing_by_id.get(record.origin_id)
|
||||
car_by_url = existing_by_url.get(record.origin_url)
|
||||
entry: dict[str, object] = {"record": record, "images": images, "car_id": None}
|
||||
|
||||
if car_by_url is not None and car_by_url.origin_id != record.origin_id and car_by_id is None:
|
||||
for key, value in payload.items():
|
||||
setattr(car_by_url, key, value)
|
||||
car_by_url.last_seen_at = record.last_seen_at
|
||||
entry["car_id"] = int(car_by_url.id)
|
||||
updated += 1
|
||||
else:
|
||||
upsert_payloads.append(payload)
|
||||
if car_by_id is not None:
|
||||
updated += 1
|
||||
else:
|
||||
inserted += 1
|
||||
entries.append(entry)
|
||||
|
||||
session.flush()
|
||||
|
||||
if upsert_payloads:
|
||||
insert_stmt = pg_insert(Car).values(upsert_payloads)
|
||||
upsert_stmt = insert_stmt.on_conflict_do_update(
|
||||
index_elements=[Car.origin_id],
|
||||
set_=self._postgres_upsert_set_map(insert_stmt),
|
||||
).returning(Car.id, Car.origin_id)
|
||||
rows = session.execute(upsert_stmt).all()
|
||||
car_ids_by_origin_id = {str(origin_id): int(car_id) for car_id, origin_id in rows}
|
||||
for entry in entries:
|
||||
if entry["car_id"] is not None:
|
||||
continue
|
||||
record = entry["record"]
|
||||
car_id = car_ids_by_origin_id.get(record.origin_id)
|
||||
if car_id is None:
|
||||
raise RuntimeError(f"PostgreSQL upsert did not return car_id for {record.origin_id}")
|
||||
entry["car_id"] = car_id
|
||||
|
||||
car_ids = {int(entry["car_id"]) for entry in entries if entry["car_id"] is not None}
|
||||
existing_images_map = self._load_existing_image_urls(session, car_ids)
|
||||
images_by_car_id: dict[int, list[dict[str, object]]] = {}
|
||||
replace_ids: list[int] = []
|
||||
|
||||
for entry in entries:
|
||||
car_id = int(entry["car_id"])
|
||||
images = entry["images"]
|
||||
new_image_urls = {
|
||||
str(img.get("fullres_image", ""))
|
||||
for img in images
|
||||
if img.get("fullres_image")
|
||||
}
|
||||
old_image_urls = existing_images_map.get(car_id, set())
|
||||
if new_image_urls != old_image_urls:
|
||||
replace_ids.append(car_id)
|
||||
images_by_car_id[car_id] = images
|
||||
else:
|
||||
images_total += len(old_image_urls)
|
||||
|
||||
if replace_ids:
|
||||
for i in range(0, len(replace_ids), _IN_CHUNK_SIZE):
|
||||
chunk = replace_ids[i:i + _IN_CHUNK_SIZE]
|
||||
session.execute(delete(Image).where(Image.car_id.in_(chunk)))
|
||||
for car_id in replace_ids:
|
||||
images = images_by_car_id[car_id]
|
||||
self._add_images(session, car_id, images)
|
||||
images_total += len(images)
|
||||
|
||||
return {"inserted": inserted, "updated": updated, "images_upserted": images_total}
|
||||
|
||||
def upsert_car(self, record: CarRecord):
|
||||
# Вставка или обновление автомобиля по origin_id/origin_url.
|
||||
payload = self._car_payload(record)
|
||||
images = [image.model_dump(mode="python") for image in record.images]
|
||||
with self.session_scope() as session:
|
||||
if self._is_postgres():
|
||||
car_by_url = session.execute(
|
||||
select(Car).where(Car.origin_url == record.origin_url)
|
||||
).scalar_one_or_none()
|
||||
if car_by_url is not None and car_by_url.origin_id != record.origin_id:
|
||||
for key, value in payload.items():
|
||||
setattr(car_by_url, key, value)
|
||||
car_by_url.last_seen_at = record.last_seen_at
|
||||
session.flush()
|
||||
car_id = int(car_by_url.id)
|
||||
action = "updated"
|
||||
else:
|
||||
existed = session.execute(
|
||||
select(Car.id).where(Car.origin_id == record.origin_id)
|
||||
).scalar_one_or_none() is not None
|
||||
insert_stmt = pg_insert(Car).values(**payload)
|
||||
upsert_stmt = insert_stmt.on_conflict_do_update(
|
||||
index_elements=[Car.origin_id],
|
||||
set_=self._postgres_upsert_set_map(insert_stmt),
|
||||
).returning(Car.id)
|
||||
car_id = int(session.execute(upsert_stmt).scalar_one())
|
||||
action = "updated" if existed or car_by_url is not None else "inserted"
|
||||
|
||||
images_upserted = self._replace_images_for_car(
|
||||
session, car_id, images, record.origin_id,
|
||||
)
|
||||
return {"car_id": car_id, "images_upserted": images_upserted, "action": action}
|
||||
|
||||
car = session.execute(
|
||||
select(Car).where(or_(Car.origin_id == record.origin_id, Car.origin_url == record.origin_url))
|
||||
).scalar_one_or_none()
|
||||
action = "inserted"
|
||||
if car is None:
|
||||
car = Car(**payload)
|
||||
session.add(car)
|
||||
session.flush()
|
||||
else:
|
||||
action = "updated"
|
||||
for key, value in payload.items():
|
||||
setattr(car, key, value)
|
||||
car.last_seen_at = record.last_seen_at
|
||||
session.flush()
|
||||
images_upserted = self._replace_images_for_car(session, int(car.id), images, record.origin_id)
|
||||
return {"car_id": int(car.id), "images_upserted": images_upserted, "action": action}
|
||||
self._add_images(session, int(car.id), images)
|
||||
session.flush()
|
||||
return {"car_id": int(car.id), "images_upserted": len(images), "action": action}
|
||||
def upsert_cars_batch(self, records: list[CarRecord]) -> dict[str, int]:
|
||||
"""Пакетный upsert нескольких автомобилей в одной транзакции.
|
||||
|
||||
Оптимизации:
|
||||
- Дедупликация записей по origin_id перед вставкой.
|
||||
- Chunked IN-queries для больших списков (обход лимита PG параметров).
|
||||
- Пропуск перезаписи изображений, если набор URL не изменился.
|
||||
- Один DELETE по car_id IN (...) вместо удаления по одному.
|
||||
- Fallback на по-одному upsert если batch commit упал.
|
||||
"""
|
||||
# ── Дедупликация записей внутри батча ──
|
||||
seen_ids: dict[str, int] = {}
|
||||
unique_records: list[CarRecord] = []
|
||||
for idx, r in enumerate(records):
|
||||
key = r.origin_id or r.origin_url
|
||||
if key in seen_ids:
|
||||
logger.debug("Dedup: skipping duplicate record %s (idx %d vs %d)", key, idx, seen_ids[key])
|
||||
continue
|
||||
seen_ids[key] = idx
|
||||
unique_records.append(r)
|
||||
|
||||
if len(unique_records) < len(records):
|
||||
logger.info("Deduped batch: %d → %d records", len(records), len(unique_records))
|
||||
records = unique_records
|
||||
|
||||
try:
|
||||
return self._upsert_cars_batch_inner(records)
|
||||
except Exception as exc:
|
||||
logger.warning("Batch upsert failed (%s), falling back to individual upserts", exc)
|
||||
return self._upsert_cars_individually(records)
|
||||
|
||||
def _upsert_cars_batch_inner(self, records: list[CarRecord]) -> dict[str, int]:
|
||||
"""Внутренняя реализация batched upsert (одна транзакция)."""
|
||||
if self._is_postgres():
|
||||
return self._upsert_cars_batch_postgres(records)
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
images_total = 0
|
||||
|
||||
with self.session_scope() as session:
|
||||
# Получаем существующие записи chunked-запросами.
|
||||
origin_ids = [r.origin_id for r in records if r.origin_id]
|
||||
origin_urls = [r.origin_url for r in records if r.origin_url]
|
||||
|
||||
existing_by_id, existing_by_url = self._load_existing_cars(session, origin_ids, origin_urls)
|
||||
|
||||
# Предзагружаем ВСЕ изображения для обновляемых машин одним запросом.
|
||||
existing_car_ids = set()
|
||||
for record in records:
|
||||
car = existing_by_id.get(record.origin_id) or existing_by_url.get(record.origin_url)
|
||||
if car is not None:
|
||||
existing_car_ids.add(int(car.id))
|
||||
|
||||
# Строим маппинг car_id → set(image_urls) для сравнения.
|
||||
existing_images_map = self._load_existing_image_urls(session, existing_car_ids)
|
||||
|
||||
new_cars: list[tuple[Car, list[dict]]] = []
|
||||
update_cars_needing_images: list[tuple[Car, list[dict]]] = []
|
||||
|
||||
for record in records:
|
||||
payload = self._car_payload(record)
|
||||
images = [image.model_dump(mode="python") for image in record.images]
|
||||
|
||||
car = existing_by_id.get(record.origin_id) or existing_by_url.get(record.origin_url)
|
||||
if car is None:
|
||||
car = Car(**payload)
|
||||
session.add(car)
|
||||
inserted += 1
|
||||
new_cars.append((car, images))
|
||||
else:
|
||||
for key, value in payload.items():
|
||||
setattr(car, key, value)
|
||||
car.last_seen_at = record.last_seen_at
|
||||
updated += 1
|
||||
|
||||
# Проверяем, изменились ли изображения.
|
||||
new_image_urls = {img.get("fullres_image", "") for img in images}
|
||||
old_image_urls = existing_images_map.get(int(car.id), set())
|
||||
if new_image_urls != old_image_urls:
|
||||
update_cars_needing_images.append((car, images))
|
||||
else:
|
||||
images_total += len(old_image_urls)
|
||||
|
||||
# Один flush для всех вставок.
|
||||
session.flush()
|
||||
|
||||
# Добавляем изображения для новых автомобилей.
|
||||
for car, images in new_cars:
|
||||
self._add_images(session, int(car.id), images)
|
||||
images_total += len(images)
|
||||
|
||||
# Массово обновляем изображения только для машин с изменёнными картинками.
|
||||
if update_cars_needing_images:
|
||||
update_ids = [int(car.id) for car, _ in update_cars_needing_images]
|
||||
for i in range(0, len(update_ids), _IN_CHUNK_SIZE):
|
||||
chunk = update_ids[i:i + _IN_CHUNK_SIZE]
|
||||
session.execute(delete(Image).where(Image.car_id.in_(chunk)))
|
||||
for car, images in update_cars_needing_images:
|
||||
self._add_images(session, int(car.id), images)
|
||||
images_total += len(images)
|
||||
|
||||
return {"inserted": inserted, "updated": updated, "images_upserted": images_total}
|
||||
|
||||
def _upsert_cars_individually(self, records: list[CarRecord]) -> dict[str, int]:
|
||||
# Fallback на поштучный upsert.
|
||||
inserted = 0
|
||||
updated = 0
|
||||
images_total = 0
|
||||
for record in records:
|
||||
try:
|
||||
result = self.upsert_car(record)
|
||||
action = result.get("action", "inserted")
|
||||
if action == "inserted":
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
images_total += int(result.get("images_upserted", 0))
|
||||
except Exception as exc:
|
||||
logger.error("Individual upsert failed for %s: %s", record.origin_id, exc)
|
||||
return {"inserted": inserted, "updated": updated, "images_upserted": images_total}
|
||||
|
||||
def mark_sold_not_in_listing(self, active_origin_ids: set[str], lane: str = "encar") -> int:
|
||||
"""Помечает авто как проданные, если их нет в активном листинге (по origin_id).
|
||||
|
||||
Для PostgreSQL использует временную таблицу + LEFT JOIN вместо NOT IN,
|
||||
что кардинально быстрее при больших объёмах (200K+ IDs).
|
||||
"""
|
||||
if not active_origin_ids:
|
||||
return 0
|
||||
with self.session_scope() as session:
|
||||
if self._is_postgres():
|
||||
session.execute(text("CREATE TEMP TABLE IF NOT EXISTS _active_ids (origin_id TEXT NOT NULL) ON COMMIT DROP"))
|
||||
session.execute(text("TRUNCATE _active_ids"))
|
||||
|
||||
id_list = list(active_origin_ids)
|
||||
for i in range(0, len(id_list), _IN_CHUNK_SIZE):
|
||||
chunk = id_list[i:i + _IN_CHUNK_SIZE]
|
||||
values = ",".join(f"(:{f'u{j}'})" for j in range(len(chunk)))
|
||||
params = {f"u{j}": oid for j, oid in enumerate(chunk)}
|
||||
session.execute(text(f"INSERT INTO _active_ids (origin_id) VALUES {values}"), params)
|
||||
|
||||
session.execute(text("CREATE INDEX IF NOT EXISTS _ix_active_ids ON _active_ids (origin_id)"))
|
||||
|
||||
result = session.execute(text("""
|
||||
UPDATE cars
|
||||
SET is_sold = TRUE
|
||||
FROM (
|
||||
SELECT c.id
|
||||
FROM cars c
|
||||
LEFT JOIN _active_ids a ON c.origin_id = a.origin_id
|
||||
WHERE a.origin_id IS NULL
|
||||
AND c.is_sold = FALSE
|
||||
AND c.origin_id LIKE 'encar:%%'
|
||||
) sub
|
||||
WHERE cars.id = sub.id
|
||||
"""))
|
||||
count = result.rowcount or 0
|
||||
else:
|
||||
stmt = (
|
||||
update(Car)
|
||||
.where(Car.origin_id.notin_(active_origin_ids))
|
||||
.where(Car.is_sold == False) # noqa: E712
|
||||
.where(Car.origin_id.like("encar:%"))
|
||||
.values(is_sold=True)
|
||||
)
|
||||
result = session.execute(stmt)
|
||||
count = result.rowcount or 0
|
||||
if count:
|
||||
logger.info("Marked %d cars as sold (no longer in listing)", count)
|
||||
return count
|
||||
36
encar_scraper/storage/enums.py
Normal file
36
encar_scraper/storage/enums.py
Normal file
@@ -0,0 +1,36 @@
|
||||
CURRENCY_ENUM_VALUES = ("JPY", "USD", "EUR", "RUB", "KRW", "AED", "GBP", "CAD")
|
||||
DRIVE_ENUM_VALUES = ("FWD", "RWD", "2WD", "4WD", "NA")
|
||||
GEARBOX_ENUM_VALUES = ("AT", "CVT", "MT", "EV", "NA")
|
||||
STEERING_WHEEL_ENUM_VALUES = ("LEFT", "RIGHT", "NA")
|
||||
BODY_TYPE_ENUM_VALUES = (
|
||||
"COUPE",
|
||||
"SUV",
|
||||
"HATCHBACK",
|
||||
"MINIVAN",
|
||||
"SEDAN",
|
||||
"STATION_WAGON",
|
||||
"PICKUP",
|
||||
"TRUCK",
|
||||
"OPEN",
|
||||
"RV",
|
||||
"VAN",
|
||||
"CONVERTIBLE",
|
||||
"BUS",
|
||||
"COMPACT",
|
||||
"MID_SIZE",
|
||||
"FULL_SIZE",
|
||||
"SPORTS",
|
||||
"CLASSIC",
|
||||
"ELECTRIC",
|
||||
"HYBRID",
|
||||
"SPECIAL",
|
||||
"WAGON",
|
||||
"OTHER",
|
||||
"NA",
|
||||
)
|
||||
COUNTRY_ENUM_VALUES = ("JP", "KR", "US", "CA", "NA")
|
||||
ORIGIN_ENUM_VALUES = (
|
||||
"ENCAR",
|
||||
"NA",
|
||||
)
|
||||
SELLING_TYPE_ENUM_VALUES = ("STOCK", "AUCTION", "TENDER", "NA")
|
||||
82
encar_scraper/storage/models.py
Normal file
82
encar_scraper/storage/models.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
from .enums import (
|
||||
BODY_TYPE_ENUM_VALUES,
|
||||
COUNTRY_ENUM_VALUES,
|
||||
CURRENCY_ENUM_VALUES,
|
||||
DRIVE_ENUM_VALUES,
|
||||
GEARBOX_ENUM_VALUES,
|
||||
ORIGIN_ENUM_VALUES,
|
||||
SELLING_TYPE_ENUM_VALUES,
|
||||
STEERING_WHEEL_ENUM_VALUES,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Car(Base):
|
||||
__tablename__ = "cars"
|
||||
__table_args__ = (
|
||||
Index("ix_cars_brand_model", "brand", "model"),
|
||||
Index("ix_cars_origin_id_not_sold", "origin_id", "is_sold"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
parser_id: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
brand: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
model: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
year: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
price: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
currency: Mapped[str] = mapped_column(Enum(*CURRENCY_ENUM_VALUES, name="currencyenum", native_enum=False, create_constraint=False), nullable=False, default="USD")
|
||||
mileage: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
country: Mapped[str] = mapped_column(Enum(*COUNTRY_ENUM_VALUES, name="countryenum", native_enum=False, create_constraint=False), nullable=False, default="NA")
|
||||
is_sold: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
color: Mapped[str] = mapped_column(String(), nullable=False, default="other")
|
||||
drive: Mapped[str | None] = mapped_column(Enum(*DRIVE_ENUM_VALUES, name="driveenum", native_enum=False, create_constraint=False), nullable=True)
|
||||
gearbox: Mapped[str | None] = mapped_column(Enum(*GEARBOX_ENUM_VALUES, name="gearboxenum", native_enum=False, create_constraint=False), nullable=True)
|
||||
steering_wheel: Mapped[str | None] = mapped_column(Enum(*STEERING_WHEEL_ENUM_VALUES, name="steeringwheelenum", native_enum=False, create_constraint=False), nullable=True)
|
||||
body_type: Mapped[str] = mapped_column(Enum(*BODY_TYPE_ENUM_VALUES, name="bodytypeenum", native_enum=False, create_constraint=False), nullable=False, default="OTHER")
|
||||
engine_volume: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
selling_type: Mapped[str] = mapped_column(Enum(*SELLING_TYPE_ENUM_VALUES, name="sellingtypeenum", native_enum=False, create_constraint=False), nullable=False, default="NA")
|
||||
one_owner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
new_car: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_hidden: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_ENUM_VALUES, name="originenum", native_enum=False, create_constraint=False), nullable=False, default="NA")
|
||||
origin_url: Mapped[str] = mapped_column(String(), nullable=False, index=True)
|
||||
origin_id: Mapped[str] = mapped_column(String(), nullable=False, unique=True, index=True)
|
||||
is_damaged: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
evaluation: Mapped[str | None] = mapped_column(String(), nullable=True)
|
||||
non_smoking: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
rental: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
repair_history: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
slug: Mapped[str] = mapped_column(String(), nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
|
||||
images: Mapped[list["Image"]] = relationship("Image", back_populates="car", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Image(Base):
|
||||
__tablename__ = "images"
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
fullres_image: Mapped[str] = mapped_column(String(), nullable=False)
|
||||
preview_image: Mapped[str] = mapped_column(String(), nullable=False)
|
||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
car_id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), ForeignKey("cars.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
car: Mapped[Car] = relationship("Car", back_populates="images")
|
||||
|
||||
|
||||
class SyncRun(Base):
|
||||
__tablename__ = "sync_runs"
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, index=True)
|
||||
lane: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
ids_fetched: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
cars_upserted: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
cars_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
images_upserted: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
87
encar_scraper/storage/schemas.py
Normal file
87
encar_scraper/storage/schemas.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ImageRecord(BaseModel):
|
||||
fullres_image: str
|
||||
preview_image: str
|
||||
order_index: int = 0
|
||||
|
||||
|
||||
class CarRecord(BaseModel):
|
||||
parser_id: str
|
||||
brand: str
|
||||
model: str
|
||||
year: int | None = None
|
||||
price: int | None = None
|
||||
currency: str = "USD"
|
||||
mileage: int = 0
|
||||
country: str = "US"
|
||||
is_sold: bool = False
|
||||
color: str = "other"
|
||||
drive: str | None = None
|
||||
gearbox: str | None = None
|
||||
steering_wheel: str | None = None
|
||||
body_type: str = "OTHER"
|
||||
engine_volume: int | None = None
|
||||
selling_type: str = "AUCTION"
|
||||
one_owner: bool = False
|
||||
new_car: bool = False
|
||||
is_hidden: bool = False
|
||||
origin: str = "NA"
|
||||
origin_url: str
|
||||
origin_id: str
|
||||
is_damaged: bool = False
|
||||
evaluation: str | None = None
|
||||
non_smoking: bool = True
|
||||
rental: bool = False
|
||||
repair_history: bool = False
|
||||
slug: str
|
||||
last_seen_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
images: list[ImageRecord] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
fullres_image: str
|
||||
preview_image: str
|
||||
order_index: int = 0
|
||||
|
||||
|
||||
class CarRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
parser_id: str
|
||||
brand: str
|
||||
model: str
|
||||
year: int | None = None
|
||||
price: int | None = None
|
||||
currency: str = "USD"
|
||||
mileage: int = 0
|
||||
country: str = "US"
|
||||
is_sold: bool = False
|
||||
color: str = "other"
|
||||
drive: str | None = None
|
||||
gearbox: str | None = None
|
||||
steering_wheel: str | None = None
|
||||
body_type: str = "OTHER"
|
||||
engine_volume: int | None = None
|
||||
selling_type: str = "AUCTION"
|
||||
one_owner: bool = False
|
||||
new_car: bool = False
|
||||
is_hidden: bool = False
|
||||
origin: str = "NA"
|
||||
origin_url: str = ""
|
||||
origin_id: str = ""
|
||||
is_damaged: bool = False
|
||||
evaluation: str | None = None
|
||||
non_smoking: bool = True
|
||||
rental: bool = False
|
||||
repair_history: bool = False
|
||||
slug: str = ""
|
||||
last_seen_at: datetime | None = None
|
||||
images: list[ImageRead] = Field(default_factory=list)
|
||||
|
||||
997
encar_scraper/translations.py
Normal file
997
encar_scraper/translations.py
Normal file
@@ -0,0 +1,997 @@
|
||||
# Словари переводов корейского текста русский / английский.
|
||||
# Используется в encar.py для маппинга данных Encar API.
|
||||
#
|
||||
# Эталон переводов брендов и моделей — словарь партнёра
|
||||
# (`partner_dictionary.py`). Он подмешивается ниже как база: значения оттуда
|
||||
# имеют приоритет для брендов и моделей, а специфичные для проекта записи
|
||||
# (корейские модели Hyundai/Kia/Genesis и т.п., которых нет у партнёра)
|
||||
# сохраняются как дополнение.
|
||||
|
||||
import re
|
||||
|
||||
from .partner_dictionary import ENCAR_KO_EN_TABLES as _PARTNER_TABLES
|
||||
|
||||
|
||||
# Топливо
|
||||
|
||||
FUEL_TYPE_MAP = {
|
||||
"가솔린": "Gasoline",
|
||||
"gasoline": "Gasoline",
|
||||
"디젤": "Diesel",
|
||||
"경유": "Diesel",
|
||||
"diesel": "Diesel",
|
||||
"전기": "Electric",
|
||||
"전기차": "Electric",
|
||||
"electric": "Electric",
|
||||
"하이브리드": "Hybrid",
|
||||
"hybrid": "Hybrid",
|
||||
"가솔린 하이브리드": "Gasoline Hybrid",
|
||||
"플러그인 하이브리드": "Plug-in Hybrid",
|
||||
"lpg": "LPG",
|
||||
"LPG": "LPG",
|
||||
"LPG(일반인 구입)": "LPG",
|
||||
"LPG(장애인 구입)": "LPG",
|
||||
"LNG": "LNG",
|
||||
"수소": "Hydrogen",
|
||||
"가솔린+전기": "Gasoline + Electric",
|
||||
"가솔린 + 전기": "Gasoline + Electric",
|
||||
"디젤+전기": "Hybrid",
|
||||
"CNG": "CNG",
|
||||
}
|
||||
|
||||
|
||||
# КПП
|
||||
|
||||
GEARBOX_MAP_KO = {
|
||||
"오토": "AT",
|
||||
"자동": "AT",
|
||||
"자동변속": "AT",
|
||||
"수동": "MT",
|
||||
"세미오토": "AT",
|
||||
"cvt": "CVT",
|
||||
"CVT": "CVT",
|
||||
"듀얼클러치": "AT",
|
||||
}
|
||||
|
||||
|
||||
# Тип продажи
|
||||
|
||||
SELL_TYPE_MAP = {
|
||||
"일반": "STOCK",
|
||||
"경매": "AUCTION",
|
||||
"입찰": "TENDER",
|
||||
"리스": "STOCK",
|
||||
"렌트": "STOCK",
|
||||
"할부": "STOCK",
|
||||
}
|
||||
|
||||
|
||||
# Город → страна
|
||||
|
||||
CITY_TO_COUNTRY = {
|
||||
"서울": "KR", "경기": "KR", "인천": "KR", "부산": "KR", "대구": "KR",
|
||||
"광주": "KR", "대전": "KR", "울산": "KR", "세종": "KR", "강원": "KR",
|
||||
"충북": "KR", "충남": "KR", "전북": "KR", "전남": "KR", "경북": "KR",
|
||||
"경남": "KR", "제주": "KR",
|
||||
}
|
||||
|
||||
|
||||
# Бренды: корейский → английский
|
||||
|
||||
BRAND_TRANSLATIONS = {
|
||||
"현대": "Hyundai",
|
||||
"기아": "Kia",
|
||||
"쉐보레(GM대우)": "Chevrolet",
|
||||
"벤츠": "Mercedes-Benz",
|
||||
"메르세데스-벤츠": "Mercedes-Benz",
|
||||
"메르세데스 벤츠": "Mercedes-Benz",
|
||||
"메르세데스-AMG": "Mercedes-AMG",
|
||||
"메르세데스 AMG": "Mercedes-AMG",
|
||||
"AMG": "AMG",
|
||||
"BMW": "BMW",
|
||||
"아우디": "Audi",
|
||||
"폭스바겐": "Volkswagen",
|
||||
"토요타": "Toyota",
|
||||
"도요타": "Toyota",
|
||||
"닛산": "Nissan",
|
||||
"혼다": "Honda",
|
||||
"포르쉐": "Porsche",
|
||||
"랜드로버": "Land Rover",
|
||||
"재규어": "Jaguar",
|
||||
"볼보": "Volvo",
|
||||
"푸조": "Peugeot",
|
||||
"시트로엥": "Citroen",
|
||||
"르노": "Renault",
|
||||
"미니": "MINI",
|
||||
"링컨": "Lincoln",
|
||||
"캐딜락": "Cadillac",
|
||||
"크라이슬러": "Chrysler",
|
||||
"지프": "Jeep",
|
||||
"포드": "Ford",
|
||||
"테슬라": "Tesla",
|
||||
"폴크스바겐": "Volkswagen",
|
||||
"스코다": "Skoda",
|
||||
"세아트": "SEAT",
|
||||
"오펠": "Opel",
|
||||
"피아트": "Fiat",
|
||||
"알파로메오": "Alfa Romeo",
|
||||
"마세라티": "Maserati",
|
||||
"람보르기니": "Lamborghini",
|
||||
"페라리": "Ferrari",
|
||||
"벤틀리": "Bentley",
|
||||
"롤스로이스": "Rolls-Royce",
|
||||
"애스턴마틴": "Aston Martin",
|
||||
"애스톤마틴": "Aston Martin",
|
||||
"맥라렌": "McLaren",
|
||||
"부가티": "Bugatti",
|
||||
"코닉세그": "Koenigsegg",
|
||||
"파가니": "Pagani",
|
||||
"헤네시": "Hennessey",
|
||||
"쉘비": "Shelby",
|
||||
"다임러": "Daimler",
|
||||
"마이바흐": "Maybach",
|
||||
"스마트": "Smart",
|
||||
"DS": "DS",
|
||||
"렉서스": "Lexus",
|
||||
"인피니티": "Infiniti",
|
||||
"아큐라": "Acura",
|
||||
"스바루": "Subaru",
|
||||
"미쓰비시": "Mitsubishi",
|
||||
"마쓰다": "Mazda",
|
||||
"이스즈": "Isuzu",
|
||||
"다이하츠": "Daihatsu",
|
||||
"스즈키": "Suzuki",
|
||||
"KG모빌리티(쌍용)": "KG Mobility",
|
||||
"KG모빌리티": "KG Mobility",
|
||||
"르노코리아(삼성)": "Renault Korea",
|
||||
"한국GM": "GM Korea",
|
||||
"쌍용": "SsangYong",
|
||||
"대우": "Daewoo",
|
||||
"제네시스": "Genesis",
|
||||
"르노삼성": "Renault Samsung",
|
||||
"로터스": "Lotus",
|
||||
"리비안": "Rivian",
|
||||
"루시드": "Lucid",
|
||||
"리막": "Rimac",
|
||||
"알파 로메오": "Alfa Romeo",
|
||||
"마쯔다": "Mazda",
|
||||
"어큐라": "Acura",
|
||||
"다이하쯔": "Daihatsu",
|
||||
# --- Новые бренды (с сайта Encar) ---
|
||||
"쉐보레": "Chevrolet",
|
||||
"시트로엥/DS": "Citroen/DS",
|
||||
"미쯔비시": "Mitsubishi",
|
||||
"미쯔오까": "Mitsuoka",
|
||||
"동풍소콘": "Dongfeng Sokon",
|
||||
"북기은상": "BAIC",
|
||||
"신위안": "Skywell",
|
||||
"이네오스": "Ineos",
|
||||
"지리": "Geely",
|
||||
"홍치": "Hongqi",
|
||||
"BYD": "BYD",
|
||||
"머큐리": "Mercury",
|
||||
"사이언": "Scion",
|
||||
"뷰익": "Buick",
|
||||
"새턴": "Saturn",
|
||||
"올즈모빌": "Oldsmobile",
|
||||
"폰티악": "Pontiac",
|
||||
"포톤": "Foton",
|
||||
"MG로버": "MG Rover",
|
||||
"닷지": "Dodge",
|
||||
"램": "Ram",
|
||||
"험머": "Hummer",
|
||||
"폴스타": "Polestar",
|
||||
"사브": "Saab",
|
||||
"기타 제조사": "Other",
|
||||
"기타 수입차": "Other Imported",
|
||||
}
|
||||
|
||||
# Подмешиваем бренды из эталонного словаря партнёра как базу. Существующие
|
||||
# записи проекта имеют приоритет (не перезаписываются), новые из словаря
|
||||
# партнёра добавляются.
|
||||
for _ko, _en in _PARTNER_TABLES["makers"].items():
|
||||
BRAND_TRANSLATIONS.setdefault(_ko, _en)
|
||||
|
||||
|
||||
# Бренды: английский → алиасы (корейский + русский)
|
||||
|
||||
BRAND_ENGLISH_ALIASES: dict[str, list[str]] = {
|
||||
"hyundai": ["현대", "Хёндэ"],
|
||||
"kia": ["기아", "Киа"],
|
||||
"genesis": ["제네시스", "Генезис"],
|
||||
"chevrolet": ["쉐보레(GM대우)", "쉐보레", "Шевроле"],
|
||||
"mercedes": ["벤츠", "Мерседес-Бенц"],
|
||||
"mercedes-benz": ["벤츠", "Мерседес-Бенц"],
|
||||
"bmw": ["BMW"],
|
||||
"audi": ["아우디", "Ауди"],
|
||||
"volkswagen": ["폭스바겐", "폴크스바겐", "Фольксваген"],
|
||||
"toyota": ["토요타", "도요타", "Тойота"],
|
||||
"nissan": ["닛산", "Ниссан"],
|
||||
"honda": ["혼다", "Хонда"],
|
||||
"porsche": ["포르쉐", "Порше"],
|
||||
"land rover": ["랜드로버", "Ленд Ровер"],
|
||||
"jaguar": ["재규어", "Ягуар"],
|
||||
"volvo": ["볼보", "Вольво"],
|
||||
"peugeot": ["푸조", "Пежо"],
|
||||
"citroen": ["시트로엥", "Ситроен", "시트로엥/DS", "Ситроен/DS"],
|
||||
"renault": ["르노", "Рено"],
|
||||
"mini": ["미니", "Мини"],
|
||||
"lincoln": ["링컨", "Линкольн"],
|
||||
"cadillac": ["캐딜락", "Кадиллак"],
|
||||
"chrysler": ["크라이슬러", "Крайслер"],
|
||||
"jeep": ["지프", "Джип"],
|
||||
"ford": ["포드", "Форд"],
|
||||
"tesla": ["테슬라", "Тесла"],
|
||||
"lexus": ["렉서스", "Лексус"],
|
||||
"infiniti": ["인피니티", "Инфинити"],
|
||||
"acura": ["아큐라", "어큐라", "Акура"],
|
||||
"subaru": ["스바루", "Субару"],
|
||||
"mitsubishi": ["미쓰비시", "미쯔비시", "Мицубиси"],
|
||||
"mazda": ["마쓰다", "마쯔다", "Мазда"],
|
||||
"suzuki": ["스즈키", "Сузуки"],
|
||||
"ferrari": ["페라리", "Феррари"],
|
||||
"lamborghini": ["람보르기니", "Ламборгини"],
|
||||
"maserati": ["마세라티", "Мазерати"],
|
||||
"bentley": ["벤틀리", "Бентли"],
|
||||
"rolls-royce": ["롤스로이스", "Роллс-Ройс"],
|
||||
"aston martin": ["애스턴마틴", "Астон Мартин"],
|
||||
"mclaren": ["맥라렌", "Макларен"],
|
||||
"ssangyong": ["쌍용", "СсангЙонг", "KG모빌리티(쌍용)", "KG Mobility"],
|
||||
"kg mobility": ["KG모빌리티(쌍용)", "KG Mobility", "쌍용", "СсангЙонг"],
|
||||
"daewoo": ["대우", "Дэу"],
|
||||
"dodge": ["닷지", "Додж"],
|
||||
"hummer": ["험머", "Хаммер"],
|
||||
"gmc": ["GMC"],
|
||||
"polestar": ["폴스타", "Полстар"],
|
||||
"ds": ["DS", "시트로엥/DS", "Ситроен/DS"],
|
||||
"renault korea": ["르노코리아(삼성)", "Рено Корея", "르노삼성", "Рено Самсунг"],
|
||||
"saab": ["사브", "Сааб"],
|
||||
"lotus": ["로터스", "Лотус"],
|
||||
"alfa romeo": ["알파로메오", "Альфа Ромео", "알파 로메오"],
|
||||
"fiat": ["피아트", "Фиат"],
|
||||
"smart": ["스마트", "Смарт"],
|
||||
"skoda": ["스코다", "Шкода"],
|
||||
"daihatsu": ["다이하츠", "다이하쯔", "Дайхатсу"],
|
||||
"koenigsegg": ["코닉세그", "Кёнигсегг"],
|
||||
"pagani": ["파가니", "Пагани"],
|
||||
"bugatti": ["부가티", "Бугатти"],
|
||||
"maybach": ["마이바흐", "Майбах"],
|
||||
"rivian": ["리비안", "Ривиан"],
|
||||
"lucid": ["루시드", "Люсид"],
|
||||
"rimac": ["리막", "Римак"],
|
||||
"byd": ["BYD"],
|
||||
"geely": ["지리", "Джили"],
|
||||
"dongfeng": ["동풍소콘", "Донгфенг Сокон"],
|
||||
"baic": ["북기은상", "BAIC"],
|
||||
"skywell": ["신위안", "Skywell"],
|
||||
"ineos": ["이네오스", "Инеос"],
|
||||
"mercury": ["머큐리", "Меркурий"],
|
||||
"scion": ["사이언", "Scion"],
|
||||
"buick": ["뷰익", "Бьюик"],
|
||||
"saturn": ["새턴", "Saturn"],
|
||||
"oldsmobile": ["올즈모빌", "Oldsmobile"],
|
||||
"pontiac": ["폰티악", "Понтиак"],
|
||||
"foton": ["포톤", "Foton"],
|
||||
"mg rover": ["MG로버", "MG Rover"],
|
||||
"mitsuoka": ["미쯔오까", "Мицуока"],
|
||||
"other": ["기타 제조사", "기타 수입차", "Другие", "Другие импортные"],
|
||||
}
|
||||
|
||||
|
||||
def expand_allowed_brands(brands: set[str] | None) -> set[str] | None:
|
||||
"""Расширяет набор английских брендов всеми известными alias'ами (корейский + русский)."""
|
||||
if not brands:
|
||||
return brands
|
||||
expanded = set(brands)
|
||||
for brand in brands:
|
||||
aliases = BRAND_ENGLISH_ALIASES.get(brand.lower(), [])
|
||||
for alias in aliases:
|
||||
expanded.add(alias.lower())
|
||||
return expanded
|
||||
|
||||
|
||||
|
||||
# Модели / комплектации: корейский → английский
|
||||
|
||||
# Точные переводы базовых названий моделей Encar. Этот словарь используется
|
||||
# перед частичным переводом и не должен содержать общие слова вроде "뉴".
|
||||
MODEL_EXACT_TRANSLATIONS = {
|
||||
# BMW / Mercedes-Benz
|
||||
"1시리즈": "1 Series",
|
||||
"2시리즈": "2 Series",
|
||||
"3시리즈": "3 Series",
|
||||
"4시리즈": "4 Series",
|
||||
"5시리즈": "5 Series",
|
||||
"6시리즈": "6 Series",
|
||||
"7시리즈": "7 Series",
|
||||
"8시리즈": "8 Series",
|
||||
"A-클래스": "A-Class",
|
||||
"B-클래스": "B-Class",
|
||||
"C-클래스": "C-Class",
|
||||
"E-클래스": "E-Class",
|
||||
"S-클래스": "S-Class",
|
||||
"SL-클래스": "SL-Class",
|
||||
"SLK-클래스": "SLK-Class",
|
||||
"GLA-클래스": "GLA-Class",
|
||||
"GLB-클래스": "GLB-Class",
|
||||
"GLC-클래스": "GLC-Class",
|
||||
"GLE-클래스": "GLE-Class",
|
||||
"GLS-클래스": "GLS-Class",
|
||||
"V-클래스": "V-Class",
|
||||
# Hyundai / Kia / Genesis
|
||||
"팰리세이드": "Palisade",
|
||||
"싼타페": "Santa Fe",
|
||||
"투싼": "Tucson",
|
||||
"아반떼": "Avante",
|
||||
"쏘나타": "Sonata",
|
||||
"쏘나타 디 엣지": "Sonata The Edge",
|
||||
"그랜저": "Grandeur",
|
||||
"아이오닉5": "Ioniq 5",
|
||||
"아이오닉6": "Ioniq 6",
|
||||
"아이오닉": "Ioniq",
|
||||
"넥쏘": "Nexo",
|
||||
"벨로스터": "Veloster",
|
||||
"코나": "Kona",
|
||||
"캐스퍼": "Casper",
|
||||
"베뉴": "Venue",
|
||||
"스타렉스": "Starex",
|
||||
"스타리아": "Staria",
|
||||
"아슬란": "Aslan",
|
||||
"베라크루즈": "Veracruz",
|
||||
"카니발": "Carnival",
|
||||
"쏘렌토": "Sorento",
|
||||
"스포티지": "Sportage",
|
||||
"에스컬레이드": "Escalade",
|
||||
"프리미어": "Premier",
|
||||
"1세대": "1st gen",
|
||||
"2세대": "2nd gen",
|
||||
"3세대": "3rd gen",
|
||||
"4세대": "4th gen",
|
||||
"5세대": "5th gen",
|
||||
"6세대": "6th gen",
|
||||
"7세대": "7th gen",
|
||||
"8세대": "8th gen",
|
||||
"모닝": "Morning",
|
||||
"레이": "Ray",
|
||||
"셀토스": "Seltos",
|
||||
"니로": "Niro",
|
||||
"스팅어": "Stinger",
|
||||
"쏘울": "Soul",
|
||||
"모하비": "Mohave",
|
||||
"프라이드": "Pride",
|
||||
"스토닉": "Stonic",
|
||||
"카렌스": "Carens",
|
||||
"제네시스": "Genesis",
|
||||
# Chevrolet / Renault Korea / KG Mobility
|
||||
"스파크": "Spark",
|
||||
"말리부": "Malibu",
|
||||
"트랙스": "Trax",
|
||||
"크루즈": "Cruze",
|
||||
"올란도": "Orlando",
|
||||
"트레일블레이저": "Trailblazer",
|
||||
"콜로라도": "Colorado",
|
||||
"트래버스": "Traverse",
|
||||
"캡티바": "Captiva",
|
||||
"이쿼녹스": "Equinox",
|
||||
"아베오": "Aveo",
|
||||
"볼트": "Volt",
|
||||
"임팔라": "Impala",
|
||||
"맥스크루즈": "Max Cruz",
|
||||
"렉스턴 스포츠 칸": "Rexton Sport Khan",
|
||||
"렉스턴 스포츠": "Rexton Sport",
|
||||
"렉스턴": "Rexton",
|
||||
"토레스": "Torres",
|
||||
"티볼리 에어": "Tivoli Air",
|
||||
"티볼리 아머": "Tivoli Armor",
|
||||
"티볼리": "Tivoli",
|
||||
"코란도 투리스모": "Korando Turismo",
|
||||
"코란도 스포츠": "Korando Sport",
|
||||
"코란도": "Korando",
|
||||
"체어맨": "Chairman",
|
||||
"오피러스": "Opirus",
|
||||
# Import models written in Korean
|
||||
"익스플로러": "Explorer",
|
||||
"머스탱": "Mustang",
|
||||
"카이엔": "Cayenne",
|
||||
"카이엔 쿠페": "Cayenne Coupe",
|
||||
"파나메라": "Panamera",
|
||||
"파나메라 스포츠 투리스모": "Panamera Sport Turismo",
|
||||
"마칸": "Macan",
|
||||
"타이칸": "Taycan",
|
||||
"타이칸 크로스 투리스모": "Taycan Cross Turismo",
|
||||
"918 스파이더": "918 Spyder",
|
||||
"718 스파이더": "718 Spyder",
|
||||
"4.0 스파이더": "4.0 Spyder",
|
||||
"박스터": "Boxster",
|
||||
"카이맨": "Cayman",
|
||||
"911 카브리올레": "911 Cabriolet",
|
||||
"카레라": "Carrera",
|
||||
"티구안": "Tiguan",
|
||||
"골프": "Golf",
|
||||
"제타": "Jetta",
|
||||
"투아렉": "Touareg",
|
||||
"파사트": "Passat",
|
||||
"아테온": "Arteon",
|
||||
"쿠퍼": "Cooper",
|
||||
"컨트리맨": "Countryman",
|
||||
"클럽맨": "Clubman",
|
||||
"디스커버리": "Discovery",
|
||||
"레인지로버": "Range Rover",
|
||||
"이보크": "Evoque",
|
||||
"벨라": "Velar",
|
||||
"디펜더": "Defender",
|
||||
"랭글러": "Wrangler",
|
||||
"체로키": "Cherokee",
|
||||
"레니게이드": "Renegade",
|
||||
"컴패스": "Compass",
|
||||
"루비콘": "Rubicon",
|
||||
"에비에이터": "Aviator",
|
||||
"르반떼": "Levante",
|
||||
"기블리": "Ghibli",
|
||||
"어코드": "Accord",
|
||||
"캠리": "Camry",
|
||||
"프리우스": "Prius",
|
||||
"타스만": "Tasman",
|
||||
"토러스": "Taurus",
|
||||
"카마로": "Camaro",
|
||||
"비틀": "Beetle",
|
||||
"e-트론": "e-tron",
|
||||
"글래디에이터": "Gladiator",
|
||||
"노틸러스": "Nautilus",
|
||||
"시로코": "Scirocco",
|
||||
"아틀라스": "Atlas",
|
||||
"알파드": "Alphard",
|
||||
"익스페디션": "Expedition",
|
||||
"익스프레스Van": "Express Van",
|
||||
"짐니": "Jimny",
|
||||
"쿠가": "Kuga",
|
||||
"클리오": "Clio",
|
||||
"파일럿": "Pilot",
|
||||
"콰트로포르테": "Quattroporte",
|
||||
"고스트": "Ghost",
|
||||
"팬텀": "Phantom",
|
||||
"레이스": "Wraith",
|
||||
"던": "Dawn",
|
||||
"컬리넌": "Cullinan",
|
||||
"스펙터": "Spectre",
|
||||
"벤테이가": "Bentayga",
|
||||
"컨티넨탈 GT": "Continental GT",
|
||||
"플라잉스퍼": "Flying Spur",
|
||||
"우라칸": "Huracan",
|
||||
"아벤타도르": "Aventador",
|
||||
"우루스": "Urus",
|
||||
"레부엘토": "Revuelto",
|
||||
"로마": "Roma",
|
||||
"포르토피노": "Portofino",
|
||||
"SF90 스트라달레": "SF90 Stradale",
|
||||
"F8 트리뷰토": "F8 Tributo",
|
||||
"그란투리스모": "GranTurismo",
|
||||
"반퀴시": "Vanquish",
|
||||
"밴티지": "Vantage",
|
||||
"AMG GT 4도어 쿠페": "AMG GT 4-Door Coupe",
|
||||
}
|
||||
|
||||
# Подмешиваем модели из эталонного словаря партнёра как базу. Существующие
|
||||
# записи проекта имеют приоритет. Дополнительно добавляем нормализованные
|
||||
# варианты ключей партнёра (напр. "5 시리즈" -> "5시리즈", "S 클래스" -> "S-클래스"),
|
||||
# т.к. в сырых данных Encar формат отличается пробелами/дефисами.
|
||||
def _register_partner_models() -> None:
|
||||
for _ko, _en in _PARTNER_TABLES["models"].items():
|
||||
MODEL_EXACT_TRANSLATIONS.setdefault(_ko, _en)
|
||||
# "5 시리즈" -> "5시리즈"
|
||||
_compact = re.sub(r"(?<=\d)\s+시리즈", "시리즈", _ko)
|
||||
# "S 클래스" -> "S-클래스"
|
||||
_compact = re.sub(r"(?<=[A-Za-z])\s+클래스", "-클래스", _compact)
|
||||
if _compact != _ko:
|
||||
MODEL_EXACT_TRANSLATIONS.setdefault(_compact, _en)
|
||||
|
||||
|
||||
_register_partner_models()
|
||||
|
||||
# Безопасные составные фразы. Применяются только к строкам, где есть корейский текст.
|
||||
MODEL_PHRASE_TRANSLATIONS = {
|
||||
"올 뉴": "All New",
|
||||
"더 뉴": "New",
|
||||
"디 올 뉴": "The All New",
|
||||
"베리 뉴": "Very New",
|
||||
"베리": "Very",
|
||||
"뷰티풀": "Beautiful",
|
||||
"넥스트": "Next",
|
||||
"디 ": "The ",
|
||||
"디 엣지": "The Edge",
|
||||
"액티브 투어러": "Active Tourer",
|
||||
"투어러": "Tourer",
|
||||
"그란쿠페": "GranCoupe",
|
||||
"그란 쿠페": "GranCoupe",
|
||||
"스포츠 칸": "Sport Khan",
|
||||
"스포츠": "Sport",
|
||||
"하이브리드": "Hybrid",
|
||||
"일렉트릭": "Electric",
|
||||
"네오": "Neo",
|
||||
"트랜스폼": "Transform",
|
||||
"프라임": "Prime",
|
||||
"마스터": "Master",
|
||||
"어반": "Urban",
|
||||
"볼드": "Bold",
|
||||
"아머": "Armor",
|
||||
"에어": "Air",
|
||||
}
|
||||
|
||||
# Короткие токены переводятся только как отдельные токены, не внутри других слов.
|
||||
MODEL_TOKEN_TRANSLATIONS = {
|
||||
"시리즈": "Series",
|
||||
"클래스": "Class",
|
||||
"세대": "gen",
|
||||
"뉴": "New",
|
||||
"올": "All",
|
||||
"칸": "Khan",
|
||||
}
|
||||
|
||||
MODEL_TRANSLATIONS = {
|
||||
# --- Топливо / тип двигателя ---
|
||||
"가솔린": "Gasoline",
|
||||
"디젤": "Diesel",
|
||||
"하이브리드": "Hybrid",
|
||||
"전기": "Electric",
|
||||
"터보": "Turbo",
|
||||
"일렉트릭": "Electric",
|
||||
"일렉트리파이드": "Electrified",
|
||||
"블루텍": "BlueTEC",
|
||||
"블루모션": "BlueMotion",
|
||||
# --- Кузов / конструкция ---
|
||||
"쿠페": "Coupe",
|
||||
"세단": "Sedan",
|
||||
"왜건": "Wagon",
|
||||
"웨건": "Wagon",
|
||||
"해치백": "Hatchback",
|
||||
"컨버터블": "Convertible",
|
||||
"카브리올레": "Cabriolet",
|
||||
"크로스오버": "Crossover",
|
||||
"리무진": "Limousine",
|
||||
"하이리무진": "High Limousine",
|
||||
"하이리무진(특장업체)": "High Limousine (Special)",
|
||||
"스포트백": "Sportback",
|
||||
"스파이더": "Spyder",
|
||||
"밴": "Van",
|
||||
"픽업": "Pickup",
|
||||
"캠핑카": "Camper",
|
||||
# --- КПП / привод ---
|
||||
"오토": "Auto",
|
||||
"수동": "Manual",
|
||||
"후륜": "RWD",
|
||||
"전륜": "FWD",
|
||||
"사륜": "AWD",
|
||||
"4모션": "4Motion",
|
||||
# --- Трим / комплектация ---
|
||||
"프레스티지": "Prestige",
|
||||
"시그니처": "Signature",
|
||||
"럭셔리": "Luxury",
|
||||
"스포츠": "Sport",
|
||||
"롱레인지": "Long Range",
|
||||
"스탠다드": "Standard",
|
||||
"플러스": "Plus",
|
||||
"리미티드": "Limited",
|
||||
"스페셜": "Special",
|
||||
"모던": "Modern",
|
||||
"익스클루시브": "Exclusive",
|
||||
"캘리그래피": "Calligraphy",
|
||||
"노블레스": "Noblesse",
|
||||
"트림": "Trim",
|
||||
"프리미엄": "Premium",
|
||||
"프리미어": "Premier",
|
||||
"플래티넘": "Platinum",
|
||||
"플래티넘Ⅱ": "Platinum II",
|
||||
"인스퍼레이션": "Inspiration",
|
||||
"트렌디": "Trendy",
|
||||
"다이나믹": "Dynamic",
|
||||
"프라임": "Prime",
|
||||
"초이스": "Choice",
|
||||
"르블랑": "Le Blanc",
|
||||
"프레지던트": "President",
|
||||
"인스크립션": "Inscription",
|
||||
"볼드": "Bold",
|
||||
"밸류": "Value",
|
||||
"퓨어": "Pure",
|
||||
"엑셀런스": "Excellence",
|
||||
"얼티메이트": "Ultimate",
|
||||
"에센셜": "Essential",
|
||||
"슈프림": "Supreme",
|
||||
"에어": "Air",
|
||||
"어반": "Urban",
|
||||
"탑": "Top",
|
||||
"코어": "Core",
|
||||
"엘리트": "Elite",
|
||||
"셀렉션": "Selection",
|
||||
"브라이트": "Bright",
|
||||
"클래식": "Classic",
|
||||
"고급형": "Deluxe",
|
||||
"최고급형": "Top Grade",
|
||||
"기본형": "Base",
|
||||
"디럭스": "Deluxe",
|
||||
"디럭스팩": "Deluxe Pack",
|
||||
"마이스터": "Meister",
|
||||
"마스터": "Master",
|
||||
"마스터즈": "Masters",
|
||||
"어메이징": "Amazing",
|
||||
"파워": "Power",
|
||||
"리저브": "Reserve",
|
||||
"퍼펙트": "Perfect",
|
||||
"리얼": "Real",
|
||||
"울트라": "Ultra",
|
||||
"세이프티": "Safety",
|
||||
"퍼포먼스": "Performance",
|
||||
"인텔리전트": "Intelligent",
|
||||
"컬렉션": "Collection",
|
||||
"브릴리언트": "Brilliant",
|
||||
"어드밴티지": "Advantage",
|
||||
"파이니스트": "Finest",
|
||||
"런치팩": "Launch Pack",
|
||||
"페이버드": "Favored",
|
||||
"엘레강스": "Elegance",
|
||||
"하이": "High",
|
||||
"블랙라벨": "Black Label",
|
||||
"플럭스": "Flux",
|
||||
"테크": "Tech",
|
||||
"프로": "Pro",
|
||||
"베스트": "Best",
|
||||
"라이즈": "Rise",
|
||||
"액티브": "Active",
|
||||
"롱": "Long",
|
||||
"패키지": "Package",
|
||||
"에디션": "Edition",
|
||||
"디자인": "Design",
|
||||
"라운지": "Lounge",
|
||||
"아트": "Art",
|
||||
"팝": "Pop",
|
||||
"조이": "Joy",
|
||||
"레드라인": "Redline",
|
||||
"스타일": "Style",
|
||||
"블랙": "Black",
|
||||
"라이트": "Light",
|
||||
"온라인": "Online",
|
||||
"론지튜드": "Longitude",
|
||||
"오버랜드": "Overland",
|
||||
"컴페티션": "Competition",
|
||||
"올스페이스": "Allspace",
|
||||
"스타": "Star",
|
||||
# --- Поколение / номер ---
|
||||
"1세대": "1st gen",
|
||||
"2세대": "2nd gen",
|
||||
"3세대": "3rd gen",
|
||||
"4세대": "4th gen",
|
||||
"5세대": "5th gen",
|
||||
"6세대": "6th gen",
|
||||
"7세대": "7th gen",
|
||||
"8세대": "8th gen",
|
||||
"10세대": "10th gen",
|
||||
"더": "",
|
||||
"뉴": "New",
|
||||
"넥스트": "Next",
|
||||
"올": "All",
|
||||
"그란": "Gran",
|
||||
"그랜드": "Grand",
|
||||
# --- Посадочных мест ---
|
||||
"2인승": "2-seater",
|
||||
"3인승": "3-seater",
|
||||
"5인승": "5-seater",
|
||||
"6인승": "6-seater",
|
||||
"7인승": "7-seater",
|
||||
"9인승": "9-seater",
|
||||
"11인승": "11-seater",
|
||||
"12인승": "12-seater",
|
||||
# --- Двери ---
|
||||
"4도어": "4-door",
|
||||
"5도어": "5-door",
|
||||
# --- Модели Hyundai ---
|
||||
"팰리세이드": "Palisade",
|
||||
"싼타페": "Santa Fe",
|
||||
"투싼": "Tucson",
|
||||
"아반떼": "Avante",
|
||||
"쏘나타": "Sonata",
|
||||
"그랜저": "Grandeur",
|
||||
"아이오닉5": "Ioniq 5",
|
||||
"아이오닉6": "Ioniq 6",
|
||||
"아이오닉": "Ioniq",
|
||||
"넥쏘": "Nexo",
|
||||
"벨로스터": "Veloster",
|
||||
"코나": "Kona",
|
||||
"캐스퍼": "Casper",
|
||||
"베뉴": "Venue",
|
||||
"스타렉스": "Starex",
|
||||
"스타리아": "Staria",
|
||||
"에쿠스(신형)": "Equus",
|
||||
"엑센트(신형)": "Accent",
|
||||
"아슬란": "Aslan",
|
||||
"베라크루즈": "Veracruz",
|
||||
# --- Модели Kia ---
|
||||
"카니발": "Carnival",
|
||||
"쏘렌토": "Sorento",
|
||||
"스포티지": "Sportage",
|
||||
"모닝": "Morning",
|
||||
"뉴모닝": "New Morning",
|
||||
"레이": "Ray",
|
||||
"셀토스": "Seltos",
|
||||
"니로": "Niro",
|
||||
"스팅어": "Stinger",
|
||||
"쏘울": "Soul",
|
||||
"모하비": "Mohave",
|
||||
"프라이드": "Pride",
|
||||
"스토닉": "Stonic",
|
||||
# --- Модели Chevrolet/GM ---
|
||||
"스파크": "Spark",
|
||||
"말리부": "Malibu",
|
||||
"트랙스": "Trax",
|
||||
"크루즈": "Cruze",
|
||||
"올란도": "Orlando",
|
||||
"트레일블레이저": "Trailblazer",
|
||||
"콜로라도": "Colorado",
|
||||
"트래버스": "Traverse",
|
||||
"캡티바": "Captiva",
|
||||
"이쿼녹스": "Equinox",
|
||||
"아베오": "Aveo",
|
||||
"볼트": "Volt",
|
||||
"임팔라": "Impala",
|
||||
"맥스크루즈": "Max Cruz",
|
||||
# --- Модели Genesis ---
|
||||
"제네시스": "Genesis",
|
||||
# --- Модели иностранных авто ---
|
||||
"익스플로러": "Explorer",
|
||||
"머스탱": "Mustang",
|
||||
"카이엔": "Cayenne",
|
||||
"파나메라": "Panamera",
|
||||
"마칸": "Macan",
|
||||
"타이칸": "Taycan",
|
||||
"박스터": "Boxster",
|
||||
"카이맨": "Cayman",
|
||||
"카레라": "Carrera",
|
||||
"티구안": "Tiguan",
|
||||
"골프": "Golf",
|
||||
"제타": "Jetta",
|
||||
"투아렉": "Touareg",
|
||||
"파사트": "Passat",
|
||||
"아테온": "Arteon",
|
||||
"콰트로": "quattro",
|
||||
"아방가르드": "Avantgarde",
|
||||
"쿠퍼": "Cooper",
|
||||
"컨트리맨": "Countryman",
|
||||
"클럽맨": "Clubman",
|
||||
"디스커버리": "Discovery",
|
||||
"레인지로버": "Range Rover",
|
||||
"이보크": "Evoque",
|
||||
"벨라": "Velar",
|
||||
"디펜더": "Defender",
|
||||
"랭글러": "Wrangler",
|
||||
"체로키": "Cherokee",
|
||||
"체로키(KL)": "Cherokee (KL)",
|
||||
"체로키(WL)": "Cherokee (WL)",
|
||||
"레니게이드": "Renegade",
|
||||
"컴패스": "Compass",
|
||||
"루비콘": "Rubicon",
|
||||
"에비에이터": "Aviator",
|
||||
"에스컬레이드": "Escalade",
|
||||
"컨티넨탈": "Continental",
|
||||
"르반떼": "Levante",
|
||||
"기블리": "Ghibli",
|
||||
"알티마": "Altima",
|
||||
"오딧세이": "Odyssey",
|
||||
"어코드": "Accord",
|
||||
"캠리": "Camry",
|
||||
"시에나": "Sienna",
|
||||
"프리우스": "Prius",
|
||||
"크로스컨트리": "Cross Country",
|
||||
"타스만": "Tasman",
|
||||
"고스트": "Ghost",
|
||||
"팬텀": "Phantom",
|
||||
"레이스": "Wraith",
|
||||
"던": "Dawn",
|
||||
"컬리넌": "Cullinan",
|
||||
"스펙터": "Spectre",
|
||||
"벤테이가": "Bentayga",
|
||||
"컨티넨탈 GT": "Continental GT",
|
||||
"플라잉스퍼": "Flying Spur",
|
||||
"우라칸": "Huracan",
|
||||
"아벤타도르": "Aventador",
|
||||
"우루스": "Urus",
|
||||
"레부엘토": "Revuelto",
|
||||
"로마": "Roma",
|
||||
"포르토피노": "Portofino",
|
||||
"SF90 스트라달레": "SF90 Stradale",
|
||||
"F8 트리뷰토": "F8 Tributo",
|
||||
"콰트로포르테": "Quattroporte",
|
||||
"그란투리스모": "GranTurismo",
|
||||
"반퀴시": "Vanquish",
|
||||
"밴티지": "Vantage",
|
||||
# --- Дополнительные модели, встреченные в данных Encar ---
|
||||
"시에라": "Sierra",
|
||||
"시빅": "Civic",
|
||||
"레인저": "Ranger",
|
||||
"콜레오스": "Koleos",
|
||||
"그랑 콜레오스": "Grand Koleos",
|
||||
"페이톤": "Phaeton",
|
||||
"티록": "T-Roc",
|
||||
"큐브": "Cube",
|
||||
"크라운": "Crown",
|
||||
"라이즈": "Rise",
|
||||
"시로코": "Scirocco",
|
||||
"이오스": "Eos",
|
||||
"비틀": "Beetle",
|
||||
"폴로": "Polo",
|
||||
# --- Служебные / категории ---
|
||||
"(세부등급": "(sub-trim",
|
||||
"없음)": "none)",
|
||||
"(렌터카)": "(rental)",
|
||||
"(렌터카용)": "(for rental)",
|
||||
"LPG(렌터카용)": "LPG (for rental)",
|
||||
"LPI(렌터카용)": "LPI (for rental)",
|
||||
"(택시형)": "(taxi)",
|
||||
"택시형": "taxi",
|
||||
"어린이보호차": "child transport",
|
||||
"카고": "Cargo",
|
||||
"모델": "Model",
|
||||
"스마트": "Smart",
|
||||
"기아": "Kia",
|
||||
"마이바흐": "Maybach",
|
||||
"레인지": "Range",
|
||||
}
|
||||
|
||||
|
||||
# Цвета: корейский → английский
|
||||
|
||||
COLOR_TRANSLATIONS = {
|
||||
"흰색": "white",
|
||||
"검정색": "black",
|
||||
"회색": "gray",
|
||||
"은색": "silver",
|
||||
"진회색": "dark gray",
|
||||
"밝은 회색": "light gray",
|
||||
"청색": "blue",
|
||||
"파란색": "blue",
|
||||
"진청색": "dark blue",
|
||||
"밝은 청색": "light blue",
|
||||
"녹색": "green",
|
||||
"초록색": "green",
|
||||
"진녹색": "dark green",
|
||||
"밝은 녹색": "light green",
|
||||
"적색": "red",
|
||||
"빨간색": "red",
|
||||
"진적색": "dark red",
|
||||
"밝은 적색": "light red",
|
||||
"주황색": "orange",
|
||||
"노란색": "yellow",
|
||||
"갈색": "brown",
|
||||
"베이지색": "beige",
|
||||
"아이보리": "ivory",
|
||||
"크림색": "cream",
|
||||
"보라색": "purple",
|
||||
"분홍색": "pink",
|
||||
"금색": "gold",
|
||||
"청금색": "blue-gold",
|
||||
"청은색": "blue-silver",
|
||||
"흑청색": "black-blue",
|
||||
"흑적색": "black-red",
|
||||
"흑녹색": "black-green",
|
||||
"흑회색": "black-gray",
|
||||
"흰청색": "white-blue",
|
||||
"흰적색": "white-red",
|
||||
"흰녹색": "white-green",
|
||||
"흰회색": "white-gray",
|
||||
"청적색": "blue-red",
|
||||
"청녹색": "blue-green",
|
||||
"청회색": "blue-gray",
|
||||
"적녹색": "red-green",
|
||||
"적회색": "red-gray",
|
||||
"녹회색": "green-gray",
|
||||
"기타": "other",
|
||||
"기타색상": "other",
|
||||
"특별색상": "special",
|
||||
"무채색": "neutral",
|
||||
"메탈릭": "metallic",
|
||||
"펄": "pearl",
|
||||
"무광": "matte",
|
||||
"광택": "glossy",
|
||||
}
|
||||
|
||||
|
||||
# Тип кузова: корейский → enum
|
||||
|
||||
BODY_TYPE_MAP = {
|
||||
"세단": "SEDAN",
|
||||
"쿠페": "COUPE",
|
||||
"컨버터블": "CONVERTIBLE",
|
||||
"카브리올레": "CONVERTIBLE",
|
||||
"오픈카": "OPEN",
|
||||
"해치백": "HATCHBACK",
|
||||
"왜건": "WAGON",
|
||||
"스테이션왜건": "STATION_WAGON",
|
||||
"SUV": "SUV",
|
||||
"SUV ": "SUV",
|
||||
"크로스오버": "SUV",
|
||||
"픽업트럭": "PICKUP",
|
||||
"픽업": "PICKUP",
|
||||
"밴": "VAN",
|
||||
"미니밴": "MINIVAN",
|
||||
"트럭": "TRUCK",
|
||||
"쿠페형 SUV": "SUV",
|
||||
"버스": "BUS",
|
||||
"기타": "OTHER",
|
||||
"승용차": "SEDAN",
|
||||
"승합차": "VAN",
|
||||
"화물차": "TRUCK",
|
||||
"특장차": "SPECIAL",
|
||||
"경차": "COMPACT",
|
||||
"중형차": "MID_SIZE",
|
||||
"대형차": "FULL_SIZE",
|
||||
"스포츠카": "SPORTS",
|
||||
"클래식카": "CLASSIC",
|
||||
"전기차": "ELECTRIC",
|
||||
"하이브리드": "HYBRID",
|
||||
}
|
||||
|
||||
|
||||
# Определение body_type по модели (fallback)
|
||||
|
||||
MODEL_BODY_TYPE_MAP: dict[str, str] = {
|
||||
# SUV / кроссоверы
|
||||
"투싼": "SUV", "싼타페": "SUV", "팰리세이드": "SUV", "코나": "SUV", "베뉴": "SUV",
|
||||
"크레타": "SUV", "넥쏘": "SUV", "아이오닉5": "SUV", "아이오닉6": "SEDAN",
|
||||
"셀토스": "SUV", "스포티지": "SUV", "쏘렌토": "SUV", "모하비": "SUV",
|
||||
"니로": "SUV", "EV6": "SUV", "EV9": "SUV",
|
||||
"티볼리": "SUV", "코란도": "SUV", "렉스턴": "SUV", "토레스": "SUV", "액티언": "SUV",
|
||||
"GV70": "SUV", "GV80": "SUV", "GV60": "SUV",
|
||||
"X1": "SUV", "X2": "SUV", "X3": "SUV", "X4": "SUV", "X5": "SUV", "X6": "SUV", "X7": "SUV", "iX": "SUV", "iX3": "SUV",
|
||||
"Q2": "SUV", "Q3": "SUV", "Q4": "SUV", "Q5": "SUV", "Q7": "SUV", "Q8": "SUV",
|
||||
"GLA": "SUV", "GLB": "SUV", "GLC": "SUV", "GLE": "SUV", "GLS": "SUV",
|
||||
"카이엔": "SUV", "마칸": "SUV",
|
||||
"레인지로버": "SUV", "디스커버리": "SUV", "디펜더": "SUV",
|
||||
"래브4": "SUV", "하이랜더": "SUV", "FJ 크루져": "SUV",
|
||||
"CR-V": "SUV", "HR-V": "SUV",
|
||||
"티구안": "SUV", "투아렉": "SUV", "아틀라스": "SUV", "ID.4": "SUV",
|
||||
"트레일블레이저": "SUV", "이쿼녹스": "SUV", "트래버스": "SUV", "트랙스": "SUV", "캡티바": "SUV", "올란도": "SUV",
|
||||
"XC40": "SUV", "XC60": "SUV", "XC90": "SUV", "V40": "HATCHBACK", "V60": "STATION_WAGON", "V90": "STATION_WAGON",
|
||||
"F-PACE": "SUV", "E-PACE": "SUV", "I-PACE": "SUV",
|
||||
"모델 Y": "SUV", "Model Y": "SUV", "모델 X": "SUV", "Model X": "SUV",
|
||||
"RX": "SUV", "NX": "SUV", "UX": "SUV", "LX": "SUV",
|
||||
"QX50": "SUV", "QX60": "SUV", "QX80": "SUV",
|
||||
"QM3": "SUV", "QM6": "SUV",
|
||||
"레니게이드": "SUV", "컴패스": "SUV", "체로키": "SUV", "랭글러": "SUV", "글래디에이터": "PICKUP",
|
||||
"에비에이터": "SUV", "노틸러스": "SUV", "코세어": "SUV",
|
||||
"르반떼": "SUV", "그레칼레": "SUV",
|
||||
"Z4": "CONVERTIBLE",
|
||||
# 세단
|
||||
"쏘나타": "SEDAN", "아반떼": "SEDAN", "그랜저": "SEDAN", "K5": "SEDAN",
|
||||
"K8": "SEDAN", "K9": "SEDAN", "K3": "SEDAN", "K7": "SEDAN",
|
||||
"G70": "SEDAN", "G80": "SEDAN", "G90": "SEDAN",
|
||||
"1시리즈": "HATCHBACK",
|
||||
"2시리즈 그란쿠페": "SEDAN", "2시리즈 액티브투어러": "MINIVAN",
|
||||
"3시리즈": "SEDAN", "3시리즈 GT": "SEDAN",
|
||||
"5시리즈": "SEDAN", "7시리즈": "SEDAN", "i4": "SEDAN", "i7": "SEDAN",
|
||||
"A1": "HATCHBACK", "A3": "SEDAN", "A4": "SEDAN", "A6": "SEDAN", "A7": "SEDAN", "A8": "SEDAN",
|
||||
"A-클래스": "SEDAN", "CLA": "SEDAN",
|
||||
"C-클래스": "SEDAN", "E-클래스": "SEDAN", "S-클래스": "SEDAN",
|
||||
"캠리": "SEDAN", "아발론": "SEDAN", "프리우스": "SEDAN",
|
||||
"어코드": "SEDAN", "시빅": "SEDAN",
|
||||
"파사트": "SEDAN", "제타": "SEDAN", "아테온": "SEDAN",
|
||||
"말리부": "SEDAN", "크루즈": "SEDAN", "임팔라": "SEDAN",
|
||||
"S60": "SEDAN", "S90": "SEDAN",
|
||||
"IS": "SEDAN", "ES": "SEDAN", "GS": "SEDAN", "LS": "SEDAN",
|
||||
"Q50": "SEDAN", "Q70": "SEDAN",
|
||||
"모델 3": "SEDAN", "Model 3": "SEDAN", "모델 S": "SEDAN", "Model S": "SEDAN",
|
||||
"SM6": "SEDAN", "SM5": "SEDAN",
|
||||
# 해치백
|
||||
"스파크": "HATCHBACK", "모닝": "HATCHBACK", "레이": "HATCHBACK",
|
||||
"i30": "HATCHBACK", "벨로스터": "HATCHBACK",
|
||||
"골프": "HATCHBACK", "폴로": "HATCHBACK", "ID.3": "HATCHBACK",
|
||||
"캐스퍼": "HATCHBACK",
|
||||
# 미니밴/밴
|
||||
"카니발": "MINIVAN", "스타리아": "MINIVAN", "스타렉스": "MINIVAN",
|
||||
"V-클래스": "MINIVAN", "비토": "VAN",
|
||||
"시에나": "MINIVAN", "알파드": "MINIVAN",
|
||||
# 트럭/픽업
|
||||
"포터": "PICKUP", "봉고": "PICKUP", "마이티": "TRUCK",
|
||||
"타코마": "PICKUP", "터프": "PICKUP",
|
||||
"콜로라도": "PICKUP", "실버라도": "PICKUP",
|
||||
# 쿠페
|
||||
"4시리즈": "COUPE", "8시리즈": "COUPE", "2시리즈": "COUPE",
|
||||
"A5": "COUPE", "TT": "COUPE",
|
||||
"RC": "COUPE", "LC": "COUPE",
|
||||
}
|
||||
3
encar_scraper/worker/__init__.py
Normal file
3
encar_scraper/worker/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery_app import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
111
encar_scraper/worker/celery_app.py
Normal file
111
encar_scraper/worker/celery_app.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# Инициализация Celery-приложения и периодических задач для Encar.
|
||||
|
||||
import logging
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import beat_init
|
||||
from redis import Redis
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..core.config import settings
|
||||
|
||||
logger = logging.getLogger("encar_scraper.worker.celery_app")
|
||||
_INITIAL_SYNC_BOOTSTRAP_KEY = "encar:bootstrap:initial_sync_enqueued"
|
||||
|
||||
|
||||
def _broker_url() -> str:
|
||||
return settings.celery.broker_url or settings.redis.url
|
||||
|
||||
|
||||
def _result_backend() -> str:
|
||||
return settings.celery.result_backend or settings.redis.url
|
||||
|
||||
|
||||
celery_app = Celery(
|
||||
"encar_scraper",
|
||||
broker=_broker_url(),
|
||||
backend=_result_backend(),
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_soft_time_limit=settings.celery.task_soft_time_limit,
|
||||
task_time_limit=settings.celery.task_time_limit,
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
task_track_started=True,
|
||||
worker_concurrency=settings.celery.worker_concurrency,
|
||||
worker_max_tasks_per_child=settings.celery.worker_max_tasks_per_child,
|
||||
worker_pool="prefork",
|
||||
worker_prefetch_multiplier=1,
|
||||
broker_connection_retry_on_startup=True,
|
||||
broker_transport_options={
|
||||
"visibility_timeout": settings.celery.broker_visibility_timeout,
|
||||
},
|
||||
result_expires=86400,
|
||||
worker_hijack_root_logger=False,
|
||||
# Следующий запуск планирует сама задача в finally через apply_async(countdown=...),
|
||||
# чтобы час отсчитывался от завершения прогона, а не от старта.
|
||||
beat_schedule={},
|
||||
task_routes={
|
||||
"encar_scraper.worker.tasks.*": {"queue": "encar"},
|
||||
},
|
||||
)
|
||||
|
||||
celery_app.autodiscover_tasks(["encar_scraper.worker"])
|
||||
|
||||
|
||||
@beat_init.connect
|
||||
def _schedule_initial_sync_on_first_start(**kwargs):
|
||||
"""Ставит первый sync сразу после первого запуска проекта на пустой БД."""
|
||||
from ..storage.db import PersistenceService
|
||||
from ..storage.models import SyncRun
|
||||
from .tasks import encar_sync_listing_task
|
||||
|
||||
persistence = PersistenceService(settings)
|
||||
redis_client: Redis | None = None
|
||||
try:
|
||||
with persistence.session_scope() as session:
|
||||
total_runs = session.execute(select(func.count(SyncRun.id))).scalar() or 0
|
||||
|
||||
if total_runs:
|
||||
return
|
||||
|
||||
redis_client = Redis.from_url(
|
||||
_broker_url(),
|
||||
decode_responses=True,
|
||||
socket_timeout=10,
|
||||
socket_connect_timeout=5,
|
||||
)
|
||||
marked = redis_client.set(_INITIAL_SYNC_BOOTSTRAP_KEY, "1", nx=True, ex=600)
|
||||
if not marked:
|
||||
logger.info("Initial Encar sync already bootstrapped on this startup")
|
||||
return
|
||||
|
||||
encar_sync_listing_task.apply_async(kwargs={"car_type": "all"}, queue="encar")
|
||||
logger.info("Scheduled initial Encar sync because sync_runs table is empty")
|
||||
except Exception:
|
||||
logger.warning("Failed to schedule initial Encar sync", exc_info=True)
|
||||
finally:
|
||||
persistence.engine.dispose()
|
||||
if redis_client is not None:
|
||||
try:
|
||||
redis_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
from celery.signals import setup_logging as celery_setup_logging
|
||||
|
||||
|
||||
@celery_setup_logging.connect
|
||||
def _configure_logging(loglevel, logfile, format, colorize, **kwargs):
|
||||
"""Перехватываем настройку логирования Celery, чтобы использовать свой формат."""
|
||||
from ..core.logs import setup_logging
|
||||
import logging
|
||||
level_name = logging.getLevelName(loglevel) if isinstance(loglevel, int) else str(loglevel)
|
||||
setup_logging(level=level_name, log_file=logfile)
|
||||
319
encar_scraper/worker/tasks.py
Normal file
319
encar_scraper/worker/tasks.py
Normal file
@@ -0,0 +1,319 @@
|
||||
# Celery tasks для синхронизации автомобилей Encar.
|
||||
|
||||
import logging
|
||||
from threading import Event, Thread
|
||||
import uuid
|
||||
|
||||
from celery import shared_task
|
||||
from celery.exceptions import MaxRetriesExceededError, Retry
|
||||
from celery.signals import worker_process_init
|
||||
from redis import Redis
|
||||
|
||||
from ..core.config import Settings
|
||||
from ..core.runtime_config import load_runtime_config
|
||||
from ..encar import EncarScraper, EncarFilters, expand_allowed_brands
|
||||
from ..storage.db import PersistenceService
|
||||
|
||||
logger = logging.getLogger("encar_scraper.worker.tasks")
|
||||
|
||||
ENCAR_SYNC_LOCK_KEY = "encar:locks:sync_listing"
|
||||
|
||||
_persistence: PersistenceService | None = None
|
||||
_redis: Redis | None = None
|
||||
|
||||
|
||||
@worker_process_init.connect
|
||||
def _reset_globals_after_fork(**kwargs):
|
||||
global _persistence, _redis
|
||||
# После fork закрываем унаследованные TCP-сокеты родителя (иначе дети
|
||||
# делят одни connection pool'ы → повреждение данных).
|
||||
if _persistence is not None:
|
||||
try:
|
||||
_persistence.engine.dispose()
|
||||
except Exception:
|
||||
logger.debug("engine.dispose after fork failed", exc_info=True)
|
||||
if _redis is not None:
|
||||
try:
|
||||
_redis.close()
|
||||
except Exception:
|
||||
logger.debug("redis.close after fork failed", exc_info=True)
|
||||
_persistence = None
|
||||
_redis = None
|
||||
|
||||
|
||||
def _sync_listing_lock_ttl_seconds() -> int:
|
||||
settings = Settings()
|
||||
return max(settings.celery.task_time_limit + 120, 300)
|
||||
|
||||
|
||||
def _get_persistence() -> PersistenceService:
|
||||
global _persistence
|
||||
if _persistence is None:
|
||||
_persistence = PersistenceService(Settings())
|
||||
return _persistence
|
||||
|
||||
|
||||
def _get_redis() -> Redis:
|
||||
global _redis
|
||||
if _redis is None:
|
||||
settings = Settings()
|
||||
_redis = Redis.from_url(
|
||||
settings.redis.url,
|
||||
decode_responses=True,
|
||||
socket_timeout=10,
|
||||
socket_connect_timeout=5,
|
||||
)
|
||||
return _redis
|
||||
|
||||
|
||||
def _acquire_lock(redis_client: Redis, key: str, owner_token: str, ttl_seconds: int) -> bool:
|
||||
try:
|
||||
return bool(redis_client.set(key, owner_token, nx=True, ex=ttl_seconds))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to acquire lock %s", key, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
_REFRESH_LOCK_SCRIPT = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
_RELEASE_LOCK_SCRIPT = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
|
||||
def _refresh_lock_if_owner(redis_client: Redis, key: str, owner_token: str, ttl_seconds: int) -> bool | None:
|
||||
try:
|
||||
script = redis_client.register_script(_REFRESH_LOCK_SCRIPT)
|
||||
refreshed = script(keys=[key], args=[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:
|
||||
script = redis_client.register_script(_RELEASE_LOCK_SCRIPT)
|
||||
script(keys=[key], args=[owner_token])
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to release lock %s", key, exc_info=True)
|
||||
|
||||
|
||||
def _start_lock_heartbeat(
|
||||
redis_client: Redis,
|
||||
key: str,
|
||||
owner_token: str,
|
||||
ttl_seconds: int,
|
||||
) -> tuple[Event, Thread]:
|
||||
stop_event = Event()
|
||||
interval_seconds = max(5.0, min(30.0, ttl_seconds / 3))
|
||||
|
||||
def _heartbeat() -> None:
|
||||
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)
|
||||
return
|
||||
|
||||
thread = Thread(target=_heartbeat, name="encar-lock-heartbeat", daemon=True)
|
||||
thread.start()
|
||||
return stop_event, thread
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="encar_scraper.worker.tasks.encar_sync_listing_task",
|
||||
bind=True,
|
||||
max_retries=2,
|
||||
default_retry_delay=60,
|
||||
acks_late=True,
|
||||
)
|
||||
def encar_sync_listing_task(
|
||||
self,
|
||||
car_type: str = "all",
|
||||
manufacturer: str | None = None,
|
||||
year_from: int | None = None,
|
||||
year_to: int | None = None,
|
||||
price_max: int | None = None,
|
||||
lane: str = "encar",
|
||||
limit: int | None = None,
|
||||
):
|
||||
"""
|
||||
Полная синхронизация листинга Encar.
|
||||
Собирает ВСЕ авто, обновляет цены/данные, помечает проданные.
|
||||
"""
|
||||
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_ttl = _sync_listing_lock_ttl_seconds()
|
||||
lock_acquired = _acquire_lock(redis_client, ENCAR_SYNC_LOCK_KEY, owner_token, lock_ttl)
|
||||
|
||||
if not lock_acquired:
|
||||
logger.info("encar_sync_listing_task skipped: another sync is already running")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "sync_already_running",
|
||||
"task_id": task_id,
|
||||
}
|
||||
|
||||
heartbeat_stop: Event | None = None
|
||||
heartbeat_thread: Thread | None = None
|
||||
run_id: int | None = None
|
||||
# Планировать следующий прогон только когда текущий прогон завершён
|
||||
# (success или final failure после исчерпания retries). Retry сам
|
||||
# перепланирует задачу — не дублируем apply_async в этом случае.
|
||||
reschedule_next = True
|
||||
|
||||
try:
|
||||
heartbeat_stop, heartbeat_thread = _start_lock_heartbeat(
|
||||
redis_client,
|
||||
ENCAR_SYNC_LOCK_KEY,
|
||||
owner_token,
|
||||
lock_ttl,
|
||||
)
|
||||
self.update_state(state="STARTED", meta={"stage": "encar_sync_started", "task_id": task_id})
|
||||
|
||||
# --- Загрузка runtime_config ---
|
||||
rc = load_runtime_config()
|
||||
effective_limit = limit or rc.sync.limit
|
||||
effective_lane = lane or rc.sync.lane or "encar"
|
||||
|
||||
# --- Фиксируем начало sync_run ---
|
||||
run_id = persistence.start_sync_run(effective_lane)
|
||||
|
||||
car_type_map = {"all": "A", "domestic": "Y", "import": "N"}
|
||||
filters = EncarFilters(
|
||||
car_type=car_type_map.get(car_type, "A"),
|
||||
manufacturer=manufacturer,
|
||||
year_from=year_from,
|
||||
year_to=year_to,
|
||||
price_to=price_max,
|
||||
)
|
||||
|
||||
# Набор разрешённых брендов (lowercased) из runtime_config
|
||||
# Расширяем английские имена alias'ами (корейский + русский перевод)
|
||||
allowed_brands = expand_allowed_brands(
|
||||
set(rc.filters.brands) if rc.filters.brands else None
|
||||
)
|
||||
excluded_brands = expand_allowed_brands(
|
||||
set(rc.filters.exclude_brands) if rc.filters.exclude_brands else None
|
||||
)
|
||||
|
||||
scraper = EncarScraper(persistence=persistence)
|
||||
result = scraper.sync_listing(
|
||||
limit=effective_limit,
|
||||
filters=filters,
|
||||
lane=effective_lane,
|
||||
redis_client=redis_client,
|
||||
allowed_brands=allowed_brands,
|
||||
excluded_brands=excluded_brands,
|
||||
runtime_filters=rc.filters,
|
||||
probe_all_photos=rc.sync.probe_all_photos,
|
||||
)
|
||||
|
||||
# --- Запись результата в sync_runs ---
|
||||
persistence.finish_sync_run(
|
||||
run_id,
|
||||
status="success",
|
||||
ids_fetched=result.get("items_collected", 0),
|
||||
cars_upserted=result.get("cars_synced", 0),
|
||||
cars_failed=result.get("cars_failed", 0),
|
||||
images_upserted=0,
|
||||
)
|
||||
|
||||
summary = {
|
||||
"task_id": task_id,
|
||||
"source": "encar",
|
||||
"total_available": result.get("total_available", 0),
|
||||
"items_collected": result.get("items_collected", 0),
|
||||
"cars_synced": result.get("cars_synced", 0),
|
||||
"cars_failed": result.get("cars_failed", 0),
|
||||
"cars_marked_sold": result.get("cars_marked_sold", 0),
|
||||
}
|
||||
logger.info(
|
||||
"encar_sync_listing_task completed: %d synced, %d failed, %d marked sold",
|
||||
summary["cars_synced"], summary["cars_failed"], summary["cars_marked_sold"],
|
||||
)
|
||||
return {"status": "success", **summary}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("encar_sync_listing_task failed: %s", exc, exc_info=True)
|
||||
try:
|
||||
if run_id is not None:
|
||||
persistence.finish_sync_run(
|
||||
run_id,
|
||||
status="failed",
|
||||
ids_fetched=0,
|
||||
cars_upserted=0,
|
||||
cars_failed=0,
|
||||
images_upserted=0,
|
||||
error_summary=str(exc)[:500],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to record sync_run failure", exc_info=True)
|
||||
try:
|
||||
# Retry сам перепланирует задачу — не планируем следующий прогон
|
||||
reschedule_next = False
|
||||
raise self.retry(exc=exc)
|
||||
except MaxRetriesExceededError:
|
||||
# Retries исчерпаны — запускаем следующий прогон по расписанию
|
||||
reschedule_next = True
|
||||
raise
|
||||
finally:
|
||||
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, ENCAR_SYNC_LOCK_KEY, owner_token)
|
||||
if reschedule_next:
|
||||
try:
|
||||
interval_minutes = Settings().celery.encar_beat_interval_minutes
|
||||
countdown = max(60, int(interval_minutes) * 60)
|
||||
self.apply_async(
|
||||
kwargs={"car_type": car_type},
|
||||
countdown=countdown,
|
||||
queue="encar",
|
||||
)
|
||||
logger.info(
|
||||
"Next encar_sync_listing_task scheduled in %d minutes",
|
||||
countdown // 60,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to schedule next sync run", exc_info=True)
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="encar_scraper.worker.tasks.encar_sync_vehicle_task",
|
||||
bind=True,
|
||||
max_retries=2,
|
||||
default_retry_delay=30,
|
||||
acks_late=True,
|
||||
)
|
||||
def encar_sync_vehicle_task(self, vehicle_url: str, lane: str = "encar"):
|
||||
"""Синхронизация одного авто Encar."""
|
||||
persistence = _get_persistence()
|
||||
persistence.create_tables()
|
||||
|
||||
try:
|
||||
scraper = EncarScraper(persistence=persistence)
|
||||
result = scraper.sync_vehicle(vehicle_url, lane=lane)
|
||||
logger.info("encar_sync_vehicle_task completed: %s", vehicle_url)
|
||||
return {
|
||||
"status": "success",
|
||||
"vehicle_url": vehicle_url,
|
||||
"origin_id": result.get("origin_id"),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("encar_sync_vehicle_task failed: %s — %s", vehicle_url, exc, exc_info=True)
|
||||
raise self.retry(exc=exc)
|
||||
Reference in New Issue
Block a user