Compare commits
2 Commits
6bdbbe396c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1756a959 | ||
|
|
72e78de1d7 |
@@ -362,7 +362,7 @@ class EncarMapper:
|
|||||||
normalized = self._as_str(text)
|
normalized = self._as_str(text)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return normalized
|
return normalized
|
||||||
# На оригинальном сайте Encar скобок нет — убираем скобки вместе с содержимым.
|
|
||||||
normalized = re.sub(r"[\(\[(【][^)\])】]*[\)\])】]?", " ", normalized)
|
normalized = re.sub(r"[\(\[(【][^)\])】]*[\)\])】]?", " ", normalized)
|
||||||
normalized = normalized.replace("·", " ").replace("/", " ")
|
normalized = normalized.replace("·", " ").replace("/", " ")
|
||||||
normalized = re.sub(r"(?<=[A-Za-z])-클래스", " 클래스", normalized)
|
normalized = re.sub(r"(?<=[A-Za-z])-클래스", " 클래스", normalized)
|
||||||
@@ -370,59 +370,70 @@ class EncarMapper:
|
|||||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
def _translate_model(self, text: str) -> str:
|
def _clean_base_model_name(self, text: str) -> str:
|
||||||
"""Безопасно переводит модель Encar: точные совпадения + корейские фрагменты.
|
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:
|
||||||
|
|
||||||
Старый общий replace ломал смешанные названия. Здесь английские строки
|
|
||||||
возвращаются как есть, а короткие корейские токены заменяются только
|
|
||||||
когда они реально присутствуют как отдельный смысловой фрагмент.
|
|
||||||
"""
|
|
||||||
original = self._normalize_model_text(text)
|
original = self._normalize_model_text(text)
|
||||||
if not original or not self._has_korean(original):
|
if not original or not self._has_korean(original):
|
||||||
return original
|
return self._clean_base_model_name(original)
|
||||||
|
|
||||||
exact = MODEL_EXACT_TRANSLATIONS.get(original)
|
exact = MODEL_EXACT_TRANSLATIONS.get(original)
|
||||||
if exact:
|
if exact:
|
||||||
return exact
|
return self._clean_base_model_name(exact)
|
||||||
|
|
||||||
translated = original
|
translated = original
|
||||||
|
|
||||||
# Сначала длинные точные модели и фразы — чтобы "렉с턴 스포츠 칸"
|
|
||||||
# не превратился в "Rexton Sport 칸".
|
|
||||||
combined = {**MODEL_EXACT_TRANSLATIONS, **MODEL_PHRASE_TRANSLATIONS, **MODEL_TRANSLATIONS}
|
combined = {**MODEL_EXACT_TRANSLATIONS, **MODEL_PHRASE_TRANSLATIONS, **MODEL_TRANSLATIONS}
|
||||||
for source, target in sorted(combined.items(), key=lambda item: len(item[0]), reverse=True):
|
for source, target in sorted(combined.items(), key=lambda item: len(item[0]), reverse=True):
|
||||||
if source in translated:
|
if source in translated:
|
||||||
# Заменяем только когда корейский ключ стоит как отдельный
|
|
||||||
# смысловой фрагмент (границы — не корейские символы). Иначе
|
|
||||||
# короткий ключ "레이" (Ray) резал бы "슈팅브레이크" → "슈팅브Ray크".
|
|
||||||
pattern = rf"(?<![가-힣]){re.escape(source)}(?![가-힣])"
|
pattern = rf"(?<![가-힣]){re.escape(source)}(?![가-힣])"
|
||||||
translated = re.sub(pattern, target, translated)
|
translated = re.sub(pattern, target, translated)
|
||||||
|
|
||||||
# Потом безопасные короткие токены.
|
|
||||||
for source, target in sorted(MODEL_TOKEN_TRANSLATIONS.items(), key=lambda item: len(item[0]), reverse=True):
|
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(rf"(?<![A-Za-z0-9가-힣]){re.escape(source)}(?![A-Za-z0-9가-힣])", target, translated)
|
||||||
|
|
||||||
# Типовые слитные варианты Encar: 5시리즈, S-클래스.
|
|
||||||
translated = re.sub(r"(?<=\d)시리즈", " Series", translated)
|
translated = re.sub(r"(?<=\d)시리즈", " Series", translated)
|
||||||
translated = re.sub(r"(?<=[A-Za-z])-?클래스", "-Class", 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"\b([A-Z]{1,4}) Class\b", r"\1-Class", translated)
|
||||||
|
|
||||||
# Оставшиеся непереведённые корейские фрагменты отбрасываем — как на
|
|
||||||
# оригинальном сайте (напр. "슈팅브레이크" исчезает, не портя слово).
|
|
||||||
translated = re.sub(r"[가-힣]+", " ", translated)
|
translated = re.sub(r"[가-힣]+", " ", translated)
|
||||||
# Чистим осиротевшую пунктуацию/скобки после удаления корейского.
|
|
||||||
translated = re.sub(r"\(\s*\)", " ", translated)
|
translated = re.sub(r"\(\s*\)", " ", translated)
|
||||||
translated = re.sub(r"\s*-\s*(?=$|\))", "", translated)
|
translated = re.sub(r"\s*-\s*(?=$|\))", "", translated)
|
||||||
translated = re.sub(r"\s+([),])", r"\1", translated)
|
translated = re.sub(r"\s+([),])", r"\1", translated)
|
||||||
translated = re.sub(r"\s+", " ", translated).strip(" -")
|
translated = re.sub(r"\s+", " ", translated).strip(" -")
|
||||||
return translated
|
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
|
||||||
@@ -1498,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 = 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)
|
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()
|
||||||
@@ -1534,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)
|
||||||
|
|
||||||
@@ -1564,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
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
# Словари переводов корейского текста русский / английский.
|
# Словари переводов корейского текста русский / английский.
|
||||||
# Используется в encar.py для маппинга данных Encar API.
|
# Используется в encar.py для маппинга данных Encar API.
|
||||||
#
|
|
||||||
# Эталон переводов брендов и моделей — словарь партнёра
|
|
||||||
# (`partner_dictionary.py`). Он подмешивается ниже как база: значения оттуда
|
|
||||||
# имеют приоритет для брендов и моделей, а специфичные для проекта записи
|
|
||||||
# (корейские модели Hyundai/Kia/Genesis и т.п., которых нет у партнёра)
|
|
||||||
# сохраняются как дополнение.
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -186,9 +180,6 @@ BRAND_TRANSLATIONS = {
|
|||||||
"기타 수입차": "Other Imported",
|
"기타 수입차": "Other Imported",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Подмешиваем бренды из эталонного словаря партнёра как базу. Существующие
|
|
||||||
# записи проекта имеют приоритет (не перезаписываются), новые из словаря
|
|
||||||
# партнёра добавляются.
|
|
||||||
for _ko, _en in _PARTNER_TABLES["makers"].items():
|
for _ko, _en in _PARTNER_TABLES["makers"].items():
|
||||||
BRAND_TRANSLATIONS.setdefault(_ko, _en)
|
BRAND_TRANSLATIONS.setdefault(_ko, _en)
|
||||||
|
|
||||||
@@ -293,10 +284,7 @@ def expand_allowed_brands(brands: set[str] | None) -> set[str] | None:
|
|||||||
|
|
||||||
# Модели / комплектации: корейский → английский
|
# Модели / комплектации: корейский → английский
|
||||||
|
|
||||||
# Точные переводы базовых названий моделей Encar. Этот словарь используется
|
|
||||||
# перед частичным переводом и не должен содержать общие слова вроде "뉴".
|
|
||||||
MODEL_EXACT_TRANSLATIONS = {
|
MODEL_EXACT_TRANSLATIONS = {
|
||||||
# BMW / Mercedes-Benz
|
|
||||||
"1시리즈": "1 Series",
|
"1시리즈": "1 Series",
|
||||||
"2시리즈": "2 Series",
|
"2시리즈": "2 Series",
|
||||||
"3시리즈": "3 Series",
|
"3시리즈": "3 Series",
|
||||||
@@ -318,7 +306,6 @@ MODEL_EXACT_TRANSLATIONS = {
|
|||||||
"GLE-클래스": "GLE-Class",
|
"GLE-클래스": "GLE-Class",
|
||||||
"GLS-클래스": "GLS-Class",
|
"GLS-클래스": "GLS-Class",
|
||||||
"V-클래스": "V-Class",
|
"V-클래스": "V-Class",
|
||||||
# Hyundai / Kia / Genesis
|
|
||||||
"팰리세이드": "Palisade",
|
"팰리세이드": "Palisade",
|
||||||
"싼타페": "Santa Fe",
|
"싼타페": "Santa Fe",
|
||||||
"투싼": "Tucson",
|
"투싼": "Tucson",
|
||||||
@@ -362,7 +349,6 @@ MODEL_EXACT_TRANSLATIONS = {
|
|||||||
"스토닉": "Stonic",
|
"스토닉": "Stonic",
|
||||||
"카렌스": "Carens",
|
"카렌스": "Carens",
|
||||||
"제네시스": "Genesis",
|
"제네시스": "Genesis",
|
||||||
# Chevrolet / Renault Korea / KG Mobility
|
|
||||||
"스파크": "Spark",
|
"스파크": "Spark",
|
||||||
"말리부": "Malibu",
|
"말리부": "Malibu",
|
||||||
"트랙스": "Trax",
|
"트랙스": "Trax",
|
||||||
@@ -389,7 +375,6 @@ MODEL_EXACT_TRANSLATIONS = {
|
|||||||
"코란도": "Korando",
|
"코란도": "Korando",
|
||||||
"체어맨": "Chairman",
|
"체어맨": "Chairman",
|
||||||
"오피러스": "Opirus",
|
"오피러스": "Opirus",
|
||||||
# Import models written in Korean
|
|
||||||
"익스플로러": "Explorer",
|
"익스플로러": "Explorer",
|
||||||
"머스탱": "Mustang",
|
"머스탱": "Mustang",
|
||||||
"카이엔": "Cayenne",
|
"카이엔": "Cayenne",
|
||||||
@@ -471,16 +456,10 @@ MODEL_EXACT_TRANSLATIONS = {
|
|||||||
"AMG GT 4도어 쿠페": "AMG GT 4-Door Coupe",
|
"AMG GT 4도어 쿠페": "AMG GT 4-Door Coupe",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Подмешиваем модели из эталонного словаря партнёра как базу. Существующие
|
|
||||||
# записи проекта имеют приоритет. Дополнительно добавляем нормализованные
|
|
||||||
# варианты ключей партнёра (напр. "5 시리즈" -> "5시리즈", "S 클래스" -> "S-클래스"),
|
|
||||||
# т.к. в сырых данных Encar формат отличается пробелами/дефисами.
|
|
||||||
def _register_partner_models() -> None:
|
def _register_partner_models() -> None:
|
||||||
for _ko, _en in _PARTNER_TABLES["models"].items():
|
for _ko, _en in _PARTNER_TABLES["models"].items():
|
||||||
MODEL_EXACT_TRANSLATIONS.setdefault(_ko, _en)
|
MODEL_EXACT_TRANSLATIONS.setdefault(_ko, _en)
|
||||||
# "5 시리즈" -> "5시리즈"
|
|
||||||
_compact = re.sub(r"(?<=\d)\s+시리즈", "시리즈", _ko)
|
_compact = re.sub(r"(?<=\d)\s+시리즈", "시리즈", _ko)
|
||||||
# "S 클래스" -> "S-클래스"
|
|
||||||
_compact = re.sub(r"(?<=[A-Za-z])\s+클래스", "-클래스", _compact)
|
_compact = re.sub(r"(?<=[A-Za-z])\s+클래스", "-클래스", _compact)
|
||||||
if _compact != _ko:
|
if _compact != _ko:
|
||||||
MODEL_EXACT_TRANSLATIONS.setdefault(_compact, _en)
|
MODEL_EXACT_TRANSLATIONS.setdefault(_compact, _en)
|
||||||
@@ -488,7 +467,6 @@ def _register_partner_models() -> None:
|
|||||||
|
|
||||||
_register_partner_models()
|
_register_partner_models()
|
||||||
|
|
||||||
# Безопасные составные фразы. Применяются только к строкам, где есть корейский текст.
|
|
||||||
MODEL_PHRASE_TRANSLATIONS = {
|
MODEL_PHRASE_TRANSLATIONS = {
|
||||||
"올 뉴": "All New",
|
"올 뉴": "All New",
|
||||||
"더 뉴": "New",
|
"더 뉴": "New",
|
||||||
@@ -517,7 +495,6 @@ MODEL_PHRASE_TRANSLATIONS = {
|
|||||||
"에어": "Air",
|
"에어": "Air",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Короткие токены переводятся только как отдельные токены, не внутри других слов.
|
|
||||||
MODEL_TOKEN_TRANSLATIONS = {
|
MODEL_TOKEN_TRANSLATIONS = {
|
||||||
"시리즈": "Series",
|
"시리즈": "Series",
|
||||||
"클래스": "Class",
|
"클래스": "Class",
|
||||||
@@ -801,7 +778,6 @@ MODEL_TRANSLATIONS = {
|
|||||||
"그란투리스모": "GranTurismo",
|
"그란투리스모": "GranTurismo",
|
||||||
"반퀴시": "Vanquish",
|
"반퀴시": "Vanquish",
|
||||||
"밴티지": "Vantage",
|
"밴티지": "Vantage",
|
||||||
# --- Дополнительные модели, встреченные в данных Encar ---
|
|
||||||
"시에라": "Sierra",
|
"시에라": "Sierra",
|
||||||
"시빅": "Civic",
|
"시빅": "Civic",
|
||||||
"레인저": "Ranger",
|
"레인저": "Ranger",
|
||||||
@@ -835,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",
|
||||||
@@ -865,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",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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")
|
||||||
# Скобки удаляются — на оригинальном сайте Encar их нет.
|
|
||||||
self.assertEqual(record.model, "G90")
|
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
|
||||||
@@ -87,23 +86,26 @@ class TestEncarMapper(unittest.TestCase):
|
|||||||
def test_translate_model_keeps_english_text(self):
|
def test_translate_model_keeps_english_text(self):
|
||||||
mapper = EncarMapper()
|
mapper = EncarMapper()
|
||||||
|
|
||||||
self.assertEqual(mapper._translate_model("All New Carnival"), "All New Carnival")
|
self.assertEqual(mapper._translate_model("All New Carnival"), "Carnival")
|
||||||
self.assertEqual(mapper._translate_model("New Kia Ray"), "New Kia Ray")
|
self.assertEqual(mapper._translate_model("New Kia Ray"), "Kia Ray")
|
||||||
self.assertEqual(mapper._translate_model("K5 Hybrid 3rd gen"), "K5 Hybrid 3rd gen")
|
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):
|
def test_translate_model_handles_korean_fragments(self):
|
||||||
mapper = EncarMapper()
|
mapper = EncarMapper()
|
||||||
|
|
||||||
cases = {
|
cases = {
|
||||||
# Скобки и их содержимое отбрасываются как на сайте Encar.
|
|
||||||
"5시리즈 (G30)": "5 Series",
|
"5시리즈 (G30)": "5 Series",
|
||||||
"S-클래스 W223": "S-Class W223",
|
"S-클래스 W223": "S-Class W223",
|
||||||
"Sonata 디 엣지(DN8)": "Sonata The Edge",
|
"Sonata 디 엣지(DN8)": "Sonata The Edge",
|
||||||
"렉스턴 Sport 칸": "Rexton Sport Khan",
|
"렉스턴 Sport 칸": "Rexton Sport Khan",
|
||||||
"티볼리 Air": "Tivoli Air",
|
"티볼리 Air": "Tivoli Air",
|
||||||
"코란도 투리스모": "Korando Turismo",
|
"코란도 투리스모": "Korando Turismo",
|
||||||
"Kia Carnival 4세대": "Kia Carnival 4th gen",
|
"Kia Carnival 4세대": "Kia Carnival",
|
||||||
"Cadillac 에스컬레이드 5세대": "Cadillac Escalade 5th gen",
|
"Cadillac 에스컬레이드 5세대": "Cadillac Escalade",
|
||||||
"K7 프리미어": "K7 Premier",
|
"K7 프리미어": "K7 Premier",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +117,8 @@ class TestEncarMapper(unittest.TestCase):
|
|||||||
mapper = EncarMapper()
|
mapper = EncarMapper()
|
||||||
|
|
||||||
cases = {
|
cases = {
|
||||||
# "레이" (Ray) не должен резать слово "슈팅브레이크" изнутри.
|
|
||||||
"슈팅브레이크": "",
|
"슈팅브레이크": "",
|
||||||
"The New G70 슈팅브레이크": "The New G70",
|
"The New G70 슈팅브레이크": "G70",
|
||||||
"G70 (슈팅브레이크)": "G70",
|
"G70 (슈팅브레이크)": "G70",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +126,42 @@ class TestEncarMapper(unittest.TestCase):
|
|||||||
with self.subTest(raw=raw):
|
with self.subTest(raw=raw):
|
||||||
self.assertEqual(mapper._translate_model(raw), expected)
|
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):
|
||||||
|
|||||||
Reference in New Issue
Block a user