Архив рубрики ~Лента новостей~

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

Из немыслимого в элементарное: парсинг сайта спортпита на 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 annotations import argparse import csv import json import re import time from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Iterable from urllib.parse import urljoin, urlparse, urlunparse import requests from bs4 import BeautifulSoup from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry BASE = «https://badrazves.ru» CATEGORY_URL = f»{BASE}/sport/protein/» @dataclass class 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 session def 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, href def 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 result def 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(«# Белки — данные товаровnn») f.write(f»Всего товаров: **{len(products)}**nn») for number, product in enumerate(products, 1): f.write(f»## {number}. {product.name}nn») f.write(f»**Страница товара:** {product.url}nn») if product.prices: f.write(«**Варианты и цены:** » + «; «.join(product.prices) + «nn») if product.description: f.write(product.description + «nn») f.write(«—nn») 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%. Мне нужно другое!

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

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

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

Источник: habr.com

Оцените материал:

Поделиться
Понравилась статья? Расскажите другим
ВКонтакте
Читайте также
Архив рубрики ~Коротко из Telegram~ Huawei придумал кнопку КОНФИДЕНЦИАЛЬНОСТИ — в новый ноутбук встроили физический переключатель… Архив рубрики ~Коротко из Telegram~ Один стандарт плагинов для ИИ Agent Plugins OpenAI, AWS, Cursor,… Новости робототехники TechCrunch Mobility: Zoox готовится к запуску и империя беспилотных автомобилей Uber Архив рубрики ~Обо всем~ Обзор Galaxy Z Fold 8: миниатюрный смартфон, способный менять форму в любой момент. Архив рубрики ~Коротко из Telegram~ OpenAI обещает больше доступа для бесплатных пользователей ChatGPT OpenAI расширяет… Архив рубрики ~Коротко из Telegram~ Российская сборная снова выиграла Международную олимпиаду по ИИ В Шереметьево… Архив рубрики ~Коротко из Telegram~ WAN Camera-to-Image WAN приглашает немедленно опробовать новую фишку — стилизация… Архив рубрики ~Коротко из Telegram~ Anthropic рассказала о необычных тестах безопасности Claude Компания опубликовала отчёт,… Архив рубрики ~Коротко из Telegram~ Вышел лёгкий браузер для ИИ-агентов — Lightpanda Инструмент создан специально… Архив рубрики ~Коротко из Telegram~ Загружаем огромные проекты в ИИ без слива токенов Нашли топовый… Архив рубрики ~Коротко из Telegram~ Настраиваем Windows без страха убить систему: нашли универсальный PowerShell-модуль с… Архив рубрики ~Коротко из Telegram~ Бывший инженер Comet собрал новый ИИ браузер Кевин Цзян, один… Архив рубрики ~Коротко из Telegram~ Побег ИИ-агентов OpenAI и взлом чужих серверов — главная тема… Архив рубрики ~Коротко из Telegram~ Mercedes-Benz возвращает физические кнопки в свои автомобили В компании наконец… Архив рубрики ~Коротко из Telegram~ Huawei придумал кнопку КОНФИДЕНЦИАЛЬНОСТИ — в новый ноутбук встроили физический переключатель… Архив рубрики ~Коротко из Telegram~ Один стандарт плагинов для ИИ Agent Plugins OpenAI, AWS, Cursor,… Новости робототехники TechCrunch Mobility: Zoox готовится к запуску и империя беспилотных автомобилей Uber Архив рубрики ~Обо всем~ Обзор Galaxy Z Fold 8: миниатюрный смартфон, способный менять форму в любой момент. Архив рубрики ~Коротко из Telegram~ OpenAI обещает больше доступа для бесплатных пользователей ChatGPT OpenAI расширяет… Архив рубрики ~Коротко из Telegram~ Российская сборная снова выиграла Международную олимпиаду по ИИ В Шереметьево… Архив рубрики ~Коротко из Telegram~ WAN Camera-to-Image WAN приглашает немедленно опробовать новую фишку — стилизация… Архив рубрики ~Коротко из Telegram~ Anthropic рассказала о необычных тестах безопасности Claude Компания опубликовала отчёт,… Архив рубрики ~Коротко из Telegram~ Вышел лёгкий браузер для ИИ-агентов — Lightpanda Инструмент создан специально… Архив рубрики ~Коротко из Telegram~ Загружаем огромные проекты в ИИ без слива токенов Нашли топовый… Архив рубрики ~Коротко из Telegram~ Настраиваем Windows без страха убить систему: нашли универсальный PowerShell-модуль с… Архив рубрики ~Коротко из Telegram~ Бывший инженер Comet собрал новый ИИ браузер Кевин Цзян, один… Архив рубрики ~Коротко из Telegram~ Побег ИИ-агентов OpenAI и взлом чужих серверов — главная тема… Архив рубрики ~Коротко из Telegram~ Mercedes-Benz возвращает физические кнопки в свои автомобили В компании наконец…

Оставить комментарий