Add vehicle color parsing
This commit is contained in:
@@ -420,11 +420,20 @@ class EncarMapper:
|
|||||||
return self._clean_base_model_name(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
|
||||||
@@ -1500,24 +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]]]:
|
||||||
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()
|
||||||
@@ -1535,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)
|
||||||
|
|
||||||
@@ -1565,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
|
||||||
|
|||||||
@@ -816,16 +816,20 @@ COLOR_TRANSLATIONS = {
|
|||||||
"검정색": "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",
|
||||||
@@ -839,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",
|
||||||
|
|||||||
@@ -126,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