Compare commits
2 Commits
800867b4e6
...
47ee4311c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ee4311c7 | ||
|
|
6dc08c15f0 |
@@ -52,12 +52,6 @@
|
|||||||
MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED: ${MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED:-true}
|
MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED: ${MOBILEDE_BOOTSTRAP_FULL_SCAN_ENABLED:-true}
|
||||||
MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP: ${MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP:-true}
|
MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP: ${MOBILEDE_INCREMENTAL_AFTER_BOOTSTRAP:-true}
|
||||||
MOBILEDE_INCREMENTAL_PAGE_WINDOW: ${MOBILEDE_INCREMENTAL_PAGE_WINDOW:-1}
|
MOBILEDE_INCREMENTAL_PAGE_WINDOW: ${MOBILEDE_INCREMENTAL_PAGE_WINDOW:-1}
|
||||||
MOBILEDE_FLARESOLVERR_ENABLED: ${MOBILEDE_FLARESOLVERR_ENABLED:-false}
|
|
||||||
MOBILEDE_FLARESOLVERR_URL: ${MOBILEDE_FLARESOLVERR_URL:-http://flaresolverr:8191/v1}
|
|
||||||
MOBILEDE_FLARESOLVERR_TIMEOUT_SECONDS: ${MOBILEDE_FLARESOLVERR_TIMEOUT_SECONDS:-120}
|
|
||||||
MOBILEDE_FLARESOLVERR_MAX_TIMEOUT_MS: ${MOBILEDE_FLARESOLVERR_MAX_TIMEOUT_MS:-60000}
|
|
||||||
MOBILEDE_FLARESOLVERR_SESSION: ${MOBILEDE_FLARESOLVERR_SESSION:-}
|
|
||||||
MOBILEDE_FLARESOLVERR_STATUSES: ${MOBILEDE_FLARESOLVERR_STATUSES:-403,429,503}
|
|
||||||
IAAI_STARTUP_SYNC_ENABLED: ${IAAI_STARTUP_SYNC_ENABLED:-true}
|
IAAI_STARTUP_SYNC_ENABLED: ${IAAI_STARTUP_SYNC_ENABLED:-true}
|
||||||
IAAI_INTER_BATCH_DELAY_SECONDS: ${IAAI_INTER_BATCH_DELAY_SECONDS:-0}
|
IAAI_INTER_BATCH_DELAY_SECONDS: ${IAAI_INTER_BATCH_DELAY_SECONDS:-0}
|
||||||
IAAI_FAIL_RATE_THRESHOLD: ${IAAI_FAIL_RATE_THRESHOLD:-0.9}
|
IAAI_FAIL_RATE_THRESHOLD: ${IAAI_FAIL_RATE_THRESHOLD:-0.9}
|
||||||
@@ -150,23 +144,6 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
flaresolverr:
|
|
||||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
|
||||||
container_name: mobilede-flaresolverr
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
LOG_LEVEL: ${FLARESOLVERR_LOG_LEVEL:-info}
|
|
||||||
LOG_HTML: ${FLARESOLVERR_LOG_HTML:-false}
|
|
||||||
CAPTCHA_SOLVER: ${FLARESOLVERR_CAPTCHA_SOLVER:-none}
|
|
||||||
TZ: ${TZ:-UTC}
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:${MOBILEDE_HOST_FLARESOLVERR_PORT:-8191}:8191"
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "5"
|
|
||||||
|
|
||||||
# Миграции.
|
# Миграции.
|
||||||
migrate:
|
migrate:
|
||||||
<<: *app-service
|
<<: *app-service
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
class ScraperError(Exception):
|
|
||||||
"""Базовое исключение скрапера."""
|
|
||||||
|
|
||||||
|
|
||||||
class AntiBotDetectedError(ScraperError):
|
|
||||||
"""Вызывается, когда сайт блокирует автоматизацию."""
|
|
||||||
|
|
||||||
|
|
||||||
class SiteStructureChangedError(ScraperError):
|
|
||||||
"""Вызывается, когда структура страницы изменилась и данных не хватает."""
|
|
||||||
|
|
||||||
|
|
||||||
class ListingResumeError(ScraperError):
|
|
||||||
"""Вызывается, когда resume по checkpoint больше недостижим."""
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import logging
|
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from playwright.sync_api import Error, TimeoutError as PlaywrightTimeoutError
|
|
||||||
|
|
||||||
from .exceptions import AntiBotDetectedError
|
|
||||||
|
|
||||||
logger = logging.getLogger("MOBILEDE_scraper.retry")
|
|
||||||
|
|
||||||
# Типы исключений, при которых retry имеет смысл.
|
|
||||||
RETRYABLE_EXCEPTIONS = (
|
|
||||||
PlaywrightTimeoutError,
|
|
||||||
Error,
|
|
||||||
ConnectionError,
|
|
||||||
OSError,
|
|
||||||
TimeoutError,
|
|
||||||
AntiBotDetectedError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def retryable(
|
|
||||||
max_attempts: int,
|
|
||||||
delay_seconds: float = 2.5,
|
|
||||||
backoff_multiplier: float = 2.0,
|
|
||||||
jitter_seconds: float = 0.0,
|
|
||||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
||||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
last_error: Exception | None = None
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
|
||||||
try:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
except RETRYABLE_EXCEPTIONS as exc:
|
|
||||||
last_error = exc
|
|
||||||
logger.warning("%s failed on attempt %s/%s: %s", func.__name__, attempt, max_attempts, exc)
|
|
||||||
if attempt < max_attempts:
|
|
||||||
sleep_for = delay_seconds * (backoff_multiplier ** (attempt - 1))
|
|
||||||
if jitter_seconds > 0:
|
|
||||||
sleep_for += random.uniform(0, jitter_seconds)
|
|
||||||
logger.debug("Retrying %s in %.2fs", func.__name__, sleep_for)
|
|
||||||
time.sleep(sleep_for)
|
|
||||||
if last_error is not None:
|
|
||||||
raise last_error
|
|
||||||
raise RuntimeError("Retry wrapper failed without a captured exception")
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def save_to_json(data: Any, filename: str | Path) -> None:
|
def save_to_json(data: Any, filename: str | Path) -> None:
|
||||||
@@ -10,21 +9,7 @@ def save_to_json(data: Any, filename: str | Path) -> None:
|
|||||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def first_non_empty(values: Iterable[Any]) -> Any | None:
|
|
||||||
for value in values:
|
|
||||||
if value not in (None, "", [], {}, ()):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Регулярные выражения для VIN, lot и price.
|
|
||||||
VIN_RE = re.compile(r"\b([A-HJ-NPR-Z0-9]{17})\b", re.IGNORECASE)
|
|
||||||
LOT_RE = re.compile(r"\b(\d{7,10})\b")
|
|
||||||
PRICE_RE = re.compile(r"\$\s?([\d,]+(?:\.\d{1,2})?)")
|
|
||||||
|
|
||||||
|
|
||||||
def deep_find_key(obj, target_keys: set[str], max_depth: int = 64, _depth: int = 0) -> list:
|
def deep_find_key(obj, target_keys: set[str], max_depth: int = 64, _depth: int = 0) -> list:
|
||||||
# Рекурсивно ищет значения по набору ключей в произвольном JSON-дереве.
|
|
||||||
found = []
|
found = []
|
||||||
if _depth >= max_depth:
|
if _depth >= max_depth:
|
||||||
return found
|
return found
|
||||||
@@ -37,36 +22,3 @@ def deep_find_key(obj, target_keys: set[str], max_depth: int = 64, _depth: int =
|
|||||||
for item in obj:
|
for item in obj:
|
||||||
found.extend(deep_find_key(item, target_keys, max_depth=max_depth, _depth=_depth + 1))
|
found.extend(deep_find_key(item, target_keys, max_depth=max_depth, _depth=_depth + 1))
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def deep_find_all_keys(
|
|
||||||
payloads: list,
|
|
||||||
field_map: dict[str, set[str]],
|
|
||||||
max_depth: int = 64,
|
|
||||||
) -> dict[str, list]:
|
|
||||||
"""Извлекает все нужные поля за один проход по JSON."""
|
|
||||||
# Готовим обратную карту: нормализованный ключ -> имя поля.
|
|
||||||
reverse: dict[str, str] = {}
|
|
||||||
for field_name, keys in field_map.items():
|
|
||||||
for k in keys:
|
|
||||||
reverse[k.lower()] = field_name
|
|
||||||
|
|
||||||
result: dict[str, list] = {f: [] for f in field_map}
|
|
||||||
|
|
||||||
def _recurse(obj: Any, depth: int) -> None:
|
|
||||||
if depth >= max_depth:
|
|
||||||
return
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
for k, v in obj.items():
|
|
||||||
field = reverse.get(k.lower())
|
|
||||||
if field is not None:
|
|
||||||
result[field].append(v)
|
|
||||||
_recurse(v, depth + 1)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for item in obj:
|
|
||||||
_recurse(item, depth + 1)
|
|
||||||
|
|
||||||
for payload in payloads:
|
|
||||||
_recurse(payload, 0)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ class MobileDeMapper:
|
|||||||
return "US"
|
return "US"
|
||||||
if text in {"CA", "CANADA"}:
|
if text in {"CA", "CANADA"}:
|
||||||
return "CA"
|
return "CA"
|
||||||
|
if text in {"IT", "ITALY", "ITALIA"}:
|
||||||
|
return "IT"
|
||||||
if text in {"JP", "JAPAN"}:
|
if text in {"JP", "JAPAN"}:
|
||||||
return "JP"
|
return "JP"
|
||||||
if text in {"KR", "KOREA", "SOUTH KOREA"}:
|
if text in {"KR", "KOREA", "SOUTH KOREA"}:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
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
|
||||||
@@ -12,6 +13,7 @@ from .models import Base, Car, Image, SyncRun
|
|||||||
from .schemas import CarRecord
|
from .schemas import CarRecord
|
||||||
|
|
||||||
logger = logging.getLogger("MOBILEDE_scraper.db")
|
logger = logging.getLogger("MOBILEDE_scraper.db")
|
||||||
|
MOBILEDE_SKIP_IMAGES_FOR_UPDATED = os.getenv("MOBILEDE_SKIP_IMAGES_FOR_UPDATED", "1").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
CAR_DB_FIELDS = {
|
CAR_DB_FIELDS = {
|
||||||
@@ -264,17 +266,19 @@ 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)
|
||||||
entry: dict[str, object] = {"record": record, "images": images, "car_id": None}
|
entry: dict[str, object] = {"record": record, "images": images, "car_id": None, "action": "inserted"}
|
||||||
|
|
||||||
if car_by_url is not None and car_by_url.origin_id != record.origin_id and car_by_id is None:
|
if car_by_url is not None and car_by_url.origin_id != record.origin_id and car_by_id is None:
|
||||||
for key, value in payload.items():
|
for key, value in payload.items():
|
||||||
setattr(car_by_url, key, value)
|
setattr(car_by_url, key, value)
|
||||||
car_by_url.last_seen_at = record.last_seen_at
|
car_by_url.last_seen_at = record.last_seen_at
|
||||||
entry["car_id"] = int(car_by_url.id)
|
entry["car_id"] = int(car_by_url.id)
|
||||||
|
entry["action"] = "updated"
|
||||||
updated += 1
|
updated += 1
|
||||||
else:
|
else:
|
||||||
upsert_payloads.append(payload)
|
upsert_payloads.append(payload)
|
||||||
if car_by_id is not None:
|
if car_by_id is not None:
|
||||||
|
entry["action"] = "updated"
|
||||||
updated += 1
|
updated += 1
|
||||||
else:
|
else:
|
||||||
inserted += 1
|
inserted += 1
|
||||||
@@ -306,7 +310,10 @@ class PersistenceService:
|
|||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
car_id = int(entry["car_id"])
|
car_id = int(entry["car_id"])
|
||||||
|
action = str(entry.get("action") or "updated")
|
||||||
images = entry["images"]
|
images = entry["images"]
|
||||||
|
if MOBILEDE_SKIP_IMAGES_FOR_UPDATED and action == "updated":
|
||||||
|
continue
|
||||||
new_image_urls = {
|
new_image_urls = {
|
||||||
str(img.get("fullres_image", ""))
|
str(img.get("fullres_image", ""))
|
||||||
for img in images
|
for img in images
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ BODY_TYPE_ENUM_VALUES = (
|
|||||||
"OTHER",
|
"OTHER",
|
||||||
"NA",
|
"NA",
|
||||||
)
|
)
|
||||||
COUNTRY_ENUM_VALUES = ("JP", "KR", "US", "CA", "DE", "NA")
|
COUNTRY_ENUM_VALUES = ("JP", "KR", "US", "CA", "DE", "IT", "NA")
|
||||||
ORIGIN_ENUM_VALUES = (
|
ORIGIN_ENUM_VALUES = (
|
||||||
"MOBILEDE",
|
"MOBILEDE",
|
||||||
"MOBILE_DE",
|
"MOBILE_DE",
|
||||||
|
|||||||
Reference in New Issue
Block a user