Compare commits
5 Commits
eba3750f15
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1756a959 | ||
|
|
72e78de1d7 | ||
|
|
6bdbbe396c | ||
|
|
efbbf4a067 | ||
|
|
1c5c30e31e |
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
11
Dockerfile
11
Dockerfile
@@ -1,19 +1,22 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_DEFAULT_TIMEOUT=300
|
||||||
|
|
||||||
RUN groupadd --system app && useradd --system --gid app --create-home app
|
RUN groupadd --system app && useradd --system --gid app --create-home app
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
RUN pip install --no-cache-dir uv \
|
RUN pip install --no-cache-dir --retries 10 uv \
|
||||||
&& uv export --format requirements-txt --no-dev --no-hashes --no-emit-project --frozen -o /tmp/requirements.txt \
|
&& uv export --format requirements-txt --no-dev --no-hashes --no-emit-project --frozen -o /tmp/requirements.txt \
|
||||||
&& pip install --no-cache-dir -r /tmp/requirements.txt
|
&& pip install --no-cache-dir --retries 10 -r /tmp/requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pip install --no-cache-dir -e .
|
RUN pip install --no-cache-dir --retries 10 "setuptools>=68"
|
||||||
|
RUN pip install --no-cache-dir --no-build-isolation -e .
|
||||||
RUN python -m compileall -q encar_scraper
|
RUN python -m compileall -q encar_scraper
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
RUN chown -R app:app /app
|
RUN chown -R app:app /app
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ from .translations import (
|
|||||||
FUEL_TYPE_MAP,
|
FUEL_TYPE_MAP,
|
||||||
GEARBOX_MAP_KO,
|
GEARBOX_MAP_KO,
|
||||||
MODEL_BODY_TYPE_MAP,
|
MODEL_BODY_TYPE_MAP,
|
||||||
|
MODEL_EXACT_TRANSLATIONS,
|
||||||
|
MODEL_PHRASE_TRANSLATIONS,
|
||||||
|
MODEL_TOKEN_TRANSLATIONS,
|
||||||
MODEL_TRANSLATIONS,
|
MODEL_TRANSLATIONS,
|
||||||
SELL_TYPE_MAP,
|
SELL_TYPE_MAP,
|
||||||
expand_allowed_brands,
|
expand_allowed_brands,
|
||||||
@@ -42,6 +45,7 @@ BATCH_VEHICLES_CHUNK_SIZE = 20
|
|||||||
ENCAR_DETAIL_URL_TEMPLATE = "https://www.encar.com/dc/dc_cardetailview.do?carid={vehicle_id}"
|
ENCAR_DETAIL_URL_TEMPLATE = "https://www.encar.com/dc/dc_cardetailview.do?carid={vehicle_id}"
|
||||||
ENCAR_IMAGE_BASE = "https://ci.encar.com"
|
ENCAR_IMAGE_BASE = "https://ci.encar.com"
|
||||||
VEHICLE_ID_RE = re.compile(r"(?:carid|vehicleId)=?(\d+)")
|
VEHICLE_ID_RE = re.compile(r"(?:carid|vehicleId)=?(\d+)")
|
||||||
|
NUMERIC_ID_RE = re.compile(r"(\d{6,})")
|
||||||
PHOTO_VEHICLE_ID_RE = re.compile(r"/(\d+)_\d+\.(?:jpg|jpeg|png|webp)$", re.IGNORECASE)
|
PHOTO_VEHICLE_ID_RE = re.compile(r"/(\d+)_\d+\.(?:jpg|jpeg|png|webp)$", re.IGNORECASE)
|
||||||
|
|
||||||
# Максимальный номер фото для пробинга и допустимые промахи подряд
|
# Максимальный номер фото для пробинга и допустимые промахи подряд
|
||||||
@@ -155,30 +159,24 @@ class RuntimeFilterSpec:
|
|||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class EncarMapper:
|
class EncarMapper:
|
||||||
def map_to_car_record(self, vehicle_url: str, payload: dict[str, Any], probe_all_photos: bool = False) -> CarRecord:
|
def map_to_car_record(self, vehicle_url: str, payload: dict[str, Any], probe_all_photos: bool = False) -> CarRecord:
|
||||||
vehicle_id = self._extract_vehicle_id(vehicle_url) or str(
|
vehicle_id = self._resolve_vehicle_id(vehicle_url, payload)
|
||||||
payload.get("Id") or payload.get("vehicleId") or ""
|
|
||||||
)
|
|
||||||
origin_id = f"encar:{vehicle_id}"
|
origin_id = f"encar:{vehicle_id}"
|
||||||
parser_id = f"encar:{vehicle_id}"
|
parser_id = f"encar:{vehicle_id}"
|
||||||
origin_url = vehicle_url or ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id=vehicle_id)
|
url_vehicle_id = self._extract_vehicle_id(vehicle_url)
|
||||||
|
if vehicle_url and url_vehicle_id == vehicle_id:
|
||||||
|
origin_url = vehicle_url
|
||||||
|
else:
|
||||||
|
origin_url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id=vehicle_id)
|
||||||
|
|
||||||
brand = self._as_str(payload.get("Manufacturer") or payload.get("Brand") or "UNKNOWN")
|
brand = self._as_str(payload.get("Manufacturer") or payload.get("Brand") or "UNKNOWN")
|
||||||
# Перевод бренда на русский
|
# Перевод бренда на русский
|
||||||
brand = BRAND_TRANSLATIONS.get(brand, brand)
|
brand = BRAND_TRANSLATIONS.get(brand, brand)
|
||||||
|
|
||||||
# Model + Badge + BadgeDetail = полное название
|
# Нужна только базовая модель (без trim/sub-trim)
|
||||||
model_raw = self._as_str(payload.get("Model") or "UNKNOWN")
|
model_raw = self._as_str(payload.get("Model") or "UNKNOWN")
|
||||||
badge = self._as_str(payload.get("Badge") or "")
|
badge = self._as_str(payload.get("Badge") or "")
|
||||||
badge_detail = self._as_str(payload.get("BadgeDetail") or "")
|
badge_detail = self._as_str(payload.get("BadgeDetail") or "")
|
||||||
|
model = self._translate_model(model_raw)
|
||||||
# Полное описание модели: "Model / Badge BadgeDetail"
|
|
||||||
model_parts = [model_raw]
|
|
||||||
if badge:
|
|
||||||
model_parts.append(badge)
|
|
||||||
if badge_detail and badge_detail not in badge:
|
|
||||||
model_parts.append(badge_detail)
|
|
||||||
model = " / ".join(model_parts) if len(model_parts) > 1 else model_parts[0]
|
|
||||||
model = self._translate_text(model)
|
|
||||||
|
|
||||||
year = self._to_year(payload.get("FormYear") or payload.get("Year"))
|
year = self._to_year(payload.get("FormYear") or payload.get("Year"))
|
||||||
# Price в 만원 (10 000 KRW) → переводим в KRW
|
# Price в 만원 (10 000 KRW) → переводим в KRW
|
||||||
@@ -283,6 +281,66 @@ class EncarMapper:
|
|||||||
match = VEHICLE_ID_RE.search(vehicle_url)
|
match = VEHICLE_ID_RE.search(vehicle_url)
|
||||||
return match.group(1) if match else None
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
def _resolve_vehicle_id(self, vehicle_url: str | None, payload: dict[str, Any]) -> str:
|
||||||
|
# 1) Из URL (carid=123...)
|
||||||
|
from_url = self._extract_vehicle_id(vehicle_url)
|
||||||
|
if from_url:
|
||||||
|
return from_url
|
||||||
|
|
||||||
|
# 2) Из явных полей payload
|
||||||
|
for key in ("Id", "id", "vehicleId", "VehicleId", "carId", "carid"):
|
||||||
|
value = payload.get(key)
|
||||||
|
extracted = self._extract_numeric_id(value)
|
||||||
|
if extracted:
|
||||||
|
return extracted
|
||||||
|
|
||||||
|
# 3) Из фото-пути (например /.../41854374_001.jpg)
|
||||||
|
photos = payload.get("Photos") or []
|
||||||
|
if isinstance(photos, list):
|
||||||
|
for raw in photos:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
location = raw.get("location") or raw.get("Location") or ""
|
||||||
|
extracted = self._extract_numeric_id(location)
|
||||||
|
if extracted:
|
||||||
|
return extracted
|
||||||
|
|
||||||
|
# 4) Фолбэк: любой id-like ключ в payload
|
||||||
|
fallback = self._find_any_numeric_id(payload)
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
raise ValueError("Cannot resolve numeric Encar vehicle id")
|
||||||
|
|
||||||
|
def _extract_numeric_id(self, value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return str(value)
|
||||||
|
text = self._as_str(value)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
match = NUMERIC_ID_RE.search(text)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
def _find_any_numeric_id(self, payload: dict[str, Any]) -> str | None:
|
||||||
|
stack: list[Any] = [payload]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
if isinstance(current, dict):
|
||||||
|
for key, value in current.items():
|
||||||
|
if isinstance(key, str) and "id" in key.lower():
|
||||||
|
extracted = self._extract_numeric_id(value)
|
||||||
|
if extracted:
|
||||||
|
return extracted
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
stack.append(value)
|
||||||
|
elif isinstance(current, list):
|
||||||
|
for item in current:
|
||||||
|
if isinstance(item, (dict, list)):
|
||||||
|
stack.append(item)
|
||||||
|
return None
|
||||||
|
|
||||||
def _build_slug(self, brand: str, model: str, vehicle_id: str) -> str:
|
def _build_slug(self, brand: str, model: str, vehicle_id: str) -> str:
|
||||||
raw = re.sub(r"[^a-z0-9]+", "-", f"{brand}-{model}-{vehicle_id}".lower()).strip("-")
|
raw = re.sub(r"[^a-z0-9]+", "-", f"{brand}-{model}-{vehicle_id}".lower()).strip("-")
|
||||||
return raw or f"encar-{vehicle_id}"
|
return raw or f"encar-{vehicle_id}"
|
||||||
@@ -297,12 +355,85 @@ class EncarMapper:
|
|||||||
translated = re.sub(r"\s+", " ", translated).strip()
|
translated = re.sub(r"\s+", " ", translated).strip()
|
||||||
return translated
|
return translated
|
||||||
|
|
||||||
|
def _has_korean(self, text: str) -> bool:
|
||||||
|
return bool(re.search(r"[가-힣]", text))
|
||||||
|
|
||||||
|
def _normalize_model_text(self, text: str) -> str:
|
||||||
|
normalized = self._as_str(text)
|
||||||
|
if not normalized:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
normalized = re.sub(r"[\(\[(【][^)\])】]*[\)\])】]?", " ", normalized)
|
||||||
|
normalized = normalized.replace("·", " ").replace("/", " ")
|
||||||
|
normalized = re.sub(r"(?<=[A-Za-z])-클래스", " 클래스", normalized)
|
||||||
|
normalized = re.sub(r"(?<=[0-9])시리즈", "시리즈", normalized)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _clean_base_model_name(self, text: str) -> str:
|
||||||
|
model = self._as_str(text)
|
||||||
|
if not model:
|
||||||
|
return model
|
||||||
|
|
||||||
|
marketing_prefix = r"(?:The\s+All\s+New|All\s+New|The\s+New|Very\s+New|New)"
|
||||||
|
previous = None
|
||||||
|
while previous != model:
|
||||||
|
previous = model
|
||||||
|
model = re.sub(rf"^\s*{marketing_prefix}\s+", "", model, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
model = re.sub(r"\b\d+(?:st|nd|rd|th)\s+gen\b", " ", model, flags=re.IGNORECASE)
|
||||||
|
model = re.sub(r"\bgen\b", " ", model, flags=re.IGNORECASE)
|
||||||
|
model = re.sub(r"\s+", " ", model).strip(" -")
|
||||||
|
return model
|
||||||
|
|
||||||
|
def _translate_model(self, text: str) -> str:
|
||||||
|
|
||||||
|
original = self._normalize_model_text(text)
|
||||||
|
if not original or not self._has_korean(original):
|
||||||
|
return self._clean_base_model_name(original)
|
||||||
|
|
||||||
|
exact = MODEL_EXACT_TRANSLATIONS.get(original)
|
||||||
|
if exact:
|
||||||
|
return self._clean_base_model_name(exact)
|
||||||
|
|
||||||
|
translated = original
|
||||||
|
|
||||||
|
|
||||||
|
combined = {**MODEL_EXACT_TRANSLATIONS, **MODEL_PHRASE_TRANSLATIONS, **MODEL_TRANSLATIONS}
|
||||||
|
for source, target in sorted(combined.items(), key=lambda item: len(item[0]), reverse=True):
|
||||||
|
if source in translated:
|
||||||
|
pattern = rf"(?<![가-힣]){re.escape(source)}(?![가-힣])"
|
||||||
|
translated = re.sub(pattern, target, translated)
|
||||||
|
|
||||||
|
for source, target in sorted(MODEL_TOKEN_TRANSLATIONS.items(), key=lambda item: len(item[0]), reverse=True):
|
||||||
|
translated = re.sub(rf"(?<![A-Za-z0-9가-힣]){re.escape(source)}(?![A-Za-z0-9가-힣])", target, translated)
|
||||||
|
|
||||||
|
translated = re.sub(r"(?<=\d)시리즈", " Series", translated)
|
||||||
|
translated = re.sub(r"(?<=[A-Za-z])-?클래스", "-Class", translated)
|
||||||
|
translated = re.sub(r"\b([A-Z]{1,4}) Class\b", r"\1-Class", translated)
|
||||||
|
|
||||||
|
translated = re.sub(r"[가-힣]+", " ", translated)
|
||||||
|
translated = re.sub(r"\(\s*\)", " ", translated)
|
||||||
|
translated = re.sub(r"\s*-\s*(?=$|\))", "", translated)
|
||||||
|
translated = re.sub(r"\s+([),])", r"\1", translated)
|
||||||
|
translated = re.sub(r"\s+", " ", translated).strip(" -")
|
||||||
|
return self._clean_base_model_name(translated)
|
||||||
|
|
||||||
def _translate_color(self, color_raw: str) -> str:
|
def _translate_color(self, color_raw: str) -> str:
|
||||||
value = self._as_str(color_raw)
|
value = re.sub(r"\s+", "", self._as_str(color_raw))
|
||||||
if not value:
|
if not value:
|
||||||
return "other"
|
return "other"
|
||||||
return COLOR_TRANSLATIONS.get(value, self._translate_text(value).lower() or "other")
|
return COLOR_TRANSLATIONS.get(value, self._translate_text(value).lower() or "other")
|
||||||
|
|
||||||
|
def apply_batch_color(self, record: CarRecord, vehicle_data: dict[str, Any]) -> None:
|
||||||
|
spec = vehicle_data.get("spec")
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
color_raw = self._as_str(spec.get("colorName") or spec.get("customColor") or "")
|
||||||
|
if color_raw:
|
||||||
|
record.color = self._translate_color(color_raw)
|
||||||
|
|
||||||
def _to_float(self, value: Any) -> float | None:
|
def _to_float(self, value: Any) -> float | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -1378,25 +1509,33 @@ class EncarScraper:
|
|||||||
for i in range(0, len(tasks), BATCH_VEHICLES_CHUNK_SIZE):
|
for i in range(0, len(tasks), BATCH_VEHICLES_CHUNK_SIZE):
|
||||||
chunks.append(tasks[i:i + BATCH_VEHICLES_CHUNK_SIZE])
|
chunks.append(tasks[i:i + BATCH_VEHICLES_CHUNK_SIZE])
|
||||||
|
|
||||||
def _fetch_chunk(chunk: list[tuple[int, str]]) -> list[tuple[int, list[ImageRecord]]]:
|
def _fetch_chunk(chunk: list[tuple[int, str]]) -> list[tuple[int, list[ImageRecord], dict[str, Any]]]:
|
||||||
"""Запрашивает фото для чанка машин одним batch-запросом."""
|
|
||||||
ids_str = ",".join(vid for _, vid in chunk)
|
ids_str = ",".join(vid for _, vid in chunk)
|
||||||
path = f"/v1/readside/vehicles?vehicleIds={ids_str}&include=PHOTOS"
|
path = f"/v1/readside/vehicles?vehicleIds={ids_str}&include=SPEC,PHOTOS"
|
||||||
for attempt in range(2):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
resp = pool.request("GET", path, timeout=15, retries=False)
|
resp = pool.request("GET", path, timeout=15, retries=False)
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
if not resp.data:
|
if not resp.data:
|
||||||
return [(ri, []) for ri, _ in chunk]
|
return [(ri, [], {}) for ri, _ in chunk]
|
||||||
vehicles = json.loads(resp.data.decode("utf-8", errors="ignore"))
|
vehicles = json.loads(resp.data.decode("utf-8", errors="ignore"))
|
||||||
# Маппим по vehicleId, а не по индексу — API может пропускать удалённые ID
|
|
||||||
id_to_rec: dict[str, int] = {vid: ri for ri, vid in chunk}
|
id_to_rec: dict[str, int] = {vid: ri for ri, vid in chunk}
|
||||||
result: list[tuple[int, list[ImageRecord]]] = []
|
result: list[tuple[int, list[ImageRecord], dict[str, Any]]] = []
|
||||||
for vehicle_data in vehicles:
|
matched_record_indexes: set[int] = set()
|
||||||
|
for vehicle_position, vehicle_data in enumerate(vehicles):
|
||||||
vid = str(vehicle_data.get("vehicleId") or vehicle_data.get("id") or "")
|
vid = str(vehicle_data.get("vehicleId") or vehicle_data.get("id") or "")
|
||||||
rec_idx = id_to_rec.get(vid)
|
rec_idx = None
|
||||||
|
if vehicle_position < len(chunk):
|
||||||
|
positional_rec_idx = chunk[vehicle_position][0]
|
||||||
|
if positional_rec_idx not in matched_record_indexes:
|
||||||
|
rec_idx = positional_rec_idx
|
||||||
|
if rec_idx is None:
|
||||||
|
rec_idx = id_to_rec.get(vid)
|
||||||
if rec_idx is None:
|
if rec_idx is None:
|
||||||
continue
|
continue
|
||||||
|
if rec_idx in matched_record_indexes:
|
||||||
|
continue
|
||||||
|
matched_record_indexes.add(rec_idx)
|
||||||
photos_raw = vehicle_data.get("photos") or []
|
photos_raw = vehicle_data.get("photos") or []
|
||||||
images: list[ImageRecord] = []
|
images: list[ImageRecord] = []
|
||||||
seen_paths: set[str] = set()
|
seen_paths: set[str] = set()
|
||||||
@@ -1414,28 +1553,28 @@ class EncarScraper:
|
|||||||
order_index=order,
|
order_index=order,
|
||||||
))
|
))
|
||||||
images.sort(key=lambda x: x.order_index)
|
images.sort(key=lambda x: x.order_index)
|
||||||
result.append((rec_idx, images))
|
result.append((rec_idx, images, vehicle_data))
|
||||||
return result
|
return result
|
||||||
if resp.status in (429, 503):
|
if resp.status in (429, 503):
|
||||||
time.sleep(1 + attempt * 2)
|
time.sleep(1 + attempt * 2)
|
||||||
continue
|
continue
|
||||||
return [(ri, []) for ri, _ in chunk]
|
return [(ri, [], {}) for ri, _ in chunk]
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return [(ri, []) for ri, _ in chunk]
|
return [(ri, [], {}) for ri, _ in chunk]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Batch photo chunk error (attempt %d): %s", attempt + 1, exc)
|
logger.debug("Batch photo chunk error (attempt %d): %s", attempt + 1, exc)
|
||||||
if attempt < 1:
|
if attempt < 2:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
return [(ri, []) for ri, _ in chunk]
|
return [(ri, [], {}) for ri, _ in chunk]
|
||||||
|
|
||||||
results: dict[int, list[ImageRecord]] = {}
|
results: dict[int, tuple[list[ImageRecord], dict[str, Any]]] = {}
|
||||||
with ThreadPoolExecutor(max_workers=30) as executor:
|
with ThreadPoolExecutor(max_workers=30) as executor:
|
||||||
futures = {executor.submit(_fetch_chunk, ch): ch for ch in chunks}
|
futures = {executor.submit(_fetch_chunk, ch): ch for ch in chunks}
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
try:
|
try:
|
||||||
for rec_idx, images in future.result():
|
for rec_idx, images, vehicle_data in future.result():
|
||||||
if images:
|
if vehicle_data:
|
||||||
results[rec_idx] = images
|
results[rec_idx] = (images, vehicle_data)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Batch photo future failed: %s", exc)
|
logger.warning("Batch photo future failed: %s", exc)
|
||||||
|
|
||||||
@@ -1444,7 +1583,10 @@ class EncarScraper:
|
|||||||
fallback_count = 0
|
fallback_count = 0
|
||||||
for rec_idx, item in probe_tasks:
|
for rec_idx, item in probe_tasks:
|
||||||
if rec_idx in results:
|
if rec_idx in results:
|
||||||
records[rec_idx].images = results[rec_idx]
|
images, vehicle_data = results[rec_idx]
|
||||||
|
if images:
|
||||||
|
records[rec_idx].images = images
|
||||||
|
self.car_mapper.apply_batch_color(records[rec_idx], vehicle_data)
|
||||||
api_ok += 1
|
api_ok += 1
|
||||||
else:
|
else:
|
||||||
fallback_count += 1
|
fallback_count += 1
|
||||||
|
|||||||
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,6 +1,10 @@
|
|||||||
# Словари переводов корейского текста русский / английский.
|
# Словари переводов корейского текста русский / английский.
|
||||||
# Используется в encar.py для маппинга данных Encar API.
|
# Используется в encar.py для маппинга данных Encar API.
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .partner_dictionary import ENCAR_KO_EN_TABLES as _PARTNER_TABLES
|
||||||
|
|
||||||
|
|
||||||
# Топливо
|
# Топливо
|
||||||
|
|
||||||
@@ -176,6 +180,9 @@ BRAND_TRANSLATIONS = {
|
|||||||
"기타 수입차": "Other Imported",
|
"기타 수입차": "Other Imported",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _ko, _en in _PARTNER_TABLES["makers"].items():
|
||||||
|
BRAND_TRANSLATIONS.setdefault(_ko, _en)
|
||||||
|
|
||||||
|
|
||||||
# Бренды: английский → алиасы (корейский + русский)
|
# Бренды: английский → алиасы (корейский + русский)
|
||||||
|
|
||||||
@@ -277,6 +284,226 @@ def expand_allowed_brands(brands: set[str] | None) -> set[str] | None:
|
|||||||
|
|
||||||
# Модели / комплектации: корейский → английский
|
# Модели / комплектации: корейский → английский
|
||||||
|
|
||||||
|
MODEL_EXACT_TRANSLATIONS = {
|
||||||
|
"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",
|
||||||
|
"팰리세이드": "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",
|
||||||
|
"스파크": "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",
|
||||||
|
"익스플로러": "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",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _register_partner_models() -> None:
|
||||||
|
for _ko, _en in _PARTNER_TABLES["models"].items():
|
||||||
|
MODEL_EXACT_TRANSLATIONS.setdefault(_ko, _en)
|
||||||
|
_compact = re.sub(r"(?<=\d)\s+시리즈", "시리즈", _ko)
|
||||||
|
_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 = {
|
MODEL_TRANSLATIONS = {
|
||||||
# --- Топливо / тип двигателя ---
|
# --- Топливо / тип двигателя ---
|
||||||
"가솔린": "Gasoline",
|
"가솔린": "Gasoline",
|
||||||
@@ -551,6 +778,20 @@ MODEL_TRANSLATIONS = {
|
|||||||
"그란투리스모": "GranTurismo",
|
"그란투리스모": "GranTurismo",
|
||||||
"반퀴시": "Vanquish",
|
"반퀴시": "Vanquish",
|
||||||
"밴티지": "Vantage",
|
"밴티지": "Vantage",
|
||||||
|
"시에라": "Sierra",
|
||||||
|
"시빅": "Civic",
|
||||||
|
"레인저": "Ranger",
|
||||||
|
"콜레오스": "Koleos",
|
||||||
|
"그랑 콜레오스": "Grand Koleos",
|
||||||
|
"페이톤": "Phaeton",
|
||||||
|
"티록": "T-Roc",
|
||||||
|
"큐브": "Cube",
|
||||||
|
"크라운": "Crown",
|
||||||
|
"라이즈": "Rise",
|
||||||
|
"시로코": "Scirocco",
|
||||||
|
"이오스": "Eos",
|
||||||
|
"비틀": "Beetle",
|
||||||
|
"폴로": "Polo",
|
||||||
# --- Служебные / категории ---
|
# --- Служебные / категории ---
|
||||||
"(세부등급": "(sub-trim",
|
"(세부등급": "(sub-trim",
|
||||||
"없음)": "none)",
|
"없음)": "none)",
|
||||||
@@ -570,23 +811,25 @@ MODEL_TRANSLATIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Цвета: корейский → английский
|
|
||||||
|
|
||||||
COLOR_TRANSLATIONS = {
|
COLOR_TRANSLATIONS = {
|
||||||
"흰색": "white",
|
"흰색": "white",
|
||||||
"검정색": "black",
|
"검정색": "black",
|
||||||
"회색": "gray",
|
"회색": "gray",
|
||||||
"은색": "silver",
|
"은색": "silver",
|
||||||
|
"은회색": "silver-gray",
|
||||||
"진회색": "dark gray",
|
"진회색": "dark gray",
|
||||||
|
"쥐색": "mouse gray",
|
||||||
"밝은 회색": "light gray",
|
"밝은 회색": "light gray",
|
||||||
"청색": "blue",
|
"청색": "blue",
|
||||||
"파란색": "blue",
|
"파란색": "blue",
|
||||||
"진청색": "dark blue",
|
"진청색": "dark blue",
|
||||||
"밝은 청색": "light blue",
|
"밝은 청색": "light blue",
|
||||||
|
"하늘색": "sky blue",
|
||||||
"녹색": "green",
|
"녹색": "green",
|
||||||
"초록색": "green",
|
"초록색": "green",
|
||||||
"진녹색": "dark green",
|
"진녹색": "dark green",
|
||||||
"밝은 녹색": "light green",
|
"밝은 녹색": "light green",
|
||||||
|
"담녹색": "light green",
|
||||||
"적색": "red",
|
"적색": "red",
|
||||||
"빨간색": "red",
|
"빨간색": "red",
|
||||||
"진적색": "dark red",
|
"진적색": "dark red",
|
||||||
@@ -600,6 +843,11 @@ COLOR_TRANSLATIONS = {
|
|||||||
"보라색": "purple",
|
"보라색": "purple",
|
||||||
"분홍색": "pink",
|
"분홍색": "pink",
|
||||||
"금색": "gold",
|
"금색": "gold",
|
||||||
|
"연금색": "light gold",
|
||||||
|
"명은색": "bright silver",
|
||||||
|
"은하색": "galaxy silver",
|
||||||
|
"자주색": "burgundy",
|
||||||
|
"진주색": "pearl",
|
||||||
"청금색": "blue-gold",
|
"청금색": "blue-gold",
|
||||||
"청은색": "blue-silver",
|
"청은색": "blue-silver",
|
||||||
"흑청색": "black-blue",
|
"흑청색": "black-blue",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68", "wheel"]
|
requires = ["setuptools>=68"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"httpx>=0.28.0",
|
||||||
"pytest>=8.3.0",
|
"pytest>=8.3.0",
|
||||||
"pytest-cov>=5.0.0",
|
"pytest-cov>=5.0.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class TestEncarMapper(unittest.TestCase):
|
|||||||
self.assertEqual(record.origin_id, "encar:41421262")
|
self.assertEqual(record.origin_id, "encar:41421262")
|
||||||
self.assertEqual(record.parser_id, "encar:41421262")
|
self.assertEqual(record.parser_id, "encar:41421262")
|
||||||
self.assertEqual(record.brand, "Hyundai")
|
self.assertEqual(record.brand, "Hyundai")
|
||||||
self.assertEqual(record.model, "G90 (RS4)")
|
self.assertEqual(record.model, "G90")
|
||||||
self.assertEqual(record.year, 2023)
|
self.assertEqual(record.year, 2023)
|
||||||
self.assertEqual(record.price, 85900000) # 8590 만원 × 10000
|
self.assertEqual(record.price, 85900000) # 8590 만원 × 10000
|
||||||
self.assertEqual(record.mileage, 37923)
|
self.assertEqual(record.mileage, 37923)
|
||||||
@@ -40,10 +40,133 @@ class TestEncarMapper(unittest.TestCase):
|
|||||||
url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41421262")
|
url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41421262")
|
||||||
self.assertEqual(EncarMapper()._extract_vehicle_id(url), "41421262")
|
self.assertEqual(EncarMapper()._extract_vehicle_id(url), "41421262")
|
||||||
|
|
||||||
|
def test_map_to_car_record_uses_base_model_without_trim(self):
|
||||||
|
payload = {
|
||||||
|
"Id": 41854374,
|
||||||
|
"Manufacturer": "현대",
|
||||||
|
"Model": "Genesis",
|
||||||
|
"Badge": "BH330 Grand",
|
||||||
|
"BadgeDetail": "Prime팩",
|
||||||
|
"FormYear": "2011",
|
||||||
|
"Price": 1200,
|
||||||
|
"Mileage": 100000,
|
||||||
|
"Photos": [
|
||||||
|
{"location": "/carpicture01/pic4185/41854374_001.jpg", "ordering": 1},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
record = EncarMapper().map_to_car_record(
|
||||||
|
ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41854374"),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(record.brand, "Hyundai")
|
||||||
|
self.assertEqual(record.model, "Genesis")
|
||||||
|
self.assertEqual(record.origin_id, "encar:41854374")
|
||||||
|
|
||||||
|
def test_map_to_car_record_resolves_numeric_id_from_photos(self):
|
||||||
|
payload = {
|
||||||
|
"Id": "car-BOqLLkjkgbpStlVJEJvbRG",
|
||||||
|
"Manufacturer": "Kia",
|
||||||
|
"Model": "K3",
|
||||||
|
"FormYear": "2018",
|
||||||
|
"Price": 990,
|
||||||
|
"Mileage": 75000,
|
||||||
|
"Photos": [
|
||||||
|
{"location": "/carpicture01/pic4187/41878507_001.jpg", "ordering": 1},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
record = EncarMapper().map_to_car_record("", payload)
|
||||||
|
|
||||||
|
self.assertEqual(record.origin_id, "encar:41878507")
|
||||||
|
self.assertEqual(record.parser_id, "encar:41878507")
|
||||||
|
self.assertIn("carid=41878507", record.origin_url)
|
||||||
|
|
||||||
|
def test_translate_model_keeps_english_text(self):
|
||||||
|
mapper = EncarMapper()
|
||||||
|
|
||||||
|
self.assertEqual(mapper._translate_model("All New Carnival"), "Carnival")
|
||||||
|
self.assertEqual(mapper._translate_model("New Kia Ray"), "Kia Ray")
|
||||||
|
self.assertEqual(mapper._translate_model("K5 Hybrid 3rd gen"), "K5 Hybrid")
|
||||||
|
self.assertEqual(mapper._translate_model("New K5 3rd gen"), "K5")
|
||||||
|
self.assertEqual(mapper._translate_model("New Tiguan"), "Tiguan")
|
||||||
|
self.assertEqual(mapper._translate_model("New QM6"), "QM6")
|
||||||
|
self.assertEqual(mapper._translate_model("Discovery Sport 2nd gen"), "Discovery Sport")
|
||||||
|
|
||||||
|
def test_translate_model_handles_korean_fragments(self):
|
||||||
|
mapper = EncarMapper()
|
||||||
|
|
||||||
|
cases = {
|
||||||
|
"5시리즈 (G30)": "5 Series",
|
||||||
|
"S-클래스 W223": "S-Class W223",
|
||||||
|
"Sonata 디 엣지(DN8)": "Sonata The Edge",
|
||||||
|
"렉스턴 Sport 칸": "Rexton Sport Khan",
|
||||||
|
"티볼리 Air": "Tivoli Air",
|
||||||
|
"코란도 투리스모": "Korando Turismo",
|
||||||
|
"Kia Carnival 4세대": "Kia Carnival",
|
||||||
|
"Cadillac 에스컬레이드 5세대": "Cadillac Escalade",
|
||||||
|
"K7 프리미어": "K7 Premier",
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertEqual(mapper._translate_model(raw), expected)
|
||||||
|
|
||||||
|
def test_translate_model_drops_untranslated_korean(self):
|
||||||
|
mapper = EncarMapper()
|
||||||
|
|
||||||
|
cases = {
|
||||||
|
"슈팅브레이크": "",
|
||||||
|
"The New G70 슈팅브레이크": "G70",
|
||||||
|
"G70 (슈팅브레이크)": "G70",
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertEqual(mapper._translate_model(raw), expected)
|
||||||
|
|
||||||
|
def test_translate_color_normalizes_api_spacing(self):
|
||||||
|
mapper = EncarMapper()
|
||||||
|
|
||||||
|
self.assertEqual(mapper._translate_color("흰색"), "white")
|
||||||
|
self.assertEqual(mapper._translate_color("흰 색"), "white")
|
||||||
|
self.assertEqual(mapper._translate_color("검정색"), "black")
|
||||||
|
self.assertEqual(mapper._translate_color("진주색"), "pearl")
|
||||||
|
self.assertEqual(mapper._translate_color("쥐색"), "mouse gray")
|
||||||
|
self.assertEqual(mapper._translate_color("은회색"), "silver-gray")
|
||||||
|
self.assertEqual(mapper._translate_color("하늘색"), "sky blue")
|
||||||
|
self.assertEqual(mapper._translate_color("자주색"), "burgundy")
|
||||||
|
|
||||||
|
def test_apply_batch_color_uses_spec_color_name(self):
|
||||||
|
payload = {
|
||||||
|
"Id": 42456458,
|
||||||
|
"Manufacturer": "Lincoln",
|
||||||
|
"Model": "Corsair",
|
||||||
|
"FormYear": "2022",
|
||||||
|
"Price": 2690,
|
||||||
|
"Mileage": 26600,
|
||||||
|
}
|
||||||
|
|
||||||
|
mapper = EncarMapper()
|
||||||
|
record = mapper.map_to_car_record(
|
||||||
|
ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="42456458"),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
mapper.apply_batch_color(record, {"spec": {"colorName": "흰 색"}})
|
||||||
|
|
||||||
|
self.assertEqual(record.brand, "Lincoln")
|
||||||
|
self.assertEqual(record.model, "Corsair")
|
||||||
|
self.assertEqual(record.year, 2022)
|
||||||
|
self.assertEqual(record.price, 26900000)
|
||||||
|
self.assertEqual(record.mileage, 26600)
|
||||||
|
self.assertEqual(record.color, "white")
|
||||||
|
|
||||||
|
|
||||||
class TestEncarScraper(unittest.TestCase):
|
class TestEncarScraper(unittest.TestCase):
|
||||||
def test_collect_listing_limits_results(self):
|
def test_collect_listing_limits_results(self):
|
||||||
scraper = EncarScraper()
|
scraper = EncarScraper()
|
||||||
|
|
||||||
scraper._fetch_listing_page = MagicMock(side_effect=[
|
scraper._fetch_listing_page = MagicMock(side_effect=[
|
||||||
{"SearchResults": [{"Id": 1}, {"Id": 2}]},
|
{"SearchResults": [{"Id": 1}, {"Id": 2}]},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -195,11 +195,14 @@ class TestAPILoad(unittest.TestCase):
|
|||||||
persistence: PersistenceService = app.state.persistence
|
persistence: PersistenceService = app.state.persistence
|
||||||
persistence.upsert_cars_batch([_make_car(i, images=4) for i in range(200)])
|
persistence.upsert_cars_batch([_make_car(i, images=4) for i in range(200)])
|
||||||
|
|
||||||
|
cls.app = app
|
||||||
cls.client = TestClient(app)
|
cls.client = TestClient(app)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
import os
|
import os
|
||||||
|
cls.client.close()
|
||||||
|
cls.app.state.persistence.engine.dispose()
|
||||||
if os.path.exists("test_api_load.db"):
|
if os.path.exists("test_api_load.db"):
|
||||||
os.remove("test_api_load.db")
|
os.remove("test_api_load.db")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user