Загрузка данных


import undetected_chromedriver as uc
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time
from bs4 import BeautifulSoup
import re

# ─────────────────────────────────────────────
# Вспомогательные функции
# ─────────────────────────────────────────────

def safe_click(driver, element):
    """Клик через JS — обходит перекрытия другими элементами."""
    driver.execute_script("arguments[0].scrollIntoView({block:'center'});", element)
    time.sleep(0.3)
    driver.execute_script("arguments[0].click();", element)


def open_composition_tab(driver, wait):
    """
    Открывает вкладку/аккордеон 'Состав' на странице товара.
    Возвращает True, если успешно кликнули.
    """
    # Стратегия 1: data-test-id (самый надёжный на ЗЯ)
    try:
        btn = wait.until(EC.presence_of_element_located((
            By.XPATH,
            "//*[@data-test-id='pdp-accordion-ingredients' "
            "or @data-test-id='accordion-ingredients']"
        )))
        safe_click(driver, btn)
        return True
    except Exception:
        pass

    # Стратегия 2: любой кликабельный элемент с текстом «Состав»
    try:
        candidates = driver.find_elements(
            By.XPATH,
            "//*[self::button or self::div or self::span or self::p]"
            "[contains(normalize-space(text()), 'Состав')]"
        )
        for el in candidates:
            if el.is_displayed():
                safe_click(driver, el)
                time.sleep(0.5)
                return True
    except Exception:
        pass

    # Стратегия 3: поиск по aria-label / title
    try:
        btn = driver.find_element(
            By.XPATH,
            "//*[contains(@aria-label,'остав') or contains(@title,'остав')]"
        )
        safe_click(driver, btn)
        return True
    except Exception:
        pass

    return False


def extract_price(soup):
    """
    Перебирает несколько вариантов разметки цены ЗЯ.
    Возвращает строку с числом или '—'.
    """
    # Актуальные селекторы (проверены на живом сайте, июнь 2025)
    selectors = [
        # data-test-id — самый стабильный
        "[data-test-id='pdp-price-current']",
        "[data-test-id='price-current']",
        "[data-test-id='product-price']",
        # классы
        ".wdp-price_type_current",
        ".pdp-price__current",
        ".price__current",
        ".price-value",
        # fallback: первый элемент с ₽
    ]
    for sel in selectors:
        el = soup.select_one(sel)
        if el:
            raw = el.get_text(" ", strip=True)
            # Оставляем только цифры и разделители
            digits = re.sub(r"[^\d]", "", raw)
            if digits:
                return digits + " ₽"

    # Последний шанс: ищем любой текст с ₽
    for tag in soup.find_all(string=re.compile(r"\d[\d\s]*₽")):
        digits = re.sub(r"[^\d]", "", tag)
        if digits:
            return digits + " ₽"

    return "—"


def extract_volume(soup):
    """
    Ищет объём/вес/количество на странице товара.
    Возвращает строку или '—'.
    """
    # Стратегия 1: data-test-id
    for test_id in ("pdp-volume", "product-volume", "pdp-size", "product-size"):
        el = soup.select_one(f"[data-test-id='{test_id}']")
        if el:
            return el.get_text(" ", strip=True)

    # Стратегия 2: паттерн «число + единица измерения» в заголовке или описании
    # Ищем в h1, подзаголовках, span-ах рядом с названием
    volume_re = re.compile(
        r"(\d+[\.,]?\d*)\s*(мл|л|г|кг|мг|oz|ml|fl\.oz|шт\.?|пары?)",
        re.IGNORECASE
    )
    # Приоритет: специальные блоки описания
    for tag in soup.select(
        "[class*='volume'], [class*='size'], [class*='weight'], "
        "[class*='amount'], [class*='capacity']"
    ):
        m = volume_re.search(tag.get_text())
        if m:
            return m.group(0)

    # В h1 объём часто пишут через запятую или в скобках
    h1 = soup.find("h1")
    if h1:
        m = volume_re.search(h1.get_text())
        if m:
            return m.group(0)

    return "—"


def extract_composition(soup):
    """
    Ищет блок состава ПОСЛЕ того, как вкладка уже была открыта.
    Возвращает строку или '—'.
    """
    # Стратегия 1: data-test-id блока состава
    for test_id in (
        "pdp-ingredients-content",
        "accordion-ingredients-content",
        "pdp-accordion-ingredients-content",
        "ingredients",
    ):
        el = soup.select_one(f"[data-test-id='{test_id}']")
        if el:
            text = el.get_text(" ", strip=True)
            if len(text) > 10:
                return text

    # Стратегия 2: ищем заголовок «Состав», потом смотрим соседей/детей
    headers = soup.find_all(
        string=re.compile(r"^\s*Состав\s*$", re.I)
    )
    for header in headers:
        parent = header.find_parent()
        if not parent:
            continue

        # Ищем следующий sibling с текстом
        for sibling in parent.find_next_siblings():
            text = sibling.get_text(" ", strip=True)
            if len(text) > 20:
                # Убираем сам заголовок из текста
                text = re.sub(r"^Состав\s*", "", text, flags=re.I).strip()
                return text

        # Или внутри родительского контейнера
        grandparent = parent.find_parent()
        if grandparent:
            text = grandparent.get_text(" ", strip=True)
            text = re.sub(r"^.*?Состав\s*", "", text, flags=re.I | re.DOTALL).strip()
            if len(text) > 20:
                return text[:2000]  # Ограничиваем длину

    # Стратегия 3: поиск по классам
    for sel in (
        "[class*='ingredient']",
        "[class*='composition']",
        "[class*='consist']",
    ):
        el = soup.select_one(sel)
        if el:
            text = el.get_text(" ", strip=True)
            if len(text) > 20:
                return text

    return "—"


# ─────────────────────────────────────────────
# Основной скрипт
# ─────────────────────────────────────────────

def run_scraper():
    options = uc.ChromeOptions()
    options.add_argument("--incognito")
    driver = uc.Chrome(options=options)
    driver.maximize_window()
    wait = WebDriverWait(driver, 15)

    # ── 1. Открываем страницу каталога ────────────────────────────────────
    driver.get(
        "https://goldapple.ru/uhod/uhod-za-licom/osnovnoj-uhod"
        "?calculatedprices=87-250&producttype=398"
    )
    print("Авторизуйся, прокрути список до конца. Нажми ENTER в консоли.")
    input("Жду сигнала... ")

    # ── 2. Сбор ссылок на товары ───────────────────────────────────────────
    soup = BeautifulSoup(driver.page_source, "html.parser")
    product_links = []

    for a in soup.find_all("a", href=True):
        href = a["href"]
        # Ссылки вида /19000168838-nivea-creme (начинаются с длинного числа)
        if re.match(r"^/\d{5,}-", href):
            full_url = "https://goldapple.ru" + href
            if full_url not in product_links:
                product_links.append(full_url)

    print(f"Найдено товаров для парсинга: {len(product_links)}")
    if not product_links:
        print("Ссылки не найдены. Проверь, прокрутил ли страницу до конца.")
        driver.quit()
        return

    # ── 3. Парсинг каждого товара ──────────────────────────────────────────
    with open("goldapple_products.txt", "w", encoding="utf-8") as f:

        for i, url in enumerate(product_links, start=1):
            try:
                driver.get(url)

                # Ждём загрузки h1 — признак того, что страница готова
                try:
                    wait.until(EC.presence_of_element_located((By.TAG_NAME, "h1")))
                except Exception:
                    time.sleep(4)

                # Пробуем открыть вкладку «Состав»
                opened = open_composition_tab(driver, wait)
                if opened:
                    # Даём время на анимацию раскрытия аккордеона
                    time.sleep(1.5)
                else:
                    print(f"  [!] Вкладка 'Состав' не найдена: {url}")

                ps = BeautifulSoup(driver.page_source, "html.parser")

                # ── Название ──────────────────────────────────────────────
                h1 = ps.find("h1")
                name = h1.get_text(" ", strip=True) if h1 else "—"

                # ── Цена ──────────────────────────────────────────────────
                price = extract_price(ps)

                # ── Объём ─────────────────────────────────────────────────
                volume = extract_volume(ps)

                # ── Состав ────────────────────────────────────────────────
                composition = extract_composition(ps)

                # ── Запись ────────────────────────────────────────────────
                line = (
                    f"{name} | "
                    f"Цена: {price} | "
                    f"Объём: {volume} | "
                    f"Состав: {composition}"
                )
                f.write(line + "\n")
                # Сбрасываем буфер сразу — данные не потеряются при сбое
                f.flush()

                print(f"[{i}/{len(product_links)}] {name} | {price} | {volume}")

            except Exception as e:
                print(f"Ошибка на {url}: {e}")
                # Записываем URL с ошибкой, чтобы можно было перепарсить
                f.write(f"ОШИБКА | {url}\n")
                f.flush()

    print("Готово! Файл goldapple_products.txt перезаписан.")
    driver.quit()


if __name__ == "__main__":
    run_scraper()