158 lines
6.7 KiB
Python
158 lines
6.7 KiB
Python
import logging
|
||
import re
|
||
from dataclasses import asdict, dataclass, field
|
||
from typing import Any
|
||
from urllib.parse import urljoin
|
||
|
||
from playwright.sync_api import Page
|
||
|
||
from .pace import HumanPacer
|
||
from ..core.config import Settings
|
||
from ..core.utils import first_non_empty
|
||
|
||
logger = logging.getLogger("iaai_scraper.listing")
|
||
VEHICLE_HREF_RE = re.compile(r"/VehicleDetail/\d+(?:~[A-Z]{2})?", re.IGNORECASE)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class ListingVehicleLink:
|
||
# Ссылка на карточку
|
||
href: str
|
||
title: str = ""
|
||
lot_number: str | None = None
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class ListingPageResult:
|
||
# Результат страницы листинга
|
||
source_url: str
|
||
page_number: int
|
||
vehicle_links: list[ListingVehicleLink] = field(default_factory=list)
|
||
pagination_available: bool = False
|
||
next_page_detected: bool = False
|
||
|
||
|
||
class ListingCollector:
|
||
def __init__(self, settings: Settings, pacer: HumanPacer) -> None:
|
||
# Сборщик ссылок листинга
|
||
self.settings = settings
|
||
self.pacer = pacer
|
||
|
||
def open_cars_listing(self, page: Page) -> None:
|
||
# Открываем листинг и ждём ссылки
|
||
logger.info("Opening cars listing page: %s", self.settings.listing.cars_url)
|
||
page.goto(self.settings.listing.cars_url, wait_until="domcontentloaded")
|
||
try:
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
except Exception:
|
||
logger.debug("networkidle timeout on listing page, continuing with current state")
|
||
try:
|
||
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=20000)
|
||
except Exception:
|
||
logger.warning("Vehicle links did not appear within timeout; page may not have rendered fully")
|
||
self.pacer.after_listing_open()
|
||
|
||
def apply_filters(self, page: Page, make: str | None = None, model: str | None = None) -> dict[str, str | None]:
|
||
# Пробуем make/model фильтры
|
||
applied = {"make": None, "model": None}
|
||
if make and self._try_fill_filter_input(page, ["input[placeholder*='Make']", "input[aria-label*='Make']"], make):
|
||
applied["make"] = make
|
||
self.pacer.after_filter_action()
|
||
if model and self._try_fill_filter_input(page, ["input[placeholder*='Model']", "input[aria-label*='Model']"], model):
|
||
applied["model"] = model
|
||
self.pacer.after_filter_action()
|
||
return applied
|
||
|
||
def collect_current_page(self, page: Page, page_number: int = 1) -> ListingPageResult:
|
||
# Собираем ссылки текущей страницы
|
||
anchors = page.locator("a[href*='/VehicleDetail/']")
|
||
total = min(anchors.count(), self.settings.listing.page_link_limit)
|
||
links: list[ListingVehicleLink] = []
|
||
seen: set[str] = set()
|
||
for idx in range(total):
|
||
anchor = anchors.nth(idx)
|
||
href = anchor.get_attribute("href") or ""
|
||
match = VEHICLE_HREF_RE.search(href)
|
||
if not match:
|
||
continue
|
||
absolute = urljoin(self.settings.home_url, match.group(0))
|
||
if absolute in seen:
|
||
continue
|
||
seen.add(absolute)
|
||
title = first_non_empty([anchor.get_attribute("title"), anchor.text_content(), ""]) or ""
|
||
links.append(ListingVehicleLink(href=absolute, title=str(title).strip()))
|
||
if len(links) >= self.settings.listing.max_vehicles_per_run:
|
||
break
|
||
next_page_detected = self._has_next_page(page)
|
||
return ListingPageResult(source_url=page.url, page_number=page_number, vehicle_links=links, pagination_available=next_page_detected, next_page_detected=next_page_detected)
|
||
|
||
def go_to_next_page(self, page: Page) -> bool:
|
||
# Переход на следующую страницу
|
||
selectors = ["a[aria-label*='Next']", "button[aria-label*='Next']", "a.pagination-next", "button.pagination-next", "a:has-text('Next')", "button:has-text('Next')"]
|
||
for selector in selectors:
|
||
locator = page.locator(selector).first
|
||
if locator.count() == 0:
|
||
continue
|
||
disabled = (locator.get_attribute("disabled") or "").lower()
|
||
aria_disabled = (locator.get_attribute("aria-disabled") or "").lower()
|
||
classes = (locator.get_attribute("class") or "").lower()
|
||
if disabled or aria_disabled == "true" or "disabled" in classes:
|
||
continue
|
||
self.pacer.move_mouse_to(page, locator)
|
||
locator.click()
|
||
try:
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
except Exception:
|
||
pass
|
||
self.pacer.after_page_change()
|
||
return True
|
||
return False
|
||
|
||
def collect_listing_links(self, page: Page, *, make: str | None = None, model: str | None = None) -> dict[str, Any]:
|
||
# Последовательный сбор с лимитами
|
||
self.open_cars_listing(page)
|
||
applied_filters = self.apply_filters(page, make=make, model=model)
|
||
pages: list[dict[str, object]] = []
|
||
all_links: list[str] = []
|
||
for page_number in range(1, max(1, self.settings.listing.max_pages_per_run) + 1):
|
||
page_result = self.collect_current_page(page, page_number=page_number)
|
||
pages.append({"page_number": page_result.page_number, "source_url": page_result.source_url, "links_found": len(page_result.vehicle_links), "vehicle_links": [asdict(item) for item in page_result.vehicle_links], "next_page_detected": page_result.next_page_detected})
|
||
for item in page_result.vehicle_links:
|
||
if item.href not in all_links:
|
||
all_links.append(item.href)
|
||
if len(all_links) >= self.settings.listing.max_vehicles_per_run:
|
||
break
|
||
if len(all_links) >= self.settings.listing.max_vehicles_per_run or self.settings.listing.collect_current_page_only or not self.settings.listing.include_pagination or not page_result.next_page_detected:
|
||
break
|
||
if not self.go_to_next_page(page):
|
||
break
|
||
return {"listing_url": self.settings.listing.cars_url, "applied_filters": applied_filters, "pages_collected": len(pages), "vehicles_collected": len(all_links), "vehicle_urls": all_links, "pages": pages, "strategy": {"sequential": True, "collect_current_page_only": self.settings.listing.collect_current_page_only, "include_pagination": self.settings.listing.include_pagination, "max_pages_per_run": self.settings.listing.max_pages_per_run, "max_vehicles_per_run": self.settings.listing.max_vehicles_per_run}}
|
||
|
||
@staticmethod
|
||
def _try_fill_filter_input(page: Page, selectors: list[str], value: str) -> bool:
|
||
# Несколько селекторов для фильтра
|
||
for selector in selectors:
|
||
locator = page.locator(selector).first
|
||
if locator.count() == 0:
|
||
continue
|
||
try:
|
||
locator.click()
|
||
locator.fill(value)
|
||
page.keyboard.press("Enter")
|
||
try:
|
||
page.wait_for_load_state("networkidle", timeout=15000)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
except Exception:
|
||
continue
|
||
return False
|
||
|
||
@staticmethod
|
||
def _has_next_page(page: Page) -> bool:
|
||
# Проверка кнопки next
|
||
for selector in ["a[aria-label*='Next']", "button[aria-label*='Next']", "a.pagination-next", "button.pagination-next", "a:has-text('Next')", "button:has-text('Next')"]:
|
||
if page.locator(selector).count() > 0:
|
||
return True
|
||
return False
|