fix docker scraping

improve batch sync

add postgres upsert

fix sync locking

improve listing sync

speed up scraper

clean up project

prepare for github

update docker setup
This commit is contained in:
qananasikq
2026-04-13 16:35:08 +03:00
parent b9557922ed
commit a4c93a1df1
30 changed files with 2239 additions and 624 deletions

View File

@@ -1,5 +1,6 @@
import logging
import re
import time
from dataclasses import asdict, dataclass, field
from typing import Any
from urllib.parse import urljoin
@@ -11,7 +12,7 @@ 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)
VEHICLE_HREF_RE = re.compile(r"/VehicleDetail/(\d+)(?:~[A-Z]{2})?", re.IGNORECASE)
@dataclass(slots=True)
@@ -37,15 +38,35 @@ class ListingCollector:
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")
url = self.settings.listing.cars_url
last_err = None
for attempt in range(3):
try:
page.goto(url, wait_until="commit", timeout=60_000)
last_err = None
break
except Exception as e:
last_err = e
logger.warning("goto listing attempt %d failed: %s", attempt + 1, e)
# Небольшой backoff при ошибках открытия листинга.
time.sleep(5 * (attempt + 1))
if last_err:
logger.warning("All goto attempts failed, trying JS navigation")
try:
page.evaluate(f"window.location.href = '{url}'")
except Exception:
pass
# Быстрая проверка готовности страницы.
try:
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_load_state("domcontentloaded", timeout=12_000)
except Exception:
logger.debug("networkidle timeout on listing page, continuing with current state")
pass
try:
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=20000)
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=4_000)
except Exception:
logger.warning("Vehicle links did not appear within timeout; page may not have rendered fully")
# Короткая пауза вместо длинного sleep.
time.sleep(0.25)
logger.info("Listing page URL: %s", page.url)
self.pacer.after_listing_open()
def apply_filters(self, page: Page, make: str | None = None, model: str | None = None) -> dict[str, str | None]:
@@ -59,28 +80,48 @@ class ListingCollector:
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)
# Считываем ссылки одним проходом по DOM.
raw_items = page.eval_on_selector_all(
"a[href*='/VehicleDetail/']",
"""
(nodes) => nodes.map((a) => ({
href: a.getAttribute('href') || '',
title: a.getAttribute('title') || '',
text: (a.textContent || '').trim(),
}))
""",
)
total = min(len(raw_items), 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 ""
item = raw_items[idx] if isinstance(raw_items[idx], dict) else {}
href = str(item.get("href") or "")
match = VEHICLE_HREF_RE.search(href)
if not match:
continue
lot_number = match.group(1)
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()))
title = first_non_empty([item.get("title"), item.get("text"), ""]) or ""
links.append(ListingVehicleLink(href=absolute, title=str(title).strip(), lot_number=lot_number))
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:
# Запоминаем первую ссылку текущей страницы для определения смены контента.
old_first_href = ""
try:
first_link = page.locator("a[href*='/VehicleDetail/']").first
if first_link.count() > 0:
old_first_href = first_link.get_attribute("href") or ""
except Exception:
pass
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
@@ -93,32 +134,118 @@ class ListingCollector:
continue
self.pacer.move_mouse_to(page, locator)
locator.click()
# Ждём смены контента (AJAX пагинация): первая VehicleDetail-ссылка должна измениться.
if old_first_href:
try:
page.wait_for_function(
f"""() => {{
const a = document.querySelector("a[href*='/VehicleDetail/']");
return a && a.getAttribute('href') !== '{old_first_href}';
}}""",
timeout=8000,
)
except Exception:
pass
else:
try:
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass
try:
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=3000)
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]:
def collect_listing_links(
self,
page: Page,
*,
make: str | None = None,
model: str | None = None,
known_origin_ids: set[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] = []
early_stopped = False
threshold = self.settings.listing.early_stop_threshold
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})
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:
# Ранний останов: если на этой странице много известных И нет новых — дальше нет смысла.
# Важно: если есть хоть одна новая машина — продолжаем листать (новые могут быть на любой странице).
if (
known_origin_ids is not None
and threshold > 0.0
and page_result.vehicle_links
):
page_known = sum(
1 for item in page_result.vehicle_links
if item.lot_number and f"iaai:{item.lot_number}" in known_origin_ids
)
page_new = len(page_result.vehicle_links) - page_known
ratio = page_known / len(page_result.vehicle_links)
# Останавливаемся только если нет новых И порог превышен
if ratio >= threshold and page_new == 0:
logger.info(
"Early stop on page %d: %.0f%% known (%d/%d), 0 new >= threshold %.0f%%",
page_number, ratio * 100, page_known,
len(page_result.vehicle_links), threshold * 100,
)
early_stopped = True
break
elif page_new > 0 and ratio >= threshold:
logger.info(
"Page %d: %.0f%% known but %d new found — продолжаем",
page_number, ratio * 100, page_new,
)
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}}
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,
"early_stopped": early_stopped,
"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,
"early_stop_threshold": threshold,
},
}
@staticmethod
def _try_fill_filter_input(page: Page, selectors: list[str], value: str) -> bool:
@@ -131,7 +258,11 @@ class ListingCollector:
locator.fill(value)
page.keyboard.press("Enter")
try:
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass
try:
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=3000)
except Exception:
pass
return True