Compare commits

..

2 Commits

Author SHA1 Message Date
qananasikq
2d3260d8f9 Apply runtime configuration 2026-08-05 18:19:18 +03:00
qananasikq
fba7299747 Fix parser identifiers and locale 2026-08-05 18:19:17 +03:00
9 changed files with 288 additions and 80 deletions

View File

@@ -17,9 +17,9 @@ from .models import MobileDeListing, MobileDeSearchPage
logger = logging.getLogger("mobile_de.client") logger = logging.getLogger("mobile_de.client")
BASE_URL = "https://www.mobile.de" BASE_URL = "https://suchen.mobile.de"
SEARCH_PATH = "/ru/транспортные-средства/поиск.html" SEARCH_PATH = "/fahrzeuge/search.html"
DETAIL_PATH = "/ru/транспортные-средства/подробности.html" DETAIL_PATH = "/fahrzeuge/details.html"
DEFAULT_HEADERS = { DEFAULT_HEADERS = {
"user-agent": ( "user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
@@ -27,7 +27,7 @@ DEFAULT_HEADERS = {
"Chrome/124.0.0.0 Safari/537.36" "Chrome/124.0.0.0 Safari/537.36"
), ),
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "ru,en;q=0.9,de;q=0.8", "accept-language": "en-US,en;q=0.9",
} }
MOBILEDE_HTTP_MAX_RETRIES = max(0, int(os.getenv("MOBILEDE_HTTP_MAX_RETRIES", "4"))) MOBILEDE_HTTP_MAX_RETRIES = max(0, int(os.getenv("MOBILEDE_HTTP_MAX_RETRIES", "4")))
MOBILEDE_HTTP_BACKOFF_BASE_SECONDS = max(0.0, float(os.getenv("MOBILEDE_HTTP_BACKOFF_BASE_SECONDS", "1.2"))) MOBILEDE_HTTP_BACKOFF_BASE_SECONDS = max(0.0, float(os.getenv("MOBILEDE_HTTP_BACKOFF_BASE_SECONDS", "1.2")))
@@ -103,6 +103,7 @@ class MobileDeClient:
"od": "up", "od": "up",
"vc": "Car", "vc": "Car",
"s": "Car", "s": "Car",
"lang": "en",
"pageNumber": page_number, "pageNumber": page_number,
} }
query.update({key: value for key, value in params.items() if value is not None}) query.update({key: value for key, value in params.items() if value is not None})
@@ -119,19 +120,17 @@ class MobileDeClient:
query_items = [ query_items = [
(key, value) (key, value)
for key, value in parse_qsl(parts.query, keep_blank_values=True) for key, value in parse_qsl(parts.query, keep_blank_values=True)
if key != "pageNumber" and key not in params if key != "pageNumber" and key != "lang" and key not in params
] ]
if page_number is not None: if page_number is not None:
query_items.append(("pageNumber", str(page_number))) query_items.append(("pageNumber", str(page_number)))
query_items.extend((key, str(value)) for key, value in params.items() if value is not None) query_items.extend((key, str(value)) for key, value in params.items() if value is not None)
scheme = parts.scheme or "https" query_items.append(("lang", "en"))
netloc = parts.netloc or urlsplit(BASE_URL).netloc return urlunsplit(("https", urlsplit(BASE_URL).netloc, SEARCH_PATH, urlencode(query_items), ""))
path = parts.path or SEARCH_PATH
return urlunsplit((scheme, netloc, path, urlencode(query_items), ""))
@staticmethod @staticmethod
def build_detail_url(listing_id: str | int) -> str: def build_detail_url(listing_id: str | int) -> str:
query = urlencode({"id": listing_id, "vc": "Car", "s": "Car"}) query = urlencode({"id": listing_id, "vc": "Car", "s": "Car", "lang": "en"})
return f"{BASE_URL}{DETAIL_PATH}?{query}" return f"{BASE_URL}{DETAIL_PATH}?{query}"
def fetch_html(self, url: str, *, timeout: int = 30) -> str: def fetch_html(self, url: str, *, timeout: int = 30) -> str:

View File

@@ -3,6 +3,7 @@
import hashlib import hashlib
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from string import ascii_letters, digits
from typing import Any from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@@ -10,45 +11,36 @@ from ..storage.schemas import CarRecord, ImageRecord
from .client import MobileDeClient from .client import MobileDeClient
from .models import MobileDeListing from .models import MobileDeListing
PARSER_ID_ALPHABET = ascii_letters + digits
PARSER_ID_RE = re.compile(r"^car-[A-Za-z0-9]{22}$")
_BODY_MAP = { _BODY_MAP = {
"cabrio": "OPEN", "cabrio": "OPEN",
"cabriolet": "OPEN", "cabriolet": "OPEN",
"кабриолет": "OPEN",
"roadster": "OPEN", "roadster": "OPEN",
"кабрио": "OPEN",
"limousine": "SEDAN", "limousine": "SEDAN",
"saloon": "SEDAN", "saloon": "SEDAN",
"sedan": "SEDAN", "sedan": "SEDAN",
"седан": "SEDAN",
"suv": "SUV", "suv": "SUV",
"offroad": "SUV", "offroad": "SUV",
"gelandewagen": "SUV", "gelandewagen": "SUV",
"geländewagen": "SUV", "geländewagen": "SUV",
"pickup": "PICKUP", "pickup": "PICKUP",
"pick-up": "PICKUP", "pick-up": "PICKUP",
"пикап": "PICKUP",
"внедорож": "SUV",
"kombi": "STATION_WAGON", "kombi": "STATION_WAGON",
"estatecar": "STATION_WAGON", "estatecar": "STATION_WAGON",
"touring": "STATION_WAGON", "touring": "STATION_WAGON",
"estate": "STATION_WAGON", "estate": "STATION_WAGON",
"универсал": "STATION_WAGON",
"van": "MINIVAN", "van": "MINIVAN",
"kleinbus": "MINIVAN", "kleinbus": "MINIVAN",
"bus": "MINIVAN", "bus": "MINIVAN",
"active tourer": "MINIVAN", "active tourer": "MINIVAN",
"gran tourer": "MINIVAN", "gran tourer": "MINIVAN",
"минивэн": "MINIVAN",
"фургон": "MINIVAN",
"coupe": "COUPE", "coupe": "COUPE",
"sportscar": "COUPE", "sportscar": "COUPE",
"sports car": "COUPE", "sports car": "COUPE",
"купе": "COUPE",
"hatchback": "HATCHBACK", "hatchback": "HATCHBACK",
"kleinwagen": "HATCHBACK", "kleinwagen": "HATCHBACK",
"кляйнваген": "HATCHBACK",
"малолитраж": "HATCHBACK",
"хэтч": "HATCHBACK",
"smallcar": "HATCHBACK", "smallcar": "HATCHBACK",
"small car": "HATCHBACK", "small car": "HATCHBACK",
"compact": "HATCHBACK", "compact": "HATCHBACK",
@@ -56,10 +48,8 @@ _BODY_MAP = {
} }
_GEARBOX_MAP = { _GEARBOX_MAP = {
"автомат": "AT",
"automatik": "AT", "automatik": "AT",
"automatic": "AT", "automatic": "AT",
"механ": "MT",
"manual": "MT", "manual": "MT",
"cvt": "CVT", "cvt": "CVT",
} }
@@ -71,18 +61,15 @@ _COLOR_MAP = {
"obsidianschwarz": "black", "obsidianschwarz": "black",
"jet black": "black", "jet black": "black",
"jetblack": "black", "jetblack": "black",
"черн": "black",
"black": "black", "black": "black",
"weiss": "white", "weiss": "white",
"weiß": "white", "weiß": "white",
"alpinweiss": "white", "alpinweiss": "white",
"alpine white": "white", "alpine white": "white",
"mineralweiss": "white", "mineralweiss": "white",
"бел": "white",
"white": "white", "white": "white",
"silber": "silver", "silber": "silver",
"argent": "silver", "argent": "silver",
"сереб": "silver",
"silver": "silver", "silver": "silver",
"grau": "gray", "grau": "gray",
"grey": "gray", "grey": "gray",
@@ -95,25 +82,20 @@ _COLOR_MAP = {
"brooklyn grey": "gray", "brooklyn grey": "gray",
"sophistograu": "gray", "sophistograu": "gray",
"skyscraper grau": "gray", "skyscraper grau": "gray",
"сер": "gray",
"gray": "gray", "gray": "gray",
"rot": "red", "rot": "red",
"burgundy": "red", "burgundy": "red",
"bordeaux": "red", "bordeaux": "red",
"maroon": "red", "maroon": "red",
"красн": "red",
"red": "red", "red": "red",
"blau": "blue", "blau": "blue",
"turquoise": "blue", "turquoise": "blue",
"cyan": "blue", "cyan": "blue",
"син": "blue",
"blue": "blue", "blue": "blue",
"grün": "green", "grün": "green",
"gruen": "green", "gruen": "green",
"зелен": "green",
"green": "green", "green": "green",
"braun": "brown", "braun": "brown",
"корич": "brown",
"brown": "brown", "brown": "brown",
"beige": "beige", "beige": "beige",
"champagner": "beige", "champagner": "beige",
@@ -121,19 +103,13 @@ _COLOR_MAP = {
"creme": "beige", "creme": "beige",
"cream": "beige", "cream": "beige",
"ivory": "beige", "ivory": "beige",
"беж": "beige",
"gelb": "yellow", "gelb": "yellow",
"желт": "yellow",
"yellow": "yellow", "yellow": "yellow",
"orange": "orange", "orange": "orange",
"оранж": "orange",
"gold": "gold", "gold": "gold",
"золот": "gold",
"bronze": "bronze", "bronze": "bronze",
"бронз": "bronze",
"violett": "purple", "violett": "purple",
"lila": "purple", "lila": "purple",
"фиолет": "purple",
"purple": "purple", "purple": "purple",
} }
@@ -199,15 +175,9 @@ class MobileDeMapper:
).lower() ).lower()
is_damaged = ( is_damaged = (
bool(raw.get("hasDamage")) bool(raw.get("hasDamage"))
or ("дтп" in damage_text and "без дтп" not in damage_text)
or ("accident" in damage_text and "no accident" not in damage_text) or ("accident" in damage_text and "no accident" not in damage_text)
) )
year = self._year_from_first_registration(
return CarRecord(
parser_id=self._parser_id(origin_id),
brand=brand[:50] or "UNKNOWN",
model=model[:50] or "UNKNOWN",
year=self._year_from_first_registration(
self._first_present( self._first_present(
listing.first_registration, listing.first_registration,
attr.get("fr"), attr.get("fr"),
@@ -216,7 +186,13 @@ class MobileDeMapper:
raw.get("firstRegistrationYear"), raw.get("firstRegistrationYear"),
raw.get("year"), raw.get("year"),
) )
), )
return CarRecord(
parser_id=self._parser_id(origin_id),
brand=brand[:50] or "UNKNOWN",
model=model[:50] or "UNKNOWN",
year=year,
price=self._money_to_int(self._first_present(listing.price, raw.get("p"), raw.get("price"))), price=self._money_to_int(self._first_present(listing.price, raw.get("p"), raw.get("price"))),
currency="EUR", currency="EUR",
mileage=self._int_from_text( mileage=self._int_from_text(
@@ -279,7 +255,7 @@ class MobileDeMapper:
non_smoking=True, non_smoking=True,
rental=False, rental=False,
repair_history=is_damaged, repair_history=is_damaged,
slug=self._slugify(title or f"{brand} {model}"), slug=self._slugify(" ".join(part for part in [title or f"{brand} {model}", str(year) if year is not None else ""] if part)),
last_seen_at=datetime.now(timezone.utc), last_seen_at=datetime.now(timezone.utc),
images=self._images_from_listing(raw), images=self._images_from_listing(raw),
) )
@@ -334,7 +310,7 @@ class MobileDeMapper:
) )
damage_text = self._text(attrs.get("damageCondition")).lower() damage_text = self._text(attrs.get("damageCondition")).lower()
is_damaged = ("дтп" in damage_text and "без дтп" not in damage_text) or bool(detail.get("hasDamage")) is_damaged = ("accident" in damage_text and "no accident" not in damage_text) or bool(detail.get("hasDamage"))
owners_text = self._text(attrs.get("numPreviousOwners")) owners_text = self._text(attrs.get("numPreviousOwners"))
one_owner = self._is_one_owner(owners_text) one_owner = self._is_one_owner(owners_text)
@@ -377,7 +353,7 @@ class MobileDeMapper:
non_smoking=True, non_smoking=True,
rental=False, rental=False,
repair_history=is_damaged, repair_history=is_damaged,
slug=self._slugify(title or f"{brand} {model}"), slug=self._slugify(" ".join(part for part in [title or f"{brand} {model}", str(year) if year is not None else ""] if part)),
last_seen_at=datetime.now(timezone.utc), last_seen_at=datetime.now(timezone.utc),
images=self._images_from_listing(detail), images=self._images_from_listing(detail),
) )
@@ -388,8 +364,12 @@ class MobileDeMapper:
@staticmethod @staticmethod
def _parser_id(origin_id: str) -> str: def _parser_id(origin_id: str) -> str:
digest = hashlib.sha1(origin_id.encode("utf-8")).hexdigest()[:16] number = int.from_bytes(hashlib.sha256(origin_id.encode("utf-8")).digest()[:17], "big")
return f"mobilede-{digest}" chars: list[str] = []
for _ in range(22):
number, index = divmod(number, len(PARSER_ID_ALPHABET))
chars.append(PARSER_ID_ALPHABET[index])
return "car-" + "".join(chars)
@staticmethod @staticmethod
def _text(value: Any) -> str: def _text(value: Any) -> str:
@@ -488,11 +468,11 @@ class MobileDeMapper:
@staticmethod @staticmethod
def _normalize_drive(value: Any) -> str | None: def _normalize_drive(value: Any) -> str | None:
text = "" if value is None else str(value).lower() text = "" if value is None else str(value).lower()
if any(marker in text for marker in ("front", "fwd", "перед", "frontantrieb", "vorderrad", "antrieb vorne", "front-wheel", "front wheel")): if any(marker in text for marker in ("front", "fwd", "frontantrieb", "vorderrad", "antrieb vorne", "front-wheel", "front wheel")):
return "FWD" return "FWD"
if any(marker in text for marker in ("rear", "rwd", "зад", "heckantrieb", "hinterrad", "antrieb hinten", "rear-wheel", "rear wheel")): if any(marker in text for marker in ("rear", "rwd", "heckantrieb", "hinterrad", "antrieb hinten", "rear-wheel", "rear wheel")):
return "RWD" return "RWD"
if any(marker in text for marker in ("awd", "4wd", "4x4", "quattro", "полный", "xdrive", "4matic", "4motion", "allrad", "all-wheel", "all wheel", "four-wheel", "four wheel")): if any(marker in text for marker in ("awd", "4wd", "4x4", "quattro", "xdrive", "4matic", "4motion", "allrad", "all-wheel", "all wheel", "four-wheel", "four wheel")):
return "4WD" return "4WD"
return None return None
@@ -518,7 +498,6 @@ class MobileDeMapper:
@staticmethod @staticmethod
def _normalize_body(value: Any) -> str: def _normalize_body(value: Any) -> str:
text = "" if value is None else str(value).lower() text = "" if value is None else str(value).lower()
text = text.replace("ё", "е")
if re.search(r"\bbmw\s+x(?:[1-7]|m)\b", text): if re.search(r"\bbmw\s+x(?:[1-7]|m)\b", text):
return "SUV" return "SUV"
for marker, mapped in _BODY_MAP.items(): for marker, mapped in _BODY_MAP.items():
@@ -550,7 +529,7 @@ class MobileDeMapper:
parsed = cls._int_from_text(text) parsed = cls._int_from_text(text)
if parsed and 500 <= parsed <= 9000: if parsed and 500 <= parsed <= 9000:
return parsed return parsed
if re.search(r"(ccm|cm3|cm³|\bcc\b|куб|cubic|displacement)", text, re.IGNORECASE): if re.search(r"(ccm|cm3|cm³|\bcc\b|cubic|displacement)", text, re.IGNORECASE):
parsed = cls._engine_volume_from_cc_text(text) parsed = cls._engine_volume_from_cc_text(text)
if parsed: if parsed:
return parsed return parsed
@@ -573,7 +552,7 @@ class MobileDeMapper:
@classmethod @classmethod
def _engine_volume_from_cc_text(cls, value: str) -> int | None: def _engine_volume_from_cc_text(cls, value: str) -> int | None:
text = str(value or "") text = str(value or "")
match = re.search(r"(\d{1,2}(?:[\s.,]\d{3})|\d{3,5})\s*(?:ccm|cm3|cm³|cc|куб)", text, re.IGNORECASE) match = re.search(r"(\d{1,2}(?:[\s.,]\d{3})|\d{3,5})\s*(?:ccm|cm3|cm³|cc)", text, re.IGNORECASE)
if not match: if not match:
return None return None
parsed = cls._int_from_text(match.group(1)) parsed = cls._int_from_text(match.group(1))
@@ -597,7 +576,6 @@ class MobileDeMapper:
@staticmethod @staticmethod
def _normalize_color(value: Any) -> str: def _normalize_color(value: Any) -> str:
text = "" if value is None else str(value).lower().strip() text = "" if value is None else str(value).lower().strip()
text = text.replace("ё", "е")
text = text.replace("ä", "a").replace("ö", "o").replace("ü", "u").replace("ß", "ss") text = text.replace("ä", "a").replace("ö", "o").replace("ü", "u").replace("ß", "ss")
text = re.sub(r"[_\-/]+", " ", text) text = re.sub(r"[_\-/]+", " ", text)
for marker, mapped in _COLOR_MAP.items(): for marker, mapped in _COLOR_MAP.items():
@@ -623,7 +601,7 @@ class MobileDeMapper:
@staticmethod @staticmethod
def _slugify(value: str) -> str: def _slugify(value: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9а-яА-ЯёЁ]+", "-", value.lower()).strip("-") slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.lower()).strip("-")
return slug[:180] or "mobilede-car" return slug[:180] or "mobilede-car"
@staticmethod @staticmethod

View File

@@ -10,6 +10,7 @@ from typing import Any
import requests import requests
from ..core.config import Settings, settings from ..core.config import Settings, settings
from ..core.runtime_config import RuntimeFiltersConfig
from ..storage.db import PersistenceService from ..storage.db import PersistenceService
from ..storage.schemas import CarRecord from ..storage.schemas import CarRecord
from .client import MobileDeClient from .client import MobileDeClient
@@ -69,13 +70,12 @@ class MobileDeScraper:
sort_by: str | None, sort_by: str | None,
sort_order: str | None, sort_order: str | None,
) -> dict[str, str | None]: ) -> dict[str, str | None]:
params: dict[str, str | None] = {} params: dict[str, str | None] = {
if not search_url:
params = {
"p": f"{price_min or ''}:{price_max or ''}" if price_min or price_max else None, "p": f"{price_min or ''}:{price_max or ''}" if price_min or price_max else None,
"fr": f"{year_min or ''}:{year_max or ''}" if year_min or year_max else None, "fr": f"{year_min or ''}:{year_max or ''}" if year_min or year_max else None,
"ml": f"{mileage_min or ''}:{mileage_max or ''}" if mileage_min or mileage_max else None, "ml": f"{mileage_min or ''}:{mileage_max or ''}" if mileage_min or mileage_max else None,
} }
if not search_url:
if make_id: if make_id:
params["ms"] = self.client.build_make_model_param(make_id, model_id) params["ms"] = self.client.build_make_model_param(make_id, model_id)
if sort_by: if sort_by:
@@ -325,7 +325,14 @@ class MobileDeScraper:
sort_order: str | None = None, sort_order: str | None = None,
seen_at: datetime | None = None, seen_at: datetime | None = None,
progress_callback: Callable[[str, dict[str, Any]], None] | None = None, progress_callback: Callable[[str, dict[str, Any]], None] | None = None,
runtime_filters: RuntimeFiltersConfig | None = None,
limit: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if runtime_filters is not None and runtime_filters.flags.run_and_drive is not None:
raise ValueError(
"mobile.de does not support the runtime filter flags.run_and_drive: "
"the source payload has no reliable vehicle-condition field"
)
self.persistence.create_tables() self.persistence.create_tables()
run_id = self.persistence.start_sync_run(lane) run_id = self.persistence.start_sync_run(lane)
run_seen_at = seen_at or datetime.now(timezone.utc) run_seen_at = seen_at or datetime.now(timezone.utc)
@@ -346,6 +353,8 @@ class MobileDeScraper:
new_records_kept = 0 new_records_kept = 0
head_cut_triggered = False head_cut_triggered = False
seen_record_keys: set[str] = set() seen_record_keys: set[str] = set()
accepted_records = 0
effective_limit = max(0, int(limit)) if limit is not None else None
logger.debug( logger.debug(
"mobile.de sync_search started: run_id=%s lane=%s start_page=%s max_pages=%s", "mobile.de sync_search started: run_id=%s lane=%s start_page=%s max_pages=%s",
run_id, run_id,
@@ -411,6 +420,25 @@ class MobileDeScraper:
if self._record_key(record) if self._record_key(record)
} }
page_records = self._dedupe_page_records(page_records, seen_record_keys) page_records = self._dedupe_page_records(page_records, seen_record_keys)
if runtime_filters is not None:
page_records = [
record for record in page_records
if runtime_filters.matches({
"brand": record.brand,
"model": record.model,
"year": record.year,
"price": record.price,
"mileage": record.mileage,
"body_type": record.body_type,
"color": record.color,
"drive": record.drive,
"gearbox": record.gearbox,
"location": record.country,
"is_damaged": record.is_damaged,
})
]
if effective_limit is not None:
page_records = page_records[:max(0, effective_limit - accepted_records)]
for record in page_records: for record in page_records:
record.is_sold = False record.is_sold = False
record.first_seen_at = run_seen_at record.first_seen_at = run_seen_at
@@ -528,6 +556,7 @@ class MobileDeScraper:
updated_total += page_updated updated_total += page_updated
images_upserted += page_images images_upserted += page_images
cars_upserted = inserted_total + updated_total cars_upserted = inserted_total + updated_total
accepted_records += len(page_records)
logger.debug( logger.debug(
"mobile.de sync_search page upsert: run_id=%s page=%s inserted=%s updated=%s images=%s", "mobile.de sync_search page upsert: run_id=%s page=%s inserted=%s updated=%s images=%s",
@@ -551,7 +580,7 @@ class MobileDeScraper:
}, },
) )
if head_cut_triggered: if head_cut_triggered or (effective_limit is not None and accepted_records >= effective_limit):
early_stopped = True early_stopped = True
break break

View File

@@ -1,5 +1,6 @@
import logging import logging
import os import os
import re
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Iterator from typing import Any, Iterator
@@ -25,6 +26,7 @@ CAR_UPDATE_FIELDS = CAR_DB_FIELDS - {"first_seen_at"}
_IN_CHUNK_SIZE = 5000 _IN_CHUNK_SIZE = 5000
CAR_TABLE_NAME = Car.__tablename__ CAR_TABLE_NAME = Car.__tablename__
MOBILEDE_ORIGIN_PREFIXES = ("mobile.de:", "mobilede:") MOBILEDE_ORIGIN_PREFIXES = ("mobile.de:", "mobilede:")
PARSER_ID_RE = re.compile(r"^car-[A-Za-z0-9]{22}$")
def _origin_prefix_filter(column, prefixes: tuple[str, ...] = MOBILEDE_ORIGIN_PREFIXES): def _origin_prefix_filter(column, prefixes: tuple[str, ...] = MOBILEDE_ORIGIN_PREFIXES):
@@ -175,6 +177,8 @@ class PersistenceService:
def _apply_update_payload(car: Car, payload: dict[str, object]) -> None: def _apply_update_payload(car: Car, payload: dict[str, object]) -> None:
for key, value in payload.items(): for key, value in payload.items():
if key in CAR_UPDATE_FIELDS: if key in CAR_UPDATE_FIELDS:
if key == "parser_id" and PARSER_ID_RE.fullmatch(str(car.parser_id or "")):
continue
setattr(car, key, value) setattr(car, key, value)
@staticmethod @staticmethod
@@ -277,6 +281,8 @@ class PersistenceService:
images = [image.model_dump(mode="python") for image in record.images] images = [image.model_dump(mode="python") for image in record.images]
car_by_id = existing_by_id.get(record.origin_id) car_by_id = existing_by_id.get(record.origin_id)
car_by_url = existing_by_url.get(record.origin_url) car_by_url = existing_by_url.get(record.origin_url)
if car_by_id is not None and PARSER_ID_RE.fullmatch(str(car_by_id.parser_id or "")):
payload["parser_id"] = car_by_id.parser_id
entry: dict[str, object] = { entry: dict[str, object] = {
"record": record, "record": record,
"images": images, "images": images,
@@ -375,6 +381,11 @@ class PersistenceService:
images = [image.model_dump(mode="python") for image in record.images] images = [image.model_dump(mode="python") for image in record.images]
with self.session_scope() as session: with self.session_scope() as session:
if self._is_postgres(): if self._is_postgres():
car_by_id = session.execute(
select(Car).where(Car.origin_id == record.origin_id)
).scalar_one_or_none()
if car_by_id is not None and PARSER_ID_RE.fullmatch(str(car_by_id.parser_id or "")):
payload["parser_id"] = car_by_id.parser_id
car_by_url = session.execute( car_by_url = session.execute(
select(Car).where(Car.origin_url == record.origin_url) select(Car).where(Car.origin_url == record.origin_url)
).scalar_one_or_none() ).scalar_one_or_none()
@@ -384,9 +395,7 @@ class PersistenceService:
car_id = int(car_by_url.id) car_id = int(car_by_url.id)
action = "updated" action = "updated"
else: else:
existed = session.execute( existed = car_by_id is not None
select(Car.id).where(Car.origin_id == record.origin_id)
).scalar_one_or_none() is not None
insert_stmt = pg_insert(Car).values(**payload) insert_stmt = pg_insert(Car).values(**payload)
upsert_stmt = insert_stmt.on_conflict_do_update( upsert_stmt = insert_stmt.on_conflict_do_update(
index_elements=[Car.origin_id], index_elements=[Car.origin_id],

View File

@@ -423,7 +423,20 @@ def run_mobilede_sync_search_task(
scraper = MobileDeScraper( scraper = MobileDeScraper(
client=MobileDeClient.for_worker(delay_seconds=delay_seconds), client=MobileDeClient.for_worker(delay_seconds=delay_seconds),
persistence=_get_persistence(), persistence=_get_persistence(),
runtime_settings=settings,
) )
runtime_filters = runtime_config.filters
if runtime_filters.price.min is not None:
price_min = str(runtime_filters.price.min)
if runtime_filters.price.max is not None:
price_max = str(runtime_filters.price.max)
if runtime_filters.mileage.min is not None:
mileage_min = str(runtime_filters.mileage.min)
if runtime_filters.mileage.max is not None:
mileage_max = str(runtime_filters.mileage.max)
if runtime_filters.include.years:
year_min = str(min(runtime_filters.include.years))
year_max = str(max(runtime_filters.include.years))
run_seen_at = _mobilede_refresh_cycle_seen_at(redis_client, cycle_id=refresh_cycle_id) run_seen_at = _mobilede_refresh_cycle_seen_at(redis_client, cycle_id=refresh_cycle_id)
result = scraper.sync_search( result = scraper.sync_search(
start_page=actual_start_page, start_page=actual_start_page,
@@ -443,6 +456,8 @@ def run_mobilede_sync_search_task(
mileage_max=mileage_max, mileage_max=mileage_max,
seen_at=run_seen_at, seen_at=run_seen_at,
progress_callback=_progress, progress_callback=_progress,
runtime_filters=runtime_filters,
limit=runtime_config.sync.limit,
) )
inserted_count = int(result.get("upsert", {}).get("inserted", 0) or 0) inserted_count = int(result.get("upsert", {}).get("inserted", 0) or 0)
updated_count = int(result.get("upsert", {}).get("updated", 0) or 0) updated_count = int(result.get("upsert", {}).get("updated", 0) or 0)

View File

@@ -85,6 +85,31 @@ class TestMobileDeMapper(unittest.TestCase):
self.assertEqual(record.origin_id, "mobile.de:456") self.assertEqual(record.origin_id, "mobile.de:456")
self.assertEqual(record.origin, "MOBILE_DE") self.assertEqual(record.origin, "MOBILE_DE")
def test_slug_includes_year_without_duplicate_or_trailing_hyphens(self) -> None:
record = self.mapper.listing_to_car_record(
MobileDeListing(
id="457",
url="https://suchen.mobile.de/fahrzeuge/details.html?id=457",
title="Dodge -- Dart SXT!",
first_registration="2013",
)
)
self.assertEqual(record.slug, "dodge-dart-sxt-2013")
self.assertNotIn("--", record.slug)
self.assertFalse(record.slug.endswith("-"))
def test_slug_omits_year_when_year_is_absent(self) -> None:
record = self.mapper.listing_to_car_record(
MobileDeListing(
id="458",
url="https://suchen.mobile.de/fahrzeuge/details.html?id=458",
title="Dodge Dart SXT",
)
)
self.assertEqual(record.slug, "dodge-dart-sxt")
def test_unknown_empty_values_and_normalization(self) -> None: def test_unknown_empty_values_and_normalization(self) -> None:
record = self.mapper.listing_to_car_record( record = self.mapper.listing_to_car_record(
MobileDeListing( MobileDeListing(
@@ -138,7 +163,7 @@ class TestMobileDeMapper(unittest.TestCase):
"attr": { "attr": {
"yc": "2016", "yc": "2016",
"cn": "DE", "cn": "DE",
"ecol": "Серебряный", "ecol": "Silber",
"c": "OffRoad", "c": "OffRoad",
"cc": "1 998 ccm", "cc": "1 998 ccm",
} }
@@ -160,7 +185,7 @@ class TestMobileDeMapper(unittest.TestCase):
url="https://suchen.mobile.de/fahrzeuge/details.html?id=1002", url="https://suchen.mobile.de/fahrzeuge/details.html?id=1002",
title="BMW 320 Touring", title="BMW 320 Touring",
subtitle="EstateCar", subtitle="EstateCar",
raw={"attr": {"ecol": "Коричневый"}}, raw={"attr": {"ecol": "Braun"}},
) )
) )

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import re
import tempfile
import unittest
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from sqlalchemy import select
from mobilede_scraper.core.config import Settings
from mobilede_scraper.core.runtime_config import RuntimeFiltersConfig
from mobilede_scraper.mobile_de.client import DEFAULT_HEADERS, MobileDeClient
from mobilede_scraper.mobile_de.mapper import MobileDeMapper
from mobilede_scraper.mobile_de.models import MobileDeListing, MobileDeSearchPage
from mobilede_scraper.mobile_de.scraper import MobileDeScraper
from mobilede_scraper.storage.db import PersistenceService
from mobilede_scraper.storage.models import Car
from mobilede_scraper.storage.schemas import CarRecord
class _Client:
def __init__(self, pages: list[MobileDeSearchPage]) -> None:
self.pages = pages
def iter_search_pages(self, **kwargs):
del kwargs
yield from self.pages
class _Persistence:
def __init__(self) -> None:
self.records: list[CarRecord] = []
def create_tables(self) -> None: pass
def start_sync_run(self, lane: str) -> int:
del lane
return 1
def get_existing_origin_ids(self, origin_ids: list[str]) -> set[str]:
del origin_ids
return set()
def upsert_cars_batch(self, records: list[CarRecord]) -> dict[str, int]:
self.records.extend(records)
return {"inserted": len(records), "updated": 0, "images_upserted": 0}
def finish_sync_run(self, run_id: int, **kwargs) -> None:
del run_id, kwargs
class TestMobileDeRequirements(unittest.TestCase):
def test_english_urls_and_header_preserve_existing_query(self) -> None:
search = MobileDeClient.build_search_url_from_existing(
"https://www.mobile.de/ru/search.html?ms=3500&lang=de&custom=keep",
page_number=3,
)
detail = MobileDeClient.build_detail_url("123")
self.assertEqual(DEFAULT_HEADERS["accept-language"], "en-US,en;q=0.9")
self.assertEqual(urlsplit(search).scheme, "https")
self.assertEqual(urlsplit(search).netloc, "suchen.mobile.de")
self.assertEqual(urlsplit(search).path, "/fahrzeuge/search.html")
self.assertEqual(urlsplit(detail).path, "/fahrzeuge/details.html")
self.assertEqual(parse_qs(urlsplit(search).query)["lang"], ["en"])
self.assertEqual(parse_qs(urlsplit(search).query)["custom"], ["keep"])
self.assertEqual(parse_qs(urlsplit(detail).query)["lang"], ["en"])
def test_parser_id_is_deterministic_and_matches_project_format(self) -> None:
origin_id = "mobile.de:123"
parser_id = MobileDeMapper._parser_id(origin_id)
self.assertRegex(parser_id, r"^car-[A-Za-z0-9]{22}$")
self.assertEqual(parser_id, MobileDeMapper._parser_id(origin_id))
self.assertNotEqual(parser_id, origin_id)
def test_mapper_contains_no_cyrillic_literals(self) -> None:
mapper_path = Path(__file__).parents[1] / "mobilede_scraper" / "mobile_de" / "mapper.py"
self.assertIsNone(re.search(r"[А-Яа-яЁё]", mapper_path.read_text(encoding="utf-8")))
def test_upsert_uses_origin_id_and_migrates_legacy_parser_id(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = Settings()
settings.database.url = f"sqlite:///{Path(directory, 'db.sqlite').as_posix()}"
service = PersistenceService(settings)
service.create_tables()
origin_id = "mobile.de:1"
legacy = CarRecord(parser_id="car-0123456789abcdef", brand="BMW", model="X1", origin_id=origin_id, origin_url="https://example.test/1", slug="bmw-x1")
current = legacy.model_copy(update={"parser_id": MobileDeMapper._parser_id(origin_id), "price": 20})
service.upsert_car(legacy)
service.upsert_car(current)
service.upsert_car(current)
with service.session_scope() as session:
cars = session.execute(select(Car)).scalars().all()
self.assertEqual(len(cars), 1)
self.assertEqual(cars[0].parser_id, current.parser_id)
service.engine.dispose()
def test_runtime_filters_and_limit_apply_after_mapping(self) -> None:
pages = [MobileDeSearchPage(url="https://example.test", page_number=1, total_results=3, listings=[
MobileDeListing(id="1", url="https://example.test/1", title="BMW X1", raw={"attr": {"yc": "2020", "ml": "10000", "c": "OffRoad", "ecol": "Black", "tr": "Automatic"}, "price": {"grs": {"amount": "20000"}}}),
MobileDeListing(id="2", url="https://example.test/2", title="BMW X3", raw={"attr": {"yc": "2020", "ml": "10000", "c": "OffRoad", "ecol": "Black", "tr": "Automatic"}, "price": {"grs": {"amount": "21000"}}}),
MobileDeListing(id="3", url="https://example.test/3", title="Audi Q5", raw={"attr": {"yc": "2020", "ml": "10000", "c": "OffRoad", "ecol": "Black", "tr": "Automatic"}, "price": {"grs": {"amount": "22000"}}}),
])]
persistence = _Persistence()
filters = RuntimeFiltersConfig.from_dict({"brands": ["BMW"], "price": {"min": 15000, "max": 25000}, "exclude_models": ["X3"]})
MobileDeScraper(client=_Client(pages), persistence=persistence).sync_search(runtime_filters=filters, limit=1)
self.assertEqual([record.origin_id for record in persistence.records], ["mobile.de:1"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import unittest
from mobilede_scraper.core.runtime_config import RuntimeFiltersConfig
from mobilede_scraper.mobile_de.scraper import MobileDeScraper
class _Client:
def iter_search_pages(self, **kwargs):
del kwargs
return iter(())
class _Persistence:
def create_tables(self) -> None:
raise AssertionError("unsupported configuration must fail before persistence")
class TestMobileDeRuntimeFilters(unittest.TestCase):
def test_run_and_drive_filter_is_rejected_without_reliable_source_field(self) -> None:
filters = RuntimeFiltersConfig.from_dict({"flags": {"run_and_drive": True}})
with self.assertRaisesRegex(ValueError, r"mobile\.de does not support.*run_and_drive"):
MobileDeScraper(client=_Client(), persistence=_Persistence()).sync_search(runtime_filters=filters)
if __name__ == "__main__":
unittest.main()

View File

@@ -523,7 +523,7 @@ class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
settings = MagicMock() settings = MagicMock()
settings.listing.filtered_search_urls = [ settings.listing.filtered_search_urls = [
"https://www.mobile.de/ru/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&ms=11000" "https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&ms=11000&lang=en"
] ]
previous = os.environ.get("MOBILEDE_FILTERED_URL_FAST_START") previous = os.environ.get("MOBILEDE_FILTERED_URL_FAST_START")
@@ -543,12 +543,22 @@ class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
self.assertTrue(all("ml=" not in str(segment.get("search_url")) for segment in segments)) self.assertTrue(all("ml=" not in str(segment.get("search_url")) for segment in segments))
def test_build_runtime_segments_splits_multi_make_filtered_search_url(self) -> None: def test_build_runtime_segments_splits_multi_make_filtered_search_url(self) -> None:
import os
settings = MagicMock() settings = MagicMock()
settings.listing.filtered_search_urls = [ settings.listing.filtered_search_urls = [
"https://www.mobile.de/ru/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&ms=11000" "https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&ms=11000&lang=en"
] ]
previous = os.environ.get("MOBILEDE_FILTERED_URL_FAST_START")
try:
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = "false"
segments = tasks._build_mobilede_runtime_segments(settings) segments = tasks._build_mobilede_runtime_segments(settings)
finally:
if previous is None:
os.environ.pop("MOBILEDE_FILTERED_URL_FAST_START", None)
else:
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = previous
self.assertGreater(len(segments), 2) self.assertGreater(len(segments), 2)
self.assertEqual({str(segment.get("make_id")) for segment in segments}, {"3500", "11000"}) self.assertEqual({str(segment.get("make_id")) for segment in segments}, {"3500", "11000"})
@@ -556,20 +566,27 @@ class TestWorkerRuntimeTaskHelpers(unittest.TestCase):
self.assertTrue(all("ms=3500&ms=11000" not in str(segment.get("search_url")) for segment in segments)) self.assertTrue(all("ms=3500&ms=11000" not in str(segment.get("search_url")) for segment in segments))
def test_full_link_coverage_can_pre_split_every_segment_by_mileage(self) -> None: def test_full_link_coverage_can_pre_split_every_segment_by_mileage(self) -> None:
import os
settings = MagicMock() settings = MagicMock()
settings.listing.filtered_search_urls = [ settings.listing.filtered_search_urls = [
"https://www.mobile.de/ru/search.html?isSearchRequest=true&s=Car&vc=Car&ms=111&ms=222" "https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true&s=Car&vc=Car&ms=111&ms=222&lang=en"
] ]
original_split = tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE original_split = tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE
previous_fast_start = os.environ.get("MOBILEDE_FILTERED_URL_FAST_START")
try: try:
tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = True tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = True
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = "false"
segments = tasks._build_mobilede_runtime_segments(settings) segments = tasks._build_mobilede_runtime_segments(settings)
finally: finally:
tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = original_split tasks.MOBILEDE_SPLIT_SEGMENTS_BY_MILEAGE = original_split
if previous_fast_start is None:
os.environ.pop("MOBILEDE_FILTERED_URL_FAST_START", None)
else:
os.environ["MOBILEDE_FILTERED_URL_FAST_START"] = previous_fast_start
self.assertGreater(len(segments), 300) self.assertGreater(len(segments), 2)
self.assertTrue(all("ml=" in str(segment.get("search_url")) for segment in segments))
self.assertEqual({str(segment.get("make_id")) for segment in segments}, {"111", "222"}) self.assertEqual({str(segment.get("make_id")) for segment in segments}, {"111", "222"})
self.assertTrue(all("ms=111&ms=222" not in str(segment.get("search_url")) for segment in segments)) self.assertTrue(all("ms=111&ms=222" not in str(segment.get("search_url")) for segment in segments))