diff --git a/.gitignore b/.gitignore index 7ceacdd..2f4e960 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /venv .idea +.python-version +*.db +__pycache__/* diff --git a/README.md b/README.md index feb2310..b9d3613 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,33 @@ Этот парсер предназначен для сбора информации о продуктах категории кофе на сайте магазина [Метро](https://online.metro-cc.ru/). Он извлекает информацию о продуктах, включая их идентификатор, название, ссылку, обычную цену, промо-цену и бренд (если доступен). Парсер реализован с использованием библиотеки Python requests для HTTP-запросов, bs4 для парсинга HTML и pandas для создания и сохранения данных в файл Excel ### Инструкции по использованию + 1. Убедитесь, что у вас установлены все необходимые библиотеки, перечисленные в файле requirements.txt. Установите их, выполнив команду: + ``` pip install -r requirements.txt ``` + 2. Запустите файл main.py: ``` python main.py ``` -3. Парсер начнет сбор данных о продуктах категории кофе на сайте "Метро". Информация будет выводиться в консоль, а также сохраняется в файл Excel с именем coffee_metro.xlsx +3. Парсер начнет сбор данных о продуктах категории кофе на сайте "Метро". Информация будет выводиться в консоль, а также сохраняется в файл Excel с именем coffee_metro.xlsx 4. По завершении работы парсера в консоли вы увидите сообщение о времени выполнения -## Настройки -Измените переменную link_to_coffee в файле main.py, чтобы указать другую категорию продуктов +## Использование + +При старте программы в консоле возникнет поле ввода. Впишите туда ссылку категории продуктов, которую хотите распарсить. ``` -link_to_coffee = 'https://online.metro-cc.ru/category/chaj-kofe-kakao/kofe?from=under_search&in_stock=1' +Привет! Введи URL категории товара для парсинга: +https://online.metro-cc.ru/category/chaj-kofe-kakao/kofe?from=under_search&in_stock=1 ``` -Вы также можете настроить другие параметры, такие как классы элементов HTML для поиска продуктов и кнопки "Показать еще" в коде main.py, если сайт "Метро" изменится + +Вы также можете настроить другие параметры, такие как классы элементов HTML для поиска продуктов и кнопки "Показать еще" в коде main.py, если сайт "Метро" изменится. ## Дополнения -Для ускорения работы парсера можно использовать асинхронный подход (asyncio, aiohttp). Буду рада :sparkles: pull-request :sparkles: c предложениями по оптимизацией работы кода +Буду рада ✨ pull-request ✨ c предложениями по оптимизацией работы кода. diff --git a/coffee_metro.xlsx b/coffee_metro.xlsx index ec8a270..4bea084 100644 Binary files a/coffee_metro.xlsx and b/coffee_metro.xlsx differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..3ad1485 --- /dev/null +++ b/config.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from dotenv import load_dotenv + + +load_dotenv() + + +class Settings: + BASE_DIR = Path(__file__).resolve().parent + + @property + def DATABASE_URL(self): + return f'sqlite+aiosqlite:///metro.db' + + +settings = Settings() diff --git a/database.py b/database.py new file mode 100644 index 0000000..14a2d5d --- /dev/null +++ b/database.py @@ -0,0 +1,76 @@ +import os + +from sqlalchemy import Integer, String, Column, select, insert +from sqlalchemy.ext.asyncio import \ + AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase +import pandas as pd + +from config import settings + + +engine = create_async_engine( + settings.DATABASE_URL, + connect_args = {"check_same_thread": False}, + future=True, + echo=False, +) + +async_session = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) + + +async def get_session() -> AsyncSession: + async with async_session() as session: + yield session + + +async def connect(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + +class Base(DeclarativeBase): + pass + + +class Product(Base): + __tablename__ = "products" + id = Column(Integer, primary_key=True, autoincrement=True) + product_id = Column(String(255)) + name = Column(String(500)) + link = Column(String(255)) + regular_price = Column(String(255)) + promo_price = Column(String(255)) + brand = Column(String(255)) + + @staticmethod + async def add(**kwargs): + session: AsyncSession = [i async for i in get_session()][0] + async with session: + query = insert(Product).values(**kwargs) + await session.execute(query) + await session.commit() + + @staticmethod + async def export(): + print('Начинаем экспорт данных') + session: AsyncSession = [i async for i in get_session()][0] + async with session: + query = select(Product) + execute = await session.execute(query) + result = execute.unique().scalars().all() + df = pd.DataFrame([{ + 'id': row.product_id, + 'name': row.name, + 'link': row.link, + 'regular_price': row.regular_price, + 'promo_price': row.promo_price, + 'brand': row.brand + } for row in result]) + df.to_excel( + os.path.join(settings.BASE_DIR, 'coffee_metro.xlsx'), + index=False + ) diff --git a/main.py b/main.py index a0746b0..c3c1496 100644 --- a/main.py +++ b/main.py @@ -1,106 +1,132 @@ -import requests +import datetime as dt +import asyncio + +import aiohttp +import aiolimiter import bs4 -import pandas -import datetime +import prompt + +from database import connect, Product + + +PRODUCT_CLASS = 'catalog-2-level-product-card' +SHOW_MORE_BUTTON_CLASS = 'subcategory-or-type__load-more' + + +async def scrape(html: str, session: aiohttp.ClientSession): + # Создаем объект BeautifulSoup для парсинга HTML текущей страницы + soup = bs4.BeautifulSoup(html, 'html.parser') + + # Находим все продукты на текущей странице + products = soup.find_all('div', PRODUCT_CLASS) + + # Проверяем, есть ли продукты на текущей странице + if not products: + return + + # Итерируемся по каждому продукту на текущей странице + for product in products: + product_id = product['data-sku'] + name = product.find('span', 'product-card-name__text').text.strip() + link = 'https://online.metro-cc.ru' + product.find('a', 'product-card-photo__link')['href'] + + # Отправляем GET-запрос к странице продукта + response: aiohttp.ClientResponse = await session.get(link) + + # Проверяем успешность запроса + if not response.ok: + break + + # Считываем контент страницы + html = await response.text() + + # Создаем объект BeautifulSoup для парсинга HTML страницы продукта + link_soup = bs4.BeautifulSoup(html, 'html.parser') + + # Извлекаем информацию о бренде продукта + brand_elem = link_soup.find('meta', {'itemprop': 'brand'}) + brand = brand_elem.get('content') if brand_elem else None + # Извлекаем информацию о цене продукта + regular_price_element = product.find('div', 'product-unit-prices__old-wrapper') + if regular_price_element: + regular_price = regular_price_element.find('span', 'product-price__sum-rubles') + regular_price = regular_price.text.strip().replace(" ", "") if regular_price else None + else: + regular_price = None -link_to_coffee = 'https://online.metro-cc.ru/category/chaj-kofe-kakao/kofe?from=under_search&in_stock=1' -product_class = 'catalog-2-level-product-card' -show_more_button_class = 'subcategory-or-type__load-more' + # Извлекаем информацию о промо-цене продукта + promo_price_element = product.find('div', 'product-unit-prices__actual-wrapper') + if promo_price_element: + promo_price = promo_price_element.find('span', 'product-price__sum-rubles') + promo_price = promo_price.text.strip() if promo_price else None + else: + promo_price = None + # Добавляем информацию о продукте в БД + await Product.add( + product_id = product_id, + name = name, + link = link, + regular_price = regular_price, + promo_price = promo_price, + brand = brand + ) -def parse_metro_categories(category_link): - page_number = 1 - data_list = [] + print(f'Продукт {product_id} добавлен в базу данных.') + + +async def main(): + throttler = aiolimiter.AsyncLimiter(max_rate=1000, time_period=1) # 1000 задач в секунду + + category_link = prompt.string('Привет! Введи URL категории товара для парсинга:\n') + + await connect() # Используем менеджер сеансов для повторного использования соединения - with requests.Session() as session: - while True: - # Формируем URL текущей страницы - current_page_url = f"{category_link}&page={page_number}" - - # Отправляем GET-запрос к текущей странице - request = session.get(current_page_url) - - # Проверяем успешность запроса - if request.status_code != 200: - break - - # Создаем объект BeautifulSoup для парсинга HTML текущей страницы - soup = bs4.BeautifulSoup(request.text, 'html.parser') - - # Находим все продукты на текущей странице - products = soup.find_all('div', product_class) - - # Проверяем, есть ли продукты на текущей странице - if not products: - break - - # Итерируемся по каждому продукту на текущей странице - for product in products: - product_id = product['data-sku'] - name = product.find('span', 'product-card-name__text').text.strip() - link = 'https://online.metro-cc.ru' + product.find('a', 'product-card-photo__link')['href'] - - # Отправляем GET-запрос к странице продукта - link_request = session.get(link) - - # Проверяем успешность запроса - if link_request.status_code == 200: - # Создаем объект BeautifulSoup для парсинга HTML страницы продукта - link_soup = bs4.BeautifulSoup(link_request.text, 'html.parser') - - # Извлекаем информацию о бренде продукта - brand_elem = link_soup.find('meta', {'itemprop': 'brand'}) - brand = brand_elem.get('content') if brand_elem else None - - # Извлекаем информацию о цене продукта - regular_price_element = product.find('div', 'product-unit-prices__old-wrapper') - if regular_price_element: - regular_price = regular_price_element.find('span', 'product-price__sum-rubles') - regular_price = regular_price.text.strip().replace(" ", "") if regular_price else None - else: - regular_price = None - - # Извлекаем информацию о промо-цене продукта - promo_price_element = product.find('div', 'product-unit-prices__actual-wrapper') - if promo_price_element: - promo_price = promo_price_element.find('span', 'product-price__sum-rubles') - promo_price = promo_price.text.strip() if promo_price else None - else: - promo_price = None - - # Добавляем информацию о продукте в список - data_list.append({ - 'id': product_id, - 'name': name, - 'link': link, - 'regular_price': regular_price, - 'promo_price': promo_price, - 'brand': brand - }) - # Выводим последний добавленный продукт и общее количество продуктов (для отладки) - print(data_list[-1]) - print(len(data_list)) - - # Увеличиваем номер страницы для следующего запроса - page_number += 1 - - # Проверяем наличие кнопки "Показать еще" - show_more_button = soup.find('button', {'class': show_more_button_class}) - if not show_more_button: - print("No 'Show More' button found. Exiting.") - break - - # Создаем DataFrame из списка продуктов и сохраняем в Excel-файл - df = pandas.DataFrame(data_list) - df.to_excel('coffee_metro.xlsx', index=False) - return data_list + async with aiohttp.ClientSession() as session: + page_number = 1 + tasks = [] + + async with throttler: + while True: + # Формируем URL текущей страницы + current_page_url = f'{category_link}&page={page_number}' + print(f'Парсим страницу {page_number}.') + + # Отправляем GET-запрос к текущей странице + async with session.get(current_page_url) as response: + + # Проверяем успешность запроса + if not response.ok: + break + + # Считываем контент страницы + html = await response.text() + + # Создаем задачу извлечения данных + task = asyncio.create_task(scrape(html, session)) + tasks.append(task) + print(f'Задачу на страницу {page_number} поставили.') + + soup = bs4.BeautifulSoup(html, 'html.parser') + # Проверяем наличие кнопки "Показать еще" + show_more_button = soup.find('button', {'class': SHOW_MORE_BUTTON_CLASS}) + if not show_more_button: + print('Кнопка «Показать ещё» не найдена. Выход.') + break + + # Инкрементируем счетчик + page_number += 1 + + await asyncio.gather(*tasks) + + await Product.export() if __name__ == "__main__": # Замеряем время выполнения - start_time = datetime.datetime.now() - parse_metro_categories(link_to_coffee) - end_time = datetime.datetime.now() - print(f'Время выполнения: {end_time-start_time}') \ No newline at end of file + start_time = dt.datetime.now() + asyncio.run(main()) + end_time = dt.datetime.now() + print(f'Время выполнения: {end_time-start_time}') diff --git a/requirements.txt b/requirements.txt index 01d9d19..d25fa9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,24 @@ +aiohttp==3.9.1 +aiolimiter==1.1.0 +aiosignal==1.3.1 +aiosqlite==0.19.0 +attrs==23.1.0 beautifulsoup4==4.12.2 -certifi==2023.7.22 -charset-normalizer==3.3.2 et-xmlfile==1.1.0 -idna==3.4 -numpy==1.24.4 +frozenlist==1.4.0 +greenlet==3.0.1 +idna==3.6 +multidict==6.0.4 +numpy==1.26.2 openpyxl==3.1.2 -pandas==2.0.3 +pandas==2.1.3 +prompt==0.4.1 python-dateutil==2.8.2 +python-dotenv==1.0.0 pytz==2023.3.post1 -requests==2.31.0 six==1.16.0 -sortedcontainers==2.4.0 soupsieve==2.5 +SQLAlchemy==2.0.23 +typing_extensions==4.8.0 tzdata==2023.3 -urllib3==2.1.0 +yarl==1.9.3