fix listing parsing
This commit is contained in:
@@ -13,6 +13,18 @@ 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_LINK_SELECTOR = "a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']"
|
||||
COOKIE_ACCEPT_SELECTORS: tuple[str, ...] = (
|
||||
"button:has-text('Accept All')",
|
||||
"button:has-text('Accept all')",
|
||||
"button:has-text('I Agree')",
|
||||
"button:has-text('Agree')",
|
||||
"button:has-text('Only necessary')",
|
||||
"button:has-text('Только необходимые')",
|
||||
"button:has-text('Принять все')",
|
||||
"[id*='accept']",
|
||||
"[class*='accept']",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -32,6 +44,35 @@ class ListingPageResult:
|
||||
|
||||
|
||||
class ListingCollector:
|
||||
_NEXT_PAGE_SELECTORS: tuple[str, ...] = (
|
||||
"a[aria-label*='Next']",
|
||||
"button[aria-label*='Next']",
|
||||
"a[aria-label*='next']",
|
||||
"button[aria-label*='next']",
|
||||
"a[title*='Next']",
|
||||
"button[title*='Next']",
|
||||
"a[title*='next']",
|
||||
"button[title*='next']",
|
||||
"a[rel='next']",
|
||||
"link[rel='next']",
|
||||
"a.pagination-next",
|
||||
"button.pagination-next",
|
||||
"a.next",
|
||||
"button.next",
|
||||
"a:has-text('Next')",
|
||||
"button:has-text('Next')",
|
||||
"a:has-text('NEXT')",
|
||||
"button:has-text('NEXT')",
|
||||
"a:has-text('›')",
|
||||
"button:has-text('›')",
|
||||
"a:has-text('»')",
|
||||
"button:has-text('»')",
|
||||
"a:has(img[src*='icon-arrow-right'])",
|
||||
"button:has(img[src*='icon-arrow-right'])",
|
||||
"a:has(img[src*='arrow-right'])",
|
||||
"button:has(img[src*='arrow-right'])",
|
||||
)
|
||||
|
||||
def __init__(self, settings: Settings, pacer: HumanPacer) -> None:
|
||||
self.settings = settings
|
||||
self.pacer = pacer
|
||||
@@ -61,14 +102,50 @@ class ListingCollector:
|
||||
page.wait_for_load_state("domcontentloaded", timeout=12_000)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=4_000)
|
||||
except Exception:
|
||||
# Короткая пауза вместо длинного sleep.
|
||||
time.sleep(0.25)
|
||||
self._accept_cookie_banner(page)
|
||||
self._wait_for_listing_content(page)
|
||||
logger.info("Listing page URL: %s", page.url)
|
||||
self.pacer.after_listing_open()
|
||||
|
||||
def _accept_cookie_banner(self, page: Page) -> None:
|
||||
for selector in COOKIE_ACCEPT_SELECTORS:
|
||||
locator = page.locator(selector).first
|
||||
try:
|
||||
if locator.count() == 0:
|
||||
continue
|
||||
if not locator.is_visible(timeout=500):
|
||||
continue
|
||||
locator.click(timeout=2_000)
|
||||
logger.info("Accepted cookie banner using selector: %s", selector)
|
||||
try:
|
||||
page.wait_for_load_state("domcontentloaded", timeout=3_000)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _wait_for_listing_content(self, page: Page) -> None:
|
||||
try:
|
||||
page.wait_for_selector(VEHICLE_LINK_SELECTOR, timeout=12_000)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: React/SSR разметка может появиться не сразу, даже если <a> ещё нет в DOM.
|
||||
try:
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const html = document.documentElement?.innerHTML || '';
|
||||
const text = document.body?.innerText || '';
|
||||
return html.includes('/VehicleDetail/') || /\\b\d+\s+VEHICLES\b/i.test(text);
|
||||
}""",
|
||||
timeout=12_000,
|
||||
)
|
||||
except Exception:
|
||||
# Короткая пауза вместо длинного sleep.
|
||||
time.sleep(1.0)
|
||||
|
||||
def apply_filters(self, page: Page, make: str | None = None, model: str | None = None) -> dict[str, str | None]:
|
||||
applied = {"make": None, "model": None}
|
||||
if make and self._try_fill_filter_input(page, ["input[placeholder*='Make']", "input[aria-label*='Make']"], make):
|
||||
@@ -81,14 +158,20 @@ class ListingCollector:
|
||||
|
||||
def collect_current_page(self, page: Page, page_number: int = 1) -> ListingPageResult:
|
||||
# Считываем ссылки одним проходом по DOM.
|
||||
self._accept_cookie_banner(page)
|
||||
self._wait_for_listing_content(page)
|
||||
try:
|
||||
raw_items = page.eval_on_selector_all(
|
||||
"a[href*='/VehicleDetail/']",
|
||||
"a[href], [data-href], [href]",
|
||||
"""
|
||||
(nodes) => nodes.map((a) => ({
|
||||
href: a.getAttribute('href') || '',
|
||||
title: a.getAttribute('title') || '',
|
||||
text: (a.textContent || '').trim(),
|
||||
(nodes) => nodes.map((node) => ({
|
||||
href:
|
||||
node.getAttribute('href') ||
|
||||
node.getAttribute('data-href') ||
|
||||
node.getAttribute('data-url') ||
|
||||
'',
|
||||
title: node.getAttribute('title') || node.getAttribute('aria-label') || '',
|
||||
text: (node.textContent || '').trim(),
|
||||
}))
|
||||
""",
|
||||
)
|
||||
@@ -113,21 +196,69 @@ class ListingCollector:
|
||||
links.append(ListingVehicleLink(href=absolute, title=str(title).strip(), lot_number=lot_number))
|
||||
if len(links) >= self.settings.listing.max_vehicles_per_run:
|
||||
break
|
||||
|
||||
# Fallback: на IAAI ссылки иногда не рендерятся как <a>,
|
||||
# но присутствуют в hydration/inline JSON внутри HTML (часто как \/VehicleDetail\/").
|
||||
if not links:
|
||||
try:
|
||||
page.wait_for_timeout(1_500)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
html = page.content()
|
||||
except Exception as exc:
|
||||
logger.debug("page.content() failed on page %d: %s", page_number, exc)
|
||||
html = ""
|
||||
|
||||
for absolute, lot_number in self._extract_vehicle_links_from_html(html):
|
||||
if absolute in seen:
|
||||
continue
|
||||
seen.add(absolute)
|
||||
links.append(ListingVehicleLink(href=absolute, title="", lot_number=lot_number))
|
||||
if len(links) >= self.settings.listing.max_vehicles_per_run:
|
||||
break
|
||||
|
||||
if links:
|
||||
logger.info(
|
||||
"Page %d: recovered %d vehicle links from HTML fallback",
|
||||
page_number,
|
||||
len(links),
|
||||
)
|
||||
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 _extract_vehicle_links_from_html(self, html: str) -> list[tuple[str, str]]:
|
||||
if not html:
|
||||
return []
|
||||
|
||||
# Частый формат в JSON внутри HTML: "\/VehicleDetail\/12345678~US"
|
||||
normalized = html.replace("\\/", "/")
|
||||
found: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for match in VEHICLE_HREF_RE.finditer(normalized):
|
||||
lot_number = match.group(1)
|
||||
absolute = urljoin(self.settings.home_url, match.group(0))
|
||||
if absolute in seen:
|
||||
continue
|
||||
seen.add(absolute)
|
||||
found.append((absolute, lot_number))
|
||||
if len(found) >= self.settings.listing.page_link_limit:
|
||||
break
|
||||
|
||||
return found
|
||||
|
||||
def go_to_next_page(self, page: Page) -> bool:
|
||||
# Запоминаем первую ссылку текущей страницы для определения смены контента.
|
||||
old_first_href = ""
|
||||
try:
|
||||
first_link = page.locator("a[href*='/VehicleDetail/']").first
|
||||
first_link = page.locator(VEHICLE_LINK_SELECTOR).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:
|
||||
for selector in self._NEXT_PAGE_SELECTORS:
|
||||
locator = page.locator(selector).first
|
||||
if locator.count() == 0:
|
||||
continue
|
||||
@@ -150,7 +281,7 @@ class ListingCollector:
|
||||
try:
|
||||
page.wait_for_function(
|
||||
f"""() => {{
|
||||
const a = document.querySelector("a[href*='/VehicleDetail/']");
|
||||
const a = document.querySelector("a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']");
|
||||
return a && a.getAttribute('href') !== '{old_first_href}';
|
||||
}}""",
|
||||
timeout=8000,
|
||||
@@ -164,11 +295,131 @@ class ListingCollector:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=3000)
|
||||
page.wait_for_selector(VEHICLE_LINK_SELECTOR, timeout=3000)
|
||||
except Exception:
|
||||
pass
|
||||
self.pacer.after_page_change()
|
||||
return True
|
||||
|
||||
# Fallback для IAAI: пагинация часто рендерится как набор номеров страниц
|
||||
# + стрелка с иконкой, без явного текста Next.
|
||||
try:
|
||||
clicked = bool(page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const visible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
|
||||
const disabled = (el) => {
|
||||
if (!el) return true;
|
||||
const cls = (el.getAttribute('class') || '').toLowerCase();
|
||||
const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase();
|
||||
return el.hasAttribute('disabled') || ariaDisabled === 'true' || cls.includes('disabled');
|
||||
};
|
||||
|
||||
const controls = Array.from(document.querySelectorAll('a,button,[role="button"]'))
|
||||
.filter((el) => visible(el) && !disabled(el));
|
||||
|
||||
const current = controls.find((el) => {
|
||||
const text = (el.textContent || '').trim();
|
||||
const cls = (el.getAttribute('class') || '').toLowerCase();
|
||||
const ariaCurrent = (el.getAttribute('aria-current') || '').toLowerCase();
|
||||
return /^\d+$/.test(text) && (ariaCurrent === 'page' || cls.includes('active') || cls.includes('current') || cls.includes('selected'));
|
||||
});
|
||||
|
||||
if (current) {
|
||||
const currentPage = parseInt((current.textContent || '').trim(), 10);
|
||||
const nextNumber = controls.find((el) => {
|
||||
const text = (el.textContent || '').trim();
|
||||
return /^\d+$/.test(text) && parseInt(text, 10) === currentPage + 1;
|
||||
});
|
||||
if (nextNumber) {
|
||||
nextNumber.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const iconNext = controls.find((el) => {
|
||||
const text = (el.textContent || '').trim().toLowerCase();
|
||||
const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
|
||||
const title = (el.getAttribute('title') || '').trim().toLowerCase();
|
||||
const rel = (el.getAttribute('rel') || '').trim().toLowerCase();
|
||||
const cls = (el.getAttribute('class') || '').trim().toLowerCase();
|
||||
const hasRightArrowIcon = !!el.querySelector('img[src*="icon-arrow-right"], img[src*="arrow-right"]');
|
||||
return hasRightArrowIcon || rel === 'next' || aria.includes('next') || title.includes('next') || cls.includes('next') || ['next', '›', '»', '>'].includes(text);
|
||||
});
|
||||
|
||||
if (iconNext) {
|
||||
iconNext.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
"""
|
||||
))
|
||||
if clicked:
|
||||
if old_first_href:
|
||||
try:
|
||||
page.wait_for_function(
|
||||
f"""() => {{
|
||||
const a = document.querySelector(\"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']\");
|
||||
return a && a.getAttribute('href') !== '{old_first_href}';
|
||||
}}""",
|
||||
timeout=12000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
page.wait_for_load_state("domcontentloaded", timeout=12000)
|
||||
except Exception:
|
||||
pass
|
||||
self.pacer.after_page_change()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Numeric/icon pagination fallback failed: %s", exc)
|
||||
|
||||
# JS fallback: ищем любой видимый pagination-control «next» по атрибутам/тексту.
|
||||
try:
|
||||
clicked = bool(page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const candidates = Array.from(document.querySelectorAll('a,button,[role="button"]'));
|
||||
for (const el of candidates) {
|
||||
const text = (el.textContent || '').trim().toLowerCase();
|
||||
const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
|
||||
const title = (el.getAttribute('title') || '').trim().toLowerCase();
|
||||
const rel = (el.getAttribute('rel') || '').trim().toLowerCase();
|
||||
const cls = (el.getAttribute('class') || '').trim().toLowerCase();
|
||||
const disabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true' || cls.includes('disabled');
|
||||
const visible = !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
const looksNext = rel === 'next' || aria.includes('next') || title.includes('next') || cls.includes('next') || ['next', '›', '»', '>'].includes(text);
|
||||
if (!disabled && visible && looksNext) {
|
||||
el.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
"""
|
||||
))
|
||||
if clicked:
|
||||
if old_first_href:
|
||||
try:
|
||||
page.wait_for_function(
|
||||
f"""() => {{
|
||||
const a = document.querySelector(\"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']\");
|
||||
return a && a.getAttribute('href') !== '{old_first_href}';
|
||||
}}""",
|
||||
timeout=8000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.pacer.after_page_change()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("JS next-page fallback failed: %s", exc)
|
||||
|
||||
logger.warning("Could not navigate to next page from %s", page.url)
|
||||
return False
|
||||
|
||||
def collect_listing_links(
|
||||
@@ -287,7 +538,7 @@ class ListingCollector:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
page.wait_for_selector("a[href*='/VehicleDetail/']", timeout=3000)
|
||||
page.wait_for_selector(VEHICLE_LINK_SELECTOR, timeout=3000)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
@@ -297,7 +548,26 @@ class ListingCollector:
|
||||
|
||||
@staticmethod
|
||||
def _has_next_page(page: Page) -> bool:
|
||||
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')"]:
|
||||
for selector in ListingCollector._NEXT_PAGE_SELECTORS:
|
||||
if page.locator(selector).count() > 0:
|
||||
return True
|
||||
try:
|
||||
return bool(page.evaluate(
|
||||
"""
|
||||
() => Array.from(document.querySelectorAll('a,button,[role="button"]')).some((el) => {
|
||||
const text = (el.textContent || '').trim().toLowerCase();
|
||||
const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
|
||||
const title = (el.getAttribute('title') || '').trim().toLowerCase();
|
||||
const rel = (el.getAttribute('rel') || '').trim().toLowerCase();
|
||||
const cls = (el.getAttribute('class') || '').trim().toLowerCase();
|
||||
const disabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true' || cls.includes('disabled');
|
||||
const visible = !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
const hasRightArrowIcon = !!el.querySelector('img[src*="icon-arrow-right"], img[src*="arrow-right"]');
|
||||
const looksNumericNext = /^\d+$/.test(text);
|
||||
return !disabled && visible && (rel === 'next' || aria.includes('next') || title.includes('next') || cls.includes('next') || hasRightArrowIcon || looksNumericNext || ['next', '›', '»', '>'].includes(text));
|
||||
})
|
||||
"""
|
||||
))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user