From efbbf4a067e78903fc511b8eeb6521d7aaad81f2 Mon Sep 17 00:00:00 2001 From: qananasikq Date: Wed, 1 Jul 2026 14:42:35 +0300 Subject: [PATCH] Update model translations --- cars_100.txt | 100 +++++++++ encar_scraper/encar.py | 67 +++++- encar_scraper/partner_dictionary.py | 326 ++++++++++++++++++++++++++++ encar_scraper/translations.py | 265 ++++++++++++++++++++++ pyproject.toml | 1 + tests/test_encar.py | 45 +++- tests/test_load.py | 3 + 7 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 cars_100.txt create mode 100644 encar_scraper/partner_dictionary.py diff --git a/cars_100.txt b/cars_100.txt new file mode 100644 index 0000000..75cec4c --- /dev/null +++ b/cars_100.txt @@ -0,0 +1,100 @@ +1. Chevrolet | Spark | 2012 | id=42291468 +2. Mercedes-Benz | G-Class W465 | 2025 | id=42290455 +3. Honda | Civic | 2017 | id=41122992 +4. Honda | Civic | 2017 | id=41122190 +5. Hyundai | Grandeur IG | 2019 | id=42291487 +6. Mercedes-Benz | S-Class W223 | 2021 | id=42289505 +7. Kia | K5 | 2011 | id=42291450 +8. KG Mobility | Very New Tivoli | 2022 | id=41711722 +9. Mercedes-Benz | CLS-Class C257 | 2019 | id=42260678 +10. Kia | Seltos 2nd gen | 2026 | id=42291463 +11. BMW | X7 | 2023 | id=42291443 +12. Hyundai | Sonata | 2022 | id=42291455 +13. Mercedes-Benz | E-Class W213 | 2020 | id=42291456 +14. Hyundai | Grandeur Hybrid | 2025 | id=42291436 +15. Mercedes-Benz | C-Class W205 | 2019 | id=42291451 +16. Hyundai | Santa Fe TM | 2019 | id=42291444 +17. Mercedes-Benz | M-Class W164 | 2007 | id=42257783 +18. Land Rover | Discovery 4 | 2015 | id=42261349 +19. BMW | 3 Series | 2020 | id=42224479 +20. Land Rover | Range Rover Velar | 2019 | id=42273669 +21. Porsche | Cayenne | 2023 | id=42291413 +22. KG Mobility | Very New Tivoli | 2020 | id=42291412 +23. Hyundai | Avante MD | 2011 | id=42291395 +24. Chevrolet | All New Camaro | 2017 | id=42284504 +25. Renault Korea | SM6 | 2017 | id=42278601 +26. Kia | Mohave | 2012 | id=41576139 +27. Kia | Carnival 4th gen | 2023 | id=42291411 +28. Kia | All New K7 | 2016 | id=41951116 +29. Kia | Carnival 4th gen | 2023 | id=42283927 +30. BMW | 3 Series | 2006 | id=42291399 +31. Hyundai | Ioniq 6 | 2023 | id=42291352 +32. Hyundai | New Grand Starex | 2020 | id=41953927 +33. KG Mobility | Korando Sport | 2016 | id=41949895 +34. Renault Korea | New QM6 | 2020 | id=42291394 +35. Mercedes-Benz | E-Class W214 | 2024 | id=42291297 +36. Volvo | S60 | 2016 | id=42291398 +37. BMW | 3 Series | 2017 | id=42291386 +38. Kia | EV6 | 2022 | id=42291336 +39. KG Mobility | 2nd gen | 2026 | id=42291391 +40. Volkswagen | Jetta 7th gen | 2024 | id=42277008 +41. KG Mobility | 2nd gen | 2026 | id=42279398 +42. Chevrolet | Spark | 2012 | id=42291385 +43. Kia | New K9 2nd gen | 2022 | id=42291378 +44. Kia | Carnival 4th gen | 2021 | id=42291381 +45. Audi | A7 | 2015 | id=42291370 +46. Kia | Carnival 4th gen | 2021 | id=42283942 +47. Hyundai | Grandeur HG | 2016 | id=42291377 +48. Kia | Carnival 4th gen | 2021 | id=41916671 +49. Hyundai | New Palisade | 2023 | id=42291375 +50. Porsche | Panamera | 2024 | id=42290634 +51. Genesis | EQ900 | 2018 | id=42291315 +52. Hyundai | | 2015 | id=42291074 +53. Hyundai | Sonata | 2020 | id=42279668 +54. Hyundai | New Grandeur IG Hybrid | 2023 | id=42291312 +55. Kia | New Kia Ray | 2026 | id=42291083 +56. Mercedes-Benz | GLC-Class X253 | 2019 | id=42291373 +57. Hyundai | New Tucson | 2025 | id=42286946 +58. Hyundai | New Palisade | 2023 | id=42291369 +59. BMW | 3 Series | 2017 | id=42291367 +60. Mercedes-Benz | E-Class W213 | 2021 | id=42281942 +61. Kia | K8 | 2023 | id=42291332 +62. KG Mobility | Tivoli Air | 2017 | id=42291259 +63. Renault Korea | SM6 | 2017 | id=42291349 +64. Chevrolet | Cruze | 2014 | id=42291366 +65. Mercedes-Benz | E-Class W212 | 2015 | id=42291345 +66. Kia | K8 Hybrid | 2023 | id=42291363 +67. Renault Korea | NewQM3 | 2019 | id=42291364 +68. Mercedes-Benz | S-Class W222 | 2018 | id=42291350 +69. Hyundai | Palisade | 2021 | id=42291359 +70. Hyundai | Grand Starex | 2018 | id=42291358 +71. Mercedes-Benz | GLS-Class X167 | 2022 | id=42291347 +72. Hyundai | Palisade | 2021 | id=42278631 +73. Peugeot | 2008 | 2016 | id=42289865 +74. Mercedes-Benz | E-Class W213 | 2019 | id=42291344 +75. Hyundai | Grand Starex | 2017 | id=42273594 +76. Kia | All New Sorento | 2015 | id=42273586 +77. Kia | New Morning | 2016 | id=42254096 +78. Hyundai | Santa Fe DM | 2013 | id=42291313 +79. Mercedes-Benz | E-Class W213 | 2019 | id=42287351 +80. Kia | All New K7 | 2017 | id=42291339 +81. Kia | Niro | 2017 | id=42291337 +82. Infiniti | Q30 | 2019 | id=42291333 +83. Kia | Niro | 2017 | id=42289711 +84. Mercedes-Benz | E-Class W213 | 2020 | id=42291331 +85. Ford | Explorer 6th gen | 2023 | id=42291323 +86. Infiniti | Q30 | 2019 | id=42284562 +87. BMW | 3 Series | 2024 | id=42291329 +88. Genesis | GV70 | 2025 | id=42279323 +89. Hyundai | Grandeur HG | 2012 | id=42291328 +90. Mercedes-Benz | E-Class W213 | 2020 | id=42263463 +91. BMW | 3 Series | 2024 | id=42289258 +92. Genesis | GV70 | 2025 | id=42279316 +93. Hyundai | Grandeur HG | 2012 | id=42288803 +94. Kia | Ray | 2014 | id=42291325 +95. Porsche | Cayenne | 2021 | id=42289156 +96. KG Mobility | Tivoli Air | 2018 | id=41570718 +97. Toyota | Crown Crossover | 2024 | id=42291308 +98. Kia | New Sportage R | 2014 | id=42252147 +99. Genesis | GV80 | 2024 | id=42291320 +100. Kia | Ray | 2014 | id=42279563 diff --git a/encar_scraper/encar.py b/encar_scraper/encar.py index a27e1ee..523dbe1 100644 --- a/encar_scraper/encar.py +++ b/encar_scraper/encar.py @@ -26,6 +26,9 @@ from .translations import ( FUEL_TYPE_MAP, GEARBOX_MAP_KO, MODEL_BODY_TYPE_MAP, + MODEL_EXACT_TRANSLATIONS, + MODEL_PHRASE_TRANSLATIONS, + MODEL_TOKEN_TRANSLATIONS, MODEL_TRANSLATIONS, SELL_TYPE_MAP, expand_allowed_brands, @@ -173,7 +176,7 @@ class EncarMapper: model_raw = self._as_str(payload.get("Model") or "UNKNOWN") badge = self._as_str(payload.get("Badge") or "") badge_detail = self._as_str(payload.get("BadgeDetail") or "") - model = self._translate_text(model_raw) + model = self._translate_model(model_raw) year = self._to_year(payload.get("FormYear") or payload.get("Year")) # Price в 만원 (10 000 KRW) → переводим в KRW @@ -352,6 +355,68 @@ class EncarMapper: translated = re.sub(r"\s+", " ", translated).strip() 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 + # На оригинальном сайте Encar скобок нет — убираем скобки вместе с содержимым. + 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 _translate_model(self, text: str) -> str: + """Безопасно переводит модель Encar: точные совпадения + корейские фрагменты. + + Старый общий replace ломал смешанные названия. Здесь английские строки + возвращаются как есть, а короткие корейские токены заменяются только + когда они реально присутствуют как отдельный смысловой фрагмент. + """ + original = self._normalize_model_text(text) + if not original or not self._has_korean(original): + return original + + exact = MODEL_EXACT_TRANSLATIONS.get(original) + if exact: + return exact + + translated = original + + # Сначала длинные точные модели и фразы — чтобы "렉с턴 스포츠 칸" + # не превратился в "Rexton Sport 칸". + 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: + # Заменяем только когда корейский ключ стоит как отдельный + # смысловой фрагмент (границы — не корейские символы). Иначе + # короткий ключ "레이" (Ray) резал бы "슈팅브레이크" → "슈팅브Ray크". + pattern = rf"(? str: value = self._as_str(color_raw) if not value: diff --git a/encar_scraper/partner_dictionary.py b/encar_scraper/partner_dictionary.py new file mode 100644 index 0000000..6024399 --- /dev/null +++ b/encar_scraper/partner_dictionary.py @@ -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) diff --git a/encar_scraper/translations.py b/encar_scraper/translations.py index 6023e8d..08091ac 100644 --- a/encar_scraper/translations.py +++ b/encar_scraper/translations.py @@ -1,5 +1,15 @@ # Словари переводов корейского текста русский / английский. # Используется в encar.py для маппинга данных Encar API. +# +# Эталон переводов брендов и моделей — словарь партнёра +# (`partner_dictionary.py`). Он подмешивается ниже как база: значения оттуда +# имеют приоритет для брендов и моделей, а специфичные для проекта записи +# (корейские модели Hyundai/Kia/Genesis и т.п., которых нет у партнёра) +# сохраняются как дополнение. + +import re + +from .partner_dictionary import ENCAR_KO_EN_TABLES as _PARTNER_TABLES # Топливо @@ -176,6 +186,12 @@ BRAND_TRANSLATIONS = { "기타 수입차": "Other Imported", } +# Подмешиваем бренды из эталонного словаря партнёра как базу. Существующие +# записи проекта имеют приоритет (не перезаписываются), новые из словаря +# партнёра добавляются. +for _ko, _en in _PARTNER_TABLES["makers"].items(): + BRAND_TRANSLATIONS.setdefault(_ko, _en) + # Бренды: английский → алиасы (корейский + русский) @@ -277,6 +293,240 @@ def expand_allowed_brands(brands: set[str] | None) -> set[str] | None: # Модели / комплектации: корейский → английский +# Точные переводы базовых названий моделей 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", @@ -551,6 +801,21 @@ MODEL_TRANSLATIONS = { "그란투리스모": "GranTurismo", "반퀴시": "Vanquish", "밴티지": "Vantage", + # --- Дополнительные модели, встреченные в данных Encar --- + "시에라": "Sierra", + "시빅": "Civic", + "레인저": "Ranger", + "콜레오스": "Koleos", + "그랑 콜레오스": "Grand Koleos", + "페이톤": "Phaeton", + "티록": "T-Roc", + "큐브": "Cube", + "크라운": "Crown", + "라이즈": "Rise", + "시로코": "Scirocco", + "이오스": "Eos", + "비틀": "Beetle", + "폴로": "Polo", # --- Служебные / категории --- "(세부등급": "(sub-trim", "없음)": "none)", diff --git a/pyproject.toml b/pyproject.toml index 4d93735..4acce17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "httpx>=0.28.0", "pytest>=8.3.0", "pytest-cov>=5.0.0", ] diff --git a/tests/test_encar.py b/tests/test_encar.py index 8eacf44..442bce4 100644 --- a/tests/test_encar.py +++ b/tests/test_encar.py @@ -27,7 +27,8 @@ class TestEncarMapper(unittest.TestCase): self.assertEqual(record.origin_id, "encar:41421262") self.assertEqual(record.parser_id, "encar:41421262") self.assertEqual(record.brand, "Hyundai") - self.assertEqual(record.model, "G90 (RS4)") + # Скобки удаляются — на оригинальном сайте Encar их нет. + self.assertEqual(record.model, "G90") self.assertEqual(record.year, 2023) self.assertEqual(record.price, 85900000) # 8590 만원 × 10000 self.assertEqual(record.mileage, 37923) @@ -83,10 +84,52 @@ class TestEncarMapper(unittest.TestCase): 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"), "All New Carnival") + self.assertEqual(mapper._translate_model("New Kia Ray"), "New Kia Ray") + self.assertEqual(mapper._translate_model("K5 Hybrid 3rd gen"), "K5 Hybrid 3rd gen") + + def test_translate_model_handles_korean_fragments(self): + mapper = EncarMapper() + + cases = { + # Скобки и их содержимое отбрасываются как на сайте Encar. + "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 4th gen", + "Cadillac 에스컬레이드 5세대": "Cadillac Escalade 5th gen", + "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 = { + # "레이" (Ray) не должен резать слово "슈팅브레이크" изнутри. + "슈팅브레이크": "", + "The New G70 슈팅브레이크": "The New G70", + "G70 (슈팅브레이크)": "G70", + } + + for raw, expected in cases.items(): + with self.subTest(raw=raw): + self.assertEqual(mapper._translate_model(raw), expected) + class TestEncarScraper(unittest.TestCase): def test_collect_listing_limits_results(self): scraper = EncarScraper() + scraper._fetch_listing_page = MagicMock(side_effect=[ {"SearchResults": [{"Id": 1}, {"Id": 2}]}, ]) diff --git a/tests/test_load.py b/tests/test_load.py index 2507697..e574867 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -195,11 +195,14 @@ class TestAPILoad(unittest.TestCase): persistence: PersistenceService = app.state.persistence persistence.upsert_cars_batch([_make_car(i, images=4) for i in range(200)]) + cls.app = app cls.client = TestClient(app) @classmethod def tearDownClass(cls): import os + cls.client.close() + cls.app.state.persistence.engine.dispose() if os.path.exists("test_api_load.db"): os.remove("test_api_load.db")