516 lines
21 KiB
Python
516 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from ..browser.fast_client import FastListingVehicle, build_resizer_images_from_keys, parse_int, parse_text
|
|
from ..storage.enums import BODY_TYPE_ENUM_VALUES, DRIVE_ENUM_VALUES, GEARBOX_ENUM_VALUES
|
|
from ..storage.schemas import CarRecord, ImageRecord
|
|
|
|
ORIGIN_PREFIX = "mobilede:"
|
|
ORIGIN_URL_BASE = "https://www.MOBILEDE.com/VehicleDetail"
|
|
COLOR_ALIASES = {
|
|
"grau": "gray",
|
|
"grau metallic": "gray",
|
|
"gray": "gray",
|
|
"grey": "gray",
|
|
"silber": "silver",
|
|
"silver": "silver",
|
|
"schwarz": "black",
|
|
"black": "black",
|
|
"weiss": "white",
|
|
"weiß": "white",
|
|
"white": "white",
|
|
"blau": "blue",
|
|
"blue": "blue",
|
|
"rot": "red",
|
|
"red": "red",
|
|
"gruen": "green",
|
|
"grün": "green",
|
|
"green": "green",
|
|
"braun": "brown",
|
|
"brown": "brown",
|
|
"beige": "beige",
|
|
"orange": "orange",
|
|
"gelb": "yellow",
|
|
"yellow": "yellow",
|
|
"violett": "purple",
|
|
"lila": "purple",
|
|
"purple": "purple",
|
|
"gold": "gold",
|
|
}
|
|
DAMAGE_NEUTRAL_VALUES = {
|
|
"NORMAL WEAR & TEAR",
|
|
"NORMAL WEAR",
|
|
"NORMALWEAR&TEAR",
|
|
"NONE",
|
|
"NO DAMAGE",
|
|
"NO VISIBLE DAMAGE",
|
|
"MINOR DENT/SCRATCHES",
|
|
}
|
|
INACTIVE_STATUS_VALUES = {"SOLD", "SO", "CLOSED", "CN", "DELIVERED", "WITHDRAWN", "WDR", "COMPLETE", "COMPLETED"}
|
|
|
|
|
|
class FastCarMapper:
|
|
"""Maps MOBILEDE ProductDetailsVM payloads directly to project CarRecord."""
|
|
|
|
def map_payload_to_record(
|
|
self,
|
|
*,
|
|
detail_payload: dict[str, Any],
|
|
vehicle_url: str | None = None,
|
|
listing_vehicle: FastListingVehicle | None = None,
|
|
) -> CarRecord:
|
|
inventory_view = detail_payload.get("inventoryView")
|
|
if not isinstance(inventory_view, dict):
|
|
raise RuntimeError("detail payload missing inventoryView")
|
|
attributes = inventory_view.get("attributes")
|
|
if not isinstance(attributes, dict):
|
|
raise RuntimeError("detail payload missing inventoryView.attributes")
|
|
|
|
inventory_id = parse_text(attributes.get("Id"))
|
|
if not inventory_id and listing_vehicle is not None:
|
|
inventory_id = listing_vehicle.inventory_id
|
|
if not inventory_id and vehicle_url:
|
|
inventory_id = self._inventory_id_from_url(vehicle_url)
|
|
if not inventory_id:
|
|
raise RuntimeError("missing inventory id")
|
|
|
|
brand = self._limit_text(self._attr_text(attributes, "Make") or "UNKNOWN", 50)
|
|
model = self._build_model_name(
|
|
self._attr_text(attributes, "Model"),
|
|
self._attr_text(attributes, "Series", "Trim", "Variant"),
|
|
) or "UNKNOWN"
|
|
model = self._limit_text(model, 50)
|
|
year = self._parse_year(
|
|
self._attr_text(
|
|
attributes,
|
|
"Year",
|
|
"FirstRegistration",
|
|
"InitialRegistration",
|
|
"Erstzulassung",
|
|
"ModelYear",
|
|
)
|
|
)
|
|
descriptor_text = " ".join(
|
|
filter(
|
|
None,
|
|
[
|
|
brand,
|
|
model,
|
|
self._attr_text(attributes, "BodyStyleName", "VehicleClass", "Category", "CategoryDescription"),
|
|
self._attr_text(attributes, "DriveLineTypeDesc", "DriveType", "DriveTrain", "DriveTrainType", "DriveDescription"),
|
|
self._attr_text(attributes, "Transmission", "Gearbox", "TransmissionType"),
|
|
],
|
|
)
|
|
)
|
|
|
|
auction_info = detail_payload.get("auctionInformation")
|
|
auction_info = auction_info if isinstance(auction_info, dict) else {}
|
|
bidding_info = auction_info.get("biddingInformation")
|
|
bidding_info = bidding_info if isinstance(bidding_info, dict) else {}
|
|
prebid_info = auction_info.get("prebidInformation")
|
|
prebid_info = prebid_info if isinstance(prebid_info, dict) else {}
|
|
|
|
high_bid = self._first_positive_int(
|
|
prebid_info.get("decimalHighBidAmount"),
|
|
prebid_info.get("highBidAmount"),
|
|
bidding_info.get("highBidAmount"),
|
|
)
|
|
buy_now = self._first_positive_int(
|
|
bidding_info.get("buyNowAmount"),
|
|
prebid_info.get("buyNowPrice"),
|
|
bidding_info.get("buyNowPrice"),
|
|
)
|
|
price = high_bid if high_bid is not None else buy_now
|
|
|
|
image_dimensions = inventory_view.get("imageDimensions")
|
|
image_dimensions = image_dimensions if isinstance(image_dimensions, dict) else {}
|
|
keys_container = image_dimensions.get("keys")
|
|
keys_container = keys_container if isinstance(keys_container, dict) else {}
|
|
image_keys = keys_container.get("$values")
|
|
image_keys = image_keys if isinstance(image_keys, list) else []
|
|
images = [ImageRecord.model_validate(row) for row in build_resizer_images_from_keys(image_keys)]
|
|
|
|
primary_damage = self._attr_text(attributes, "PrimaryDamageDesc")
|
|
secondary_damage = self._attr_text(attributes, "SecondaryDamageDesc")
|
|
origin_url = vehicle_url or f"{ORIGIN_URL_BASE}/{inventory_id}"
|
|
normalized_origin_id = self._normalize_origin_inventory_id(inventory_id)
|
|
origin_id = f"{ORIGIN_PREFIX}{normalized_origin_id}"
|
|
drive_source = self._attr_text(
|
|
attributes,
|
|
"DriveLineTypeDesc",
|
|
"DriveType",
|
|
"DriveTrain",
|
|
"DriveTrainType",
|
|
"DriveDescription",
|
|
"Antrieb",
|
|
) or descriptor_text
|
|
gearbox_source = self._attr_text(attributes, "Transmission", "Gearbox", "TransmissionType") or descriptor_text
|
|
body_type_source = self._attr_text(
|
|
attributes,
|
|
"BodyStyleName",
|
|
"VehicleClass",
|
|
"Category",
|
|
"CategoryDescription",
|
|
"BodyType",
|
|
) or descriptor_text
|
|
engine_source = self._attr_text(
|
|
attributes,
|
|
"EngineSize",
|
|
"EngineInformation",
|
|
"EngineDisplacement",
|
|
"Displacement",
|
|
"CubicCapacity",
|
|
)
|
|
color_source = self._attr_text(attributes, "ExteriorColor", "ColorDesc", "Color")
|
|
|
|
return CarRecord(
|
|
parser_id=self._generate_parser_id(origin_id),
|
|
brand=brand,
|
|
model=model,
|
|
year=year,
|
|
price=price,
|
|
currency=self._map_currency(self._attr_text(attributes, "Currency") or (listing_vehicle.currency if listing_vehicle else None)),
|
|
mileage=self._parse_non_negative_int(self._attr_text(attributes, "ODOValue", "Mileage", "Kilometerstand", "Odometer")) or 0,
|
|
country=self._map_country(inventory_id, tenant=listing_vehicle.tenant if listing_vehicle else None),
|
|
is_sold=self._is_sold(listing_vehicle),
|
|
color=self._normalize_color(color_source),
|
|
drive=self._map_drive(drive_source),
|
|
gearbox=self._map_gearbox(gearbox_source),
|
|
steering_wheel="LEFT",
|
|
body_type=self._map_body_type(body_type_source),
|
|
engine_volume=self._parse_engine_volume(engine_source),
|
|
selling_type="AUCTION",
|
|
one_owner=False,
|
|
new_car=False,
|
|
is_hidden=not bool(images),
|
|
origin="MOBILEDE",
|
|
origin_url=origin_url,
|
|
origin_id=origin_id,
|
|
is_damaged=self._derive_is_damaged(primary_damage=primary_damage, secondary_damage=secondary_damage),
|
|
evaluation=self._attr_text(attributes, "VehicleGrade", "PriceRating"),
|
|
non_smoking=True,
|
|
rental=False,
|
|
repair_history=False,
|
|
slug=self._slugify(" ".join(filter(None, [brand, model, str(year or "")]))),
|
|
last_seen_at=datetime.now(timezone.utc),
|
|
images=images,
|
|
)
|
|
|
|
def payload_to_summary(self, detail_payload: dict[str, Any], vehicle_url: str) -> dict[str, Any]:
|
|
inventory_view = detail_payload.get("inventoryView") if isinstance(detail_payload, dict) else {}
|
|
inventory_view = inventory_view if isinstance(inventory_view, dict) else {}
|
|
attr = inventory_view.get("attributes")
|
|
attr = attr if isinstance(attr, dict) else {}
|
|
auction_info = detail_payload.get("auctionInformation") if isinstance(detail_payload, dict) else {}
|
|
auction_info = auction_info if isinstance(auction_info, dict) else {}
|
|
bidding_info = auction_info.get("biddingInformation")
|
|
bidding_info = bidding_info if isinstance(bidding_info, dict) else {}
|
|
prebid_info = auction_info.get("prebidInformation")
|
|
prebid_info = prebid_info if isinstance(prebid_info, dict) else {}
|
|
image_dimensions = inventory_view.get("imageDimensions")
|
|
image_dimensions = image_dimensions if isinstance(image_dimensions, dict) else {}
|
|
keys_container = image_dimensions.get("keys")
|
|
keys_container = keys_container if isinstance(keys_container, dict) else {}
|
|
image_keys = keys_container.get("$values")
|
|
image_keys = image_keys if isinstance(image_keys, list) else []
|
|
image_urls = [str(row["fullres_image"]) for row in build_resizer_images_from_keys(image_keys)]
|
|
engine_text = self._attr_text(attr, "EngineInformation", "EngineSize", "EngineDisplacement", "Displacement")
|
|
return {
|
|
"source_url": vehicle_url,
|
|
"lot_number": attr.get("Id") or attr.get("StockNumber") or attr.get("SalvageId"),
|
|
"year": attr.get("Year") or attr.get("FirstRegistration") or attr.get("Erstzulassung"),
|
|
"make": attr.get("Make"),
|
|
"model": attr.get("Model"),
|
|
"trim": attr.get("Series"),
|
|
"body_type": attr.get("BodyStyleName") or attr.get("VehicleClass") or attr.get("Category"),
|
|
"drive": attr.get("DriveLineTypeDesc") or attr.get("DriveType") or attr.get("DriveTrain"),
|
|
"engine": engine_text,
|
|
"fuel_type": attr.get("FuelTypeCode"),
|
|
"gearbox": attr.get("Transmission") or attr.get("Gearbox") or attr.get("TransmissionType"),
|
|
"color": attr.get("ExteriorColor") or attr.get("ColorDesc") or attr.get("Color"),
|
|
"primary_damage": attr.get("PrimaryDamageDesc"),
|
|
"secondary_damage": attr.get("SecondaryDamageDesc"),
|
|
"odometer": attr.get("ODOValue") or attr.get("Mileage") or attr.get("Kilometerstand"),
|
|
"location": attr.get("BranchName"),
|
|
"auction_date": attr.get("AuctionDateTime"),
|
|
"title": attr.get("Title"),
|
|
"current_bid": prebid_info.get("highBidAmount") or bidding_info.get("highBidAmount"),
|
|
"buy_now": prebid_info.get("buyNowPrice") or bidding_info.get("buyNowPrice"),
|
|
"actual_cash_value": attr.get("ProviderACV"),
|
|
"estimated_repair_cost": attr.get("EstRepairCost"),
|
|
"image_urls": image_urls,
|
|
}
|
|
|
|
@staticmethod
|
|
def _inventory_id_from_url(vehicle_url: str) -> str | None:
|
|
tail = urlparse(vehicle_url).path.rstrip("/").split("/")[-1]
|
|
return tail or None
|
|
|
|
@staticmethod
|
|
def _normalize_origin_inventory_id(inventory_id: str) -> str:
|
|
return inventory_id.strip().split("~", 1)[0]
|
|
|
|
@staticmethod
|
|
def _build_model_name(model: str | None, series: str | None) -> str:
|
|
unique_parts: list[str] = []
|
|
seen: set[str] = set()
|
|
for value in (model, series):
|
|
if not value:
|
|
continue
|
|
normalized = " ".join(value.split())
|
|
key = normalized.casefold()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
unique_parts.append(normalized)
|
|
return " ".join(unique_parts).strip()
|
|
|
|
@staticmethod
|
|
def _parse_year(value: Any) -> int | None:
|
|
if isinstance(value, str):
|
|
match = re.search(r"(19|20)\d{2}", value)
|
|
if match is not None:
|
|
parsed = int(match.group(0))
|
|
if 1900 <= parsed <= 2100:
|
|
return parsed
|
|
parsed = parse_int(value)
|
|
if parsed is None or parsed < 1900 or parsed > 2100:
|
|
return None
|
|
return parsed
|
|
|
|
@staticmethod
|
|
def _parse_non_negative_int(value: Any) -> int | None:
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if not text:
|
|
return None
|
|
grouped_match = re.search(r"\d{1,3}(?:[.,\s]\d{3})+(?!\d)", text)
|
|
if grouped_match is not None:
|
|
digits_only = re.sub(r"\D", "", grouped_match.group(0))
|
|
return int(digits_only) if digits_only else None
|
|
if re.fullmatch(r"\d{1,3}(?:[.,\s]\d{3})+", text):
|
|
digits_only = re.sub(r"\D", "", text)
|
|
return int(digits_only) if digits_only else None
|
|
parsed = parse_int(value)
|
|
if parsed is None or parsed < 0:
|
|
return None
|
|
return parsed
|
|
|
|
@staticmethod
|
|
def _attr_text(attributes: dict[str, Any], *keys: str) -> str | None:
|
|
for key in keys:
|
|
value = parse_text(attributes.get(key))
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
@classmethod
|
|
def _first_positive_int(cls, *values: Any) -> int | None:
|
|
for value in values:
|
|
parsed = cls._parse_non_negative_int(value)
|
|
if parsed is not None and parsed > 0:
|
|
return parsed
|
|
return None
|
|
|
|
@staticmethod
|
|
def _normalize_color(value: str | None) -> str:
|
|
if not value:
|
|
return "other"
|
|
normalized = FastCarMapper._simplify_text(value)
|
|
if "/" in normalized:
|
|
normalized = normalized.split("/", 1)[0].strip()
|
|
for alias, mapped in COLOR_ALIASES.items():
|
|
if alias in normalized:
|
|
return mapped
|
|
return normalized or "other"
|
|
|
|
@staticmethod
|
|
def _map_currency(value: str | None) -> str:
|
|
normalized = (value or "USD").strip().upper()
|
|
return normalized if normalized in {"USD", "CAD", "EUR", "JPY", "RUB", "KRW", "AED", "GBP"} else "USD"
|
|
|
|
@staticmethod
|
|
def _map_country(inventory_id: str, tenant: str | None = None) -> str:
|
|
upper_id = inventory_id.strip().upper()
|
|
if upper_id.endswith("~CA"):
|
|
return "CA"
|
|
if upper_id.endswith("~US"):
|
|
return "US"
|
|
tenant_normalized = (tenant or "").strip().upper()
|
|
if tenant_normalized in {"US", "CA", "JP", "KR"}:
|
|
return tenant_normalized
|
|
return "NA"
|
|
|
|
@staticmethod
|
|
def _map_drive(value: str | None) -> str | None:
|
|
if not value:
|
|
return None
|
|
normalized = FastCarMapper._simplify_text(value)
|
|
candidates: tuple[str, ...] | None = None
|
|
if any(marker in normalized for marker in ("front wheel", "front-wheel", "frontantrieb", "fwd", "traction avant")):
|
|
candidates = ("FWD",)
|
|
elif any(
|
|
marker in normalized
|
|
for marker in (
|
|
"all wheel",
|
|
"all-wheel",
|
|
"4x4",
|
|
"awd",
|
|
"four wheel",
|
|
"allrad",
|
|
"4matic",
|
|
"4motion",
|
|
"quattro",
|
|
"xdrive",
|
|
)
|
|
):
|
|
candidates = ("4WD", "FOUR_WD")
|
|
elif any(marker in normalized for marker in ("rear wheel", "rear-wheel", "heckantrieb", "rwd", "propulsion")):
|
|
candidates = ("RWD",)
|
|
elif "2wd" in normalized or "two wheel" in normalized:
|
|
candidates = ("2WD", "TWO_WD")
|
|
elif "unknown" in normalized or normalized in {"na", "n/a"}:
|
|
candidates = ("NA",)
|
|
return FastCarMapper._select_allowed(candidates, DRIVE_ENUM_VALUES) if candidates else None
|
|
|
|
@staticmethod
|
|
def _map_gearbox(value: str | None) -> str | None:
|
|
if not value:
|
|
return None
|
|
normalized = FastCarMapper._simplify_text(value)
|
|
candidates: tuple[str, ...] | None = None
|
|
if "cvt" in normalized:
|
|
candidates = ("CVT",)
|
|
elif any(marker in normalized for marker in ("manual", "mt", "schaltgetriebe", "schaltung", "stick shift")):
|
|
candidates = ("MT",)
|
|
elif "electric" in normalized or normalized == "ev":
|
|
candidates = ("EV",)
|
|
elif any(marker in normalized for marker in ("auto", "at", "automatik", "automatic", "dsg", "doppelkupplung", "semi automatic", "halbautomatik")):
|
|
candidates = ("AT",)
|
|
elif "unknown" in normalized or normalized in {"na", "n/a"}:
|
|
candidates = ("NA",)
|
|
return FastCarMapper._select_allowed(candidates, GEARBOX_ENUM_VALUES) if candidates else None
|
|
|
|
@staticmethod
|
|
def _map_body_type(value: str | None) -> str:
|
|
if not value:
|
|
return "OTHER"
|
|
normalized = FastCarMapper._simplify_text(value)
|
|
candidates: tuple[str, ...] | None = None
|
|
if any(marker in normalized for marker in ("sedan", "limousine", "saloon")):
|
|
candidates = ("SEDAN",)
|
|
elif any(marker in normalized for marker in ("sport utility", "suv", "crossover", "gelandewagen", "gelaendewagen", "off-road")):
|
|
candidates = ("SUV",)
|
|
elif any(marker in normalized for marker in ("hatch", "kleinwagen", "compact", "city car")):
|
|
candidates = ("HATCHBACK",)
|
|
elif any(marker in normalized for marker in ("wagon", "kombi", "estate", "touring", "variant", "avant", "shooting brake")):
|
|
candidates = ("STATION_WAGON", "Station Wagon")
|
|
elif "coupe" in normalized:
|
|
candidates = ("COUPE",)
|
|
elif "pickup" in normalized or ("crew" in normalized and "cab" in normalized):
|
|
candidates = ("PICKUP", "Pickup")
|
|
elif any(marker in normalized for marker in ("convertible", "roadster", "cabrio", "cabriolet", "spyder")):
|
|
candidates = ("OPEN", "Open")
|
|
elif any(marker in normalized for marker in ("van", "bus", "people mover", "mpv", "minivan", "tourer")):
|
|
candidates = ("MINIVAN",)
|
|
elif any(marker in normalized for marker in ("truck", "chassis", "pritsche")):
|
|
candidates = ("TRUCK", "Truck")
|
|
elif "rv" in normalized or "motorized" in normalized:
|
|
candidates = ("RV",)
|
|
elif normalized in {"other", "unknown"}:
|
|
candidates = ("OTHER", "Other")
|
|
return FastCarMapper._select_allowed(candidates, BODY_TYPE_ENUM_VALUES, fallback="OTHER") or "OTHER"
|
|
|
|
@staticmethod
|
|
def _parse_engine_volume(value: str | None) -> int | None:
|
|
if not value:
|
|
return None
|
|
normalized = value.strip().replace(",", ".")
|
|
match = re.search(r"(\d+(?:\.\d+)?)\s*[lL]\b", normalized)
|
|
if not match:
|
|
cc_match = re.search(r"(\d{3,5})\s*(?:ccm|cm3|cm³|cc)\b", normalized, flags=re.IGNORECASE)
|
|
if cc_match is not None:
|
|
cc = int(cc_match.group(1))
|
|
return cc if 0 < cc <= 10000 else None
|
|
raw_digits = re.fullmatch(r"\s*(\d{3,5})\s*", normalized)
|
|
if raw_digits is not None:
|
|
cc = int(raw_digits.group(1))
|
|
return cc if 0 < cc <= 10000 else None
|
|
return None
|
|
try:
|
|
liters = float(match.group(1))
|
|
except ValueError:
|
|
return None
|
|
cc = int(round(liters * 1000))
|
|
if cc <= 0 or cc > 10000:
|
|
return None
|
|
return cc
|
|
|
|
@staticmethod
|
|
def _derive_is_damaged(*, primary_damage: str | None, secondary_damage: str | None) -> bool:
|
|
neutral = {item.replace(" ", "").strip().upper() for item in DAMAGE_NEUTRAL_VALUES}
|
|
for value in (primary_damage, secondary_damage):
|
|
if not value:
|
|
continue
|
|
normalized = value.replace(" ", "").strip().upper()
|
|
if normalized and normalized not in neutral:
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _is_sold(listing_vehicle: FastListingVehicle | None) -> bool:
|
|
if listing_vehicle is None:
|
|
return False
|
|
if listing_vehicle.timed_auction_closed:
|
|
return True
|
|
status = (listing_vehicle.inventory_status or "").strip().upper()
|
|
return status in INACTIVE_STATUS_VALUES
|
|
|
|
@staticmethod
|
|
def _select_allowed(candidates: tuple[str, ...] | None, allowed: tuple[str, ...], fallback: str | None = None) -> str | None:
|
|
if not candidates:
|
|
return fallback if fallback in allowed else None
|
|
allowed_set = set(allowed)
|
|
for candidate in candidates:
|
|
if candidate in allowed_set:
|
|
return candidate
|
|
return fallback if fallback in allowed_set else None
|
|
|
|
@staticmethod
|
|
def _limit_text(value: str, max_length: int) -> str:
|
|
return value if len(value) <= max_length else value[:max_length].rstrip()
|
|
|
|
@staticmethod
|
|
def _simplify_text(value: str) -> str:
|
|
return (
|
|
value.strip()
|
|
.lower()
|
|
.replace("ä", "ae")
|
|
.replace("ö", "oe")
|
|
.replace("ü", "ue")
|
|
.replace("ß", "ss")
|
|
)
|
|
|
|
@staticmethod
|
|
def _slugify(value: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "car"
|
|
|
|
@staticmethod
|
|
def _generate_parser_id(origin_id: str) -> str:
|
|
import hashlib
|
|
from string import ascii_letters, digits
|
|
|
|
digest = hashlib.sha256(origin_id.encode()).digest()
|
|
alphabet = ascii_letters + digits
|
|
base = len(alphabet)
|
|
num = int.from_bytes(digest[:17], "big")
|
|
chars: list[str] = []
|
|
for _ in range(22):
|
|
num, idx = divmod(num, base)
|
|
chars.append(alphabet[idx])
|
|
return "car-" + "".join(chars)
|