Из немыслимого в элементарное: парсинг сайта спортпита на Python без бэкграунда программиста

от автора

Привет, Habr!

Сразу дисклеймер: эта статья не реклама магазина, не туториал «парсинг за 5 минут», и я не программист. Я переводчик и штангист-любитель, а код, который будет представлен ниже, писал вместе с нейросетью. Это история о том, как одна рекомендация из книги перевернула мои представления о спортивном питании, а лень и любопытство привели к написанию скрипта, который за пару минут сделал работу, на которую у меня раньше уходило несколько часов.

  1. С чего всё началось

  2. Сайт badrazves.ru

  3. Код парсера

  4. Результат

1.     С чего всё началось.

Сейчас я перевожу десятую книгу (третий переводческий проект, в котором я использую нейросети). Тема книги – силовые тренировки для людей старше 40 – The Barbell Prescription, авторы Энди Бейкер и Джонатон Салливан. Книга на стыке физиологии, геронтологии и силовой подготовки, и в главе о питании авторы объясняют, почему золотой стандарт для набора мышечной массы сывороточный белок (а не гидролизат коллагенового белка, которым я годами пользовался).

Говоря иными словами, всё это время я заблуждался. Гидролизат коллагена, который я брал «потому что быстрее усваивается», на деле неполноценный белок с плохим аминокислотным профилем — в нём мало лейцина, а это главный триггер синтеза мышечных белков. Сывороточный концентрат или изолят усваиваются за 1–2 часа, этого более чем достаточно, стоят дешевле, а по эффективности обходят говяжий гидролизат на голову. Плюс коллагеновый изолят, будем честны, воняет жжёной кожей.

Воодушевлённый, я побежал на сайт магазина badrazves.ru, где обычно беру белок, чтобы прицениться и выбрать нормальный сывороточный вариант. В разделе «Белки» — куча позиций на трёх страницах: от знакомой сыворотки до экзотики вроде горохового, тыквенного и конопляного протеина. Сайт работает медленно, вручную сравнивать состав, цену и процент белка у каждого — удовольствие сомнительное. И тут меня осенила идея: а что, если спарсить все товары (как потом оказалось в категории белки их 45) и скормить их нейросети с просьбой выбрать лучший вариант под мои критерии?

 Вне зависимости от источника (сыворотка, говядина, яйцо), существует три варианта очистки:

  • Концентрат: базовая форма с содержанием белка около 70-80%.

  • Изолят: более очищенная форма (до 90% белка), с минимальным количеством жиров и углеводов.

  • Гидролизат: белок, частично расщеплённый ферментами на более мелкие цепочки.

    2.     Сайт badrazves.ru

Когда я попросил Qwen помочь написать скрипт, первым делом мы вместе разобрались, как вообще устроен этот сайт. Оказалось, badrazves работает на OpenCart — это один из самых популярных бесплатных движков для интернет-магазинов, а значит структура страниц у него стандартная и предсказуемая.

Пагинация ?page=

Когда вы нажимаете «Показать ещё» или цифры страниц внизу списка товаров, сайт добавляет к адресу страницы параметр ?page=2, ?page=3 и так далее. Я это проверил экспериментально: загрузил https://badrazves.ru/sport/protein/?page=2 и увидел товары со второй страницы. Это значит, что скрипту не нужно имитировать нажатия на кнопки — можно просто загрузить страницы по прямым ссылкам.

Текст вместо картинок

Это важное открытие для парсинга. На многих сайтах названия товаров — это картинки с текстом, которые нужно распознавать отдельно (это сложно). Но на badrazves.ru названия — это обычный текст внутри ссылок (<a href=»…»>Название товара</a>), поэтому их можно извлечь напрямую, просто прочитав текст ссылки. Это сильно упрощает задачу.

Ну и про этикет: у сайта есть файл robots.txt, где прописаны ограничения для ботов. Мы ничего не ломаем, не долбим сервер тысячами запросов — скрипт делает паузу в полсекунды между запросами, обращается только к публичным страницам товаров и использует данные исключительно в личных целях. Вежливость прежде всего.

3.     Код парсера

Уверен, такие детали как промпт для написания парсера на python с помощью Qwen вас будет интересовать меньше всего, поэтому сразу код, без вступления.

#!/usr/bin/env python3"""Парсер товаров из категории «Белки» сайта badrazves.ru.Примеры:    python protein_parser.py    python protein_parser.py --details --format json    python protein_parser.py --pages 1 2 3 --delay 1.0Результат по умолчанию: proteins.csv и proteins.json."""from __future__ import annotationsimport argparseimport csvimport jsonimport reimport timefrom dataclasses import asdict, dataclass, fieldfrom pathlib import Pathfrom typing import Iterablefrom urllib.parse import urljoin, urlparse, urlunparseimport requestsfrom bs4 import BeautifulSoupfrom requests.adapters import HTTPAdapterfrom urllib3.util.retry import RetryBASE = "https://badrazves.ru"CATEGORY_URL = f"{BASE}/sport/protein/"@dataclassclass Product:    name: str    url: str    description: str = ""    page: int = 0    prices: list[str] = field(default_factory=list)def make_session() -> requests.Session:    session = requests.Session()    session.headers.update({        "User-Agent": "Mozilla/5.0 (compatible; ProteinCategoryParser/1.0)",        "Accept-Language": "ru,en;q=0.8",    })    retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504],                  allowed_methods=["GET"])    session.mount("https://", HTTPAdapter(max_retries=retry))    return sessiondef clean(text: str) -> str:    return re.sub(r"\s+", " ", text).strip()def canonical(url: str) -> str:    p = urlparse(urljoin(BASE, url))    return urlunparse((p.scheme, p.netloc, p.path.rstrip("/") + "/", "", "", ""))def product_links(soup: BeautifulSoup) -> Iterable[tuple[str, str]]:    category_path = urlparse(CATEGORY_URL).path.rstrip("/") + "/"    seen = set()    for a in soup.select("a[href]"):        href = canonical(a["href"])        parsed = urlparse(href)        if parsed.netloc != urlparse(BASE).netloc:            continue        if not parsed.path.startswith(category_path) or parsed.path == category_path:            continue        # On the category page a product is linked by both its title and its image.        if "index.php" in a["href"] or "page=" in a["href"]:            continue        name = clean(a.get_text(" ", strip=True))        if href and name and href not in seen:            seen.add(href)            yield name, hrefdef readable_description(container: BeautifulSoup) -> str:    """Преобразует HTML-описание в читаемый текст и удаляет юридический дисклеймер и SEO-спам."""    # Дисклеймер находится в отдельном div в конце описания.    for node in container.find_all(string=re.compile(r"Информация,? размещенная на сайте", re.I)):        parent = node.parent        while parent and parent.name not in {"div", "p", "section"}:            parent = parent.parent        if parent:            parent.decompose()            break    # Удаляем SEO-спам блоки: «О компании», перечисление брендов и пр.    # На badrazves такие блоки встречаются почти в каждом описании товара    # и засоряют JSON мусорным текстом, мешающим анализу нейросетью.    seo_patterns = [        r"В компании ООО Бадразвес",        r"Наша компания представляет все бренды",        r"ООО Бадразвес- лидер на рынке",        r"Наши склады располагаются",    ]    for pattern in seo_patterns:        for node in container.find_all(string=re.compile(pattern, re.I)):            parent = node.parent            while parent and parent.name not in {"div", "p", "section"}:                parent = parent.parent            if parent:                parent.decompose()                break    for tag in container.select("script, style, noscript, img"):        tag.decompose()    for br in container.find_all("br"):        br.replace_with("\n")    for link in container.find_all("a", href=True):        label = clean(link.get_text(" ", strip=True))        href = urljoin(BASE, link["href"])        link.replace_with(f"{label} ({href})" if label else href)    # Разделители строк позволяют не склеивать заголовки и абзацы.    text = container.get_text("\n")    lines = []    for line in text.splitlines():        line = clean(line)        if line:            lines.append(line)        elif lines and lines[-1] != "":            lines.append("")    while lines and lines[-1] == "":        lines.pop()    return "\n".join(lines)def parse_product_page(session: requests.Session, item: Product) -> None:    response = session.get(item.url, timeout=30)    response.raise_for_status()    soup = BeautifulSoup(response.text, "html.parser")    # Описание    meta = soup.select_one('meta[property="og:description"]')    description = soup.select_one("#tab-description") or soup.select_one(".tab-content")    if description:        item.description = readable_description(description)    elif meta:        item.description = clean(meta.get("content", ""))    # Варианты упаковки и цены.    # Основная проблема парсинга текстом: для оптовых фасовок (мешки 15-25 кг)    # сайт часто показывает «0 ₽» как заглушку «по запросу». Реальная цена    # хранится в атрибуте `price` у radio-кнопок — берём оттуда.    info = soup.select_one(".product-info")    if info:        for inp in info.select("input[type='radio'][price]"):            weight_el = (inp.find_previous_sibling(class_=re.compile(r"weight"))                         or inp.find_next(class_=re.compile(r"weight")))            if weight_el:                weight = clean(weight_el.get_text(" "))                # Убираем возможный «+» (например, «+869» — доплата к базовой цене).                price = inp.get("price", "").lstrip("+").strip()                if weight and price:                    item.prices.append(f"{weight} {price} ₽")        # Фолбэк: если через input ничего не нашли, парсим видимый текст.        if not item.prices:            text = clean(info.get_text(" ", strip=True))            item.prices = re.findall(r"\d[\d ]*\s*(?:г|кг)\s+\d[\d ]*\s*(?:₽|р)", text, flags=re.I)def parse(args: argparse.Namespace) -> list[Product]:    session = make_session()    products: dict[str, Product] = {}    page_list = list(args.pages) if args.pages else list(range(1, args.max_pages + 1))    for page in page_list:        url = CATEGORY_URL if page == 1 else f"{CATEGORY_URL}?page={page}"        response = session.get(url, timeout=30)        response.raise_for_status()        soup = BeautifulSoup(response.text, "html.parser")        found = list(product_links(soup))        if not found and not args.pages:            break        for name, href in found:            products.setdefault(href, Product(name=name, url=href, page=page))        print(f"Страница {page}: найдено ссылок {len(found)}")        if args.delay and page != page_list[-1]:            time.sleep(args.delay)    result = list(products.values())    if args.details:        for i, item in enumerate(result, 1):            try:                parse_product_page(session, item)                print(f"Товар {i}/{len(result)}: {item.name}")            except requests.RequestException as exc:                print(f"Не удалось загрузить {item.url}: {exc}")            if args.delay and i != len(result):                time.sleep(args.delay)    return resultdef save(products: list[Product], stem: str, fmt: str) -> None:    rows = [asdict(p) for p in products]    if fmt in ("csv", "both"):        with open(stem + ".csv", "w", encoding="utf-8-sig", newline="") as f:            writer = csv.DictWriter(f, fieldnames=["name", "url", "description", "page", "prices"])            writer.writeheader()            for row in rows:                row["prices"] = " | ".join(row["prices"])                writer.writerow(row)    if fmt in ("json", "both"):        with open(stem + ".json", "w", encoding="utf-8") as f:            json.dump(rows, f, ensure_ascii=False, indent=2)    if fmt in ("markdown", "both"):        with open(stem + ".md", "w", encoding="utf-8") as f:            f.write("# Белки — данные товаров\n\n")            f.write(f"Всего товаров: **{len(products)}**\n\n")            for number, product in enumerate(products, 1):                f.write(f"## {number}. {product.name}\n\n")                f.write(f"**Страница товара:** {product.url}\n\n")                if product.prices:                    f.write("**Варианты и цены:** " + "; ".join(product.prices) + "\n\n")                if product.description:                    f.write(product.description + "\n\n")                f.write("---\n\n")def main() -> None:    # Значения по умолчанию позволяют просто запустить файл двойным щелчком:    # будут обработаны страницы 1, 2 и 3, а результаты появятся рядом с этим .py-файлом.    script_dir = Path(__file__).resolve().parent    default_output = str(script_dir / "proteins")    parser = argparse.ArgumentParser(description=__doc__)    parser.add_argument(        "--pages", nargs="+", type=int, default=[1, 2, 3],        help="Номера страниц (по умолчанию: 1 2 3)",    )    parser.add_argument("--max-pages", type=int, default=20,                        help="Лимит автообхода страниц")    parser.add_argument(        "--details", action="store_true", default=True,        help="Скачать описания и цены (включено по умолчанию)",    )    parser.add_argument("--delay", type=float, default=0.5,                        help="Пауза между запросами в секундах")    parser.add_argument(        "--format", choices=["csv", "json", "markdown", "both"], default="both",        help="both создаёт proteins.csv, proteins.json и proteins.md",    )    parser.add_argument(        "--output", default=default_output,        help="Имя файлов без расширения; по умолчанию папка скрипта",    )    args = parser.parse_args()    # Даже при запуске из другой папки относительное имя сохраняем рядом со скриптом.    if not Path(args.output).is_absolute():        args.output = str(script_dir / args.output)    products = parse(args)    save(products, args.output, args.format)    print(f"Готово: уникальных товаров {len(products)}")    print(f"Файлы сохранены в папке: {script_dir}")if __name__ == "__main__":    main()

4.     Результат

Далее все еще проще.

Запустил скрипт двойным кликом, пошёл заваривать чай. Через пару минут рядом с файлом скрипта появились три файла: proteins.json (для нейросети), proteins.csv (для Excel) и proteins.md (для чтения глазами) — всего 45 товаров со всеми описаниями, ценами и вариантами фасовки.

Работа парсера

Работа парсера

Скормил JSON нейросети с одним вопросом: «Выбери лучший сывороточный продукт животного происхождения с содержанием белка не менее 80%». Ответ был мгновенным — победителем стал Сывороточный изолят Pronativ 95 (95% белка, 6560 ₽ за 1390 г), а как бюджетная альтернатива — Сывороточный протеин Laktein 80 (81% белка, 3830 ₽ за 1430 г) от того же производителя Lactalis. Всё. Задача решена. Конечно там есть и более дешевые продукты, но содержание качественного белка в них может быть, к примеру, 50%. Мне нужно другое!

 И, собственно, вывод.

Поймите меня пожалуйста, я не программист. Я переводчик/дизайнер и штангист. Буду рад дружелюбным комментариям.

Спасибо за время, которое вы уделили прочтению статьи!

ссылка на оригинал статьи https://habr.com/ru/articles/1068200/