From d962f8df382e8b518ef9cd94e0ede55a76624aab Mon Sep 17 00:00:00 2001 From: xob0t <5348245@gmail.com> Date: Thu, 5 Dec 2024 09:06:46 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * парсинг рейтинга продавца * проверка версии при запуске * парсинг редиректов в каталог из поиска fixes #9 --- README.md | 137 ++-- core/{db.py => db_utils.py} | 79 +-- core/exceptions.py | 10 + core/interactive_config.py | 304 +++++---- core/main.py | 110 ++-- core/parser_base.py | 155 ----- core/parser_url.py | 814 ++++++++++++------------ core/{telegram_utils.py => telegram.py} | 32 +- core/utils.py | 108 +++- setup.py | 4 +- 10 files changed, 823 insertions(+), 930 deletions(-) rename core/{db.py => db_utils.py} (64%) create mode 100644 core/exceptions.py delete mode 100644 core/parser_base.py rename core/{telegram_utils.py => telegram.py} (71%) diff --git a/README.md b/README.md index 6ea246f..82e4812 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,95 @@ -# +# + ``` ____ ___ ____ ___ ____ ____ _____________ _____ / __ `__ \/ __ `__ \/ __ \/ __ `/ ___/ ___/ _ \/ ___/ - / / / / / / / / / / / /_/ / /_/ / / (__ ) __/ / - /_/ /_/ /_/_/ /_/ /_/ .___/\__,_/_/ /____/\___/_/ + / / / / / / / / / / / /_/ / /_/ / / (__ ) __/ / + /_/ /_/ /_/_/ /_/ /_/ .___/\__,_/_/ /____/\___/_/ /_/ ``` + ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/xob0t/mmparser/total) + ## Сказать спасибо автору - [Yoomoney](https://yoomoney.ru/to/410018051351692) -Связь со мной [tg](https://t.me/mobate) + +Связь со мной [tg](https://t.me/mobate) - Индивидуальной поддержкой бесплатно не занимаюсь ### Демо ускорено в 10 раз [![asciicast](https://asciinema.org/a/fYFj0HVO16r16vaK1reEa4617.svg)](https://asciinema.org/a/fYFj0HVO16r16vaK1reEa4617) +
Пример уведомления Telegram
## Особенности -* Работа через api -* Парсинг карточек товаров при парсинге каталога/поиска -* Сохранение результатов в sqlite БД -* Запуск с конфигом и/или аргументами -* Интерактивное создание конфигов -* Поддержка прокси строкой или списком из файла -* Поддержка ссылок каталога, поиска, и карточек товара -* Парсинг одной ссылки в многопотоке, по потоку на прокси/соединение -* Импорт cookies экспортированних в формате Json с помошью [Cookie-Editor](https://chrome.google.com/webstore/detail/hlkenndednhfkekhgcdicdfddnkalmdm) -* Блеклист продавцов -* Regex фильтр по именам товаров -* Уведомления в телеграм по заданным параметрам -* Позволяет выставить время, через которое подходящий по параметрам уведомлений товар будет повторно отправлен в TG -* Использование блеклиста продавцов с ограничением на списание бонусов -* Сссылки на каталог супермаркетов не поддерживаются :( + +- Работа через api +- Парсинг карточек товаров при парсинге каталога/поиска +- Сохранение результатов в sqlite БД +- Запуск с конфигом и/или аргументами +- Интерактивное создание конфигов +- Поддержка прокси строкой или списком из файла +- Поддержка ссылок каталога, поиска, и карточек товара +- Парсинг одной ссылки в многопотоке, по потоку на прокси/соединение +- Импорт cookies экспортированних в формате Json с помошью [Cookie-Editor](https://chrome.google.com/webstore/detail/hlkenndednhfkekhgcdicdfddnkalmdm) +- Блеклист продавцов +- Regex фильтр по именам товаров +- Уведомления в телеграм по заданным параметрам +- Позволяет выставить время, через которое подходящий по параметрам уведомлений товар будет повторно отправлен в TG +- Использование блеклиста продавцов с ограничением на списание бонусов +- Сссылки на каталог супермаркетов не поддерживаются :( ## Установка: - 1. Установить [Python](https://www.python.org/downloads/), в установщике поставить галочку "Добавить в PATH" - 2. [Скачать парсер](https://github.com/xob0t/mmparser/releases/latest/download/mmparser.zip) - 3. Установить парсер: `pip install mmparser.zip -U` + +1. Установить [Python](https://www.python.org/downloads/), в установщике поставить галочку "Добавить в PATH" +2. [Скачать парсер](https://github.com/xob0t/mmparser/releases/latest/download/mmparser.zip) +3. Установить парсер: `pip install mmparser.zip -U` ## Пример использования + ### Кавычки обязательны! + ### Просто парсинг url + ``` mmparser "https://megamarket.ru/catalog/?q=%D0%BD%D0%BE%D1%83%D1%82%D0%B1%D1%83%D0%BA&suggestionType=frequent_query#?filters=%7B%2288C83F68482F447C9F4E401955196697%22%3A%7B%22min%22%3A229028%2C%22max%22%3A307480%7D%2C%22A03364050801C25CD0A856C734F74FE9%22%3A%5B%221%22%5D%7D&sort=1" ``` + ### Парсинг url с cookie файлом + ``` mmparser -cookies "cookies.json" "https://megamarket.ru/catalog/details/processor-amd-ryzen-5-5600-am4-oem-600008773764/" ``` + ### Без аргументов, создание конфига + ``` mmparser ``` + ### Запуск с конфигом + ``` -mmparser -cfg "config.json" +mmparser -config "config.json" ``` ## Чтение результатов + При запуске парсер создаст в рабочей директории файл storage.sqlite Это sqlite база данных, очень удобно читается в [DB Browser for SQLite](https://sqlitebrowser.org/) ## Запуск по расписанию на windows: + [Планировщик заданий Windows для начинающих](https://remontka.pro/windows-task-scheduler/) # ``` -usage: mmparser [-h] [-job JOB_NAME] [-cfg CONFIG] [-i INCLUDE] [-e EXCLUDE] [-b BLACKLIST] [-ac] [-nc] [-c COOKIES] - [-aa ACCOUNT_ALERT] [-a ADDRESS] [-p PROXY] [-pl PROXY_LIST] [-ad] [-tc TG_CONFIG] - [-pva PRICE_VALUE_ALERT] [-pbva PRICE_BONUS_VALUE_ALERT] [-bva BONUS_VALUE_ALERT] - [-bpa BONUS_PERCENT_ALERT] [-mb] [-art ALERT_REPEAT_TIMEOUT] [-t THREADS] [-d DELAY] [-ed ERROR_DELAY] - [-log {DEBUG,INFO,WARNING,ERROR,CRITICAL}] +mmparser [-h] [-job-name JOB_NAME] [-config CONFIG] [-include INCLUDE] [-exclude EXCLUDE] [-blacklist BLACKLIST] [-all-cards] [-no-cards] [-cookies COOKIES] [-account-alert ACCOUNT_ALERT] [-address ADDRESS] [-proxy PROXY] [-proxy-list PROXY_LIST] [-allow-direct] [-tg-config TG_CONFIG] [-price-value-alert PRICE_VALUE_ALERT] + [-price-bonus-value-alert PRICE_BONUS_VALUE_ALERT] [-bonus-value-alert BONUS_VALUE_ALERT] [-bonus-percent-alert BONUS_PERCENT_ALERT] [-use-merchant-blacklist] [-alert-repeat-timeout ALERT_REPEAT_TIMEOUT] [-threads THREADS] [-delay DELAY] [-error-delay ERROR_DELAY] [-log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL}] [url] positional arguments: @@ -82,52 +97,38 @@ positional arguments: options: -h, --help show this help message and exit - -job JOB_NAME, --job-name JOB_NAME - Название задачи, без этого параметра будет автоопределено - -cfg CONFIG, --config CONFIG - Путь к конфигу парсера - -i INCLUDE, --include INCLUDE - Парсить только товары, название которых совпадает с выражением - -e EXCLUDE, --exclude EXCLUDE - Пропускать товары, название которых совпадает с выражением - -b BLACKLIST, --blacklist BLACKLIST - Путь к файлу со списком игнорируемых продавцов - -ac, --all-cards Всегда парсить карточки товаров - -nc, --no-cards Не парсить карточки товаров - -c COOKIES, --cookies COOKIES - Путь к файлу с cookies в формате JSON (Cookie-Editor - Export Json) - -aa ACCOUNT_ALERT, --account-alert ACCOUNT_ALERT + -job-name JOB_NAME Название задачи, без этого параметра будет автоопределено + -config CONFIG Путь к конфигу парсера + -include INCLUDE Парсить только товары, название которых совпадает с выражением + -exclude EXCLUDE Пропускать товары, название которых совпадает с выражением + -blacklist BLACKLIST Путь к файлу со списком игнорируемых продавцов + -all-cards Всегда парсить карточки товаров + -no-cards Не парсить карточки товаров + -cookies COOKIES Путь к файлу с cookies в формате JSON (Cookie-Editor - Export Json) + -account-alert ACCOUNT_ALERT Если вы используйте cookie, и вход в аккаунт не выполнен, присылать уведомление в TG - -a ADDRESS, --address ADDRESS - Адрес, будет использовано первое сопадение - -p PROXY, --proxy PROXY - Строка прокси в формате protocol://username:password@ip:port - -pl PROXY_LIST, --proxy-list PROXY_LIST + -address ADDRESS Адрес, будет использовано первое сопадение + -proxy PROXY Строка прокси в формате protocol://username:password@ip:port + -proxy-list PROXY_LIST Путь к файлу с прокси в формате protocol://username:password@ip:port - -ad, --allow-direct Использовать прямое соединение параллельно с прокси для ускорения работы в многопотоке - -tc TG_CONFIG, --tg-config TG_CONFIG - Telegram Bot Token и Telegram Chat Id в формате token$id - -pva PRICE_VALUE_ALERT, --price-value-alert PRICE_VALUE_ALERT + -allow-direct Использовать прямое соединение параллельно с прокси для ускорения работы в многопотоке + -tg-config TG_CONFIG Telegram Bot Token и Telegram Chat Id в формате token$id + -price-value-alert PRICE_VALUE_ALERT Если цена товара равна или ниже данного значения, уведомлять в TG - -pbva PRICE_BONUS_VALUE_ALERT, --price-bonus-value-alert PRICE_BONUS_VALUE_ALERT + -price-bonus-value-alert PRICE_BONUS_VALUE_ALERT Если цена-бонусы товара равна или ниже данного значения, уведомлять в TG - -bva BONUS_VALUE_ALERT, --bonus-value-alert BONUS_VALUE_ALERT + -bonus-value-alert BONUS_VALUE_ALERT Если количество бонусов товара равно или выше данного значения, уведомлять в TG - -bpa BONUS_PERCENT_ALERT, --bonus-percent-alert BONUS_PERCENT_ALERT + -bonus-percent-alert BONUS_PERCENT_ALERT Если процент бонусов товара равно или выше данного значения, уведомлять в TG - -mb, --use-merchant-blacklist - Использовать черный список продавцов с ограничением на списание бонусов. - Для более эффективной работы рекомендуется установить парсер с поддержкой lxml: - pip install mmparser.zip[lxml] -U - -art ALERT_REPEAT_TIMEOUT, --alert-repeat-timeout ALERT_REPEAT_TIMEOUT - Если походящий по параметрам товар уже был отправлен в TG, повторно уведомлять по истечении - заданного времени, в часах - -t THREADS, --threads THREADS - Количество потоков. По умолчанию: 1 на каждое соединиение - -d DELAY, --delay DELAY - Задержка между запросами в секундах при работе в одном потоке. По умолчанию: 1.8 - -ed ERROR_DELAY, --error-delay ERROR_DELAY + -use-merchant-blacklist + Использовать черный список продавцов с ограничением на списание бонусов + -alert-repeat-timeout ALERT_REPEAT_TIMEOUT + Если походящий по параметрам товар уже был отправлен в TG, повторно уведомлять по истечении заданного времени, в часах + -threads THREADS Количество потоков. По умолчанию: 1 на каждое соединиение + -delay DELAY Задержка между запросами в секундах при работе в одном потоке. По умолчанию: 1.8 + -error-delay ERROR_DELAY Задержка между запосами в секундах в случае ошибки при работе в одном потоке. По умолчанию: 5 - -log {DEBUG,INFO,WARNING,ERROR,CRITICAL}, --log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL} + -log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL} Уровень лога. По умолчанию: INFO ``` diff --git a/core/db.py b/core/db_utils.py similarity index 64% rename from core/db.py rename to core/db_utils.py index 3fc1775..c21606b 100644 --- a/core/db.py +++ b/core/db_utils.py @@ -30,24 +30,24 @@ def new_job(job_name): """INSERT INTO jobs (name,started) VALUES (?,?)""", - (job_name, - now) + (job_name, now), ) job_id = cursor.lastrowid cursor.execute(f""" CREATE TABLE "{job_name}_{job_id}" ( - "goodsId" TEXT, - "merchantId" TEXT, + "goods_id" TEXT, + "merchant_id" TEXT, "url" TEXT, "title" TEXT, - "finalPrice" INTEGER, - "finalPriceBonus" INTEGER, - "bonusAmount" INTEGER, - "bonusPercent" INTEGER, - "availableQuantity" INTEGER, - "deliveryPossibilities" TEXT, - "merchantName" TEXT, - "scraped" DATETIME, + "price" INTEGER, + "price_bonus" INTEGER, + "bonus_amount" INTEGER, + "bonus_percent" INTEGER, + "available_quantity" INTEGER, + "delivery_date" TEXT, + "merchant_name" TEXT, + "merchant_rating" FLOAT, + "scraped_at" DATETIME, "notified" BOOL ); """) @@ -58,17 +58,18 @@ def new_job(job_name): def add_to_db( job_id, job_name, - goodsId, - merchantId, + goods_id, + merchant_id, url, title, - finalPrice, - finalPriceBonus, - bonusAmount, - bonusPercent, - availableQuantity, - deliveryPossibilities, - merchantName, + price, + price_bonus, + bonus_amount, + bonus_percent, + available_quantity, + delivery_date, + merchant_name, + merchant_rating, notified, ): now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -77,22 +78,23 @@ def add_to_db( cursor.execute( f"""INSERT INTO "{job_name}_{job_id}" - (goodsId,merchantId,url,title,finalPrice,finalPriceBonus,bonusAmount, - bonusPercent,availableQuantity,deliveryPossibilities, - merchantName,scraped,notified) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (goods_id,merchant_id,url,title,price,price_bonus,bonus_amount, + bonus_percent,available_quantity,delivery_date, + merchant_name,merchant_rating,scraped_at,notified) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( - goodsId, - merchantId, + goods_id, + merchant_id, url, title, - finalPrice, - finalPriceBonus, - bonusAmount, - bonusPercent, - availableQuantity, - deliveryPossibilities, - merchantName, + price, + price_bonus, + bonus_amount, + bonus_percent, + available_quantity, + delivery_date, + merchant_name, + merchant_rating, now, notified, ), @@ -100,7 +102,7 @@ def add_to_db( sqlite_connection.commit() -def get_last_notified(goodsId, merchantId, finalPrice, bonusAmount): +def get_last_notified(goods_id, merchant_id, price, bonus_amount): sqlite_connection = sqlite3.connect(FILENAME) cursor = sqlite_connection.cursor() last_notified_row = None @@ -113,12 +115,12 @@ def get_last_notified(goodsId, merchantId, finalPrice, bonusAmount): job_tables = [table[0] for table in cursor.fetchall()] # Construct a union query to select from all job tables at once - union_query = " UNION ".join([f"SELECT scraped FROM '{table}' WHERE notified = 1 AND goodsId = ? AND merchantId = ? AND finalPrice = ? AND bonusAmount = ?" for table in job_tables]) + union_query = " UNION ".join([f"SELECT scraped FROM '{table}' WHERE notified = 1 AND goods_id = ? AND merchant_id = ? AND price = ? AND bonus_amount = ?" for table in job_tables]) - union_query+="ORDER BY scraped DESC LIMIT 1" + union_query += "ORDER BY scraped DESC LIMIT 1" # Concatenate all parameters to be passed into the execute function - parameters = tuple([goodsId, merchantId, finalPrice, bonusAmount] * len(job_tables)) + parameters = tuple([goods_id, merchant_id, price, bonus_amount] * len(job_tables)) cursor.execute(union_query, parameters) row = cursor.fetchone() @@ -128,6 +130,7 @@ def get_last_notified(goodsId, merchantId, finalPrice, bonusAmount): cursor.close() return last_notified_row + def finish_job(job_id): now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") sqlite_connection = sqlite3.connect(FILENAME) diff --git a/core/exceptions.py b/core/exceptions.py new file mode 100644 index 0000000..2bc031f --- /dev/null +++ b/core/exceptions.py @@ -0,0 +1,10 @@ +class BaseException(Exception): + pass + + +class ConfigError(BaseException): + pass + + +class ApiError(BaseException): + pass diff --git a/core/interactive_config.py b/core/interactive_config.py index 957e63f..32a23ae 100644 --- a/core/interactive_config.py +++ b/core/interactive_config.py @@ -8,9 +8,8 @@ from curl_cffi import requests from InquirerPy import inquirer -from core.parser_url import Parser_url -import core.telegram_utils as telegram_utils -import core.utils as utils +from .parser_url import Parser_url +from . import telegram, utils, exceptions console = Console() @@ -42,15 +41,16 @@ } -def accent(string): +def accent(string: str) -> str: return f"[bold cyan]{string}[/bold cyan]" -def validate_url(url: str): +def validate_url(url: str) -> bool: + """Проверка URL""" try: if not requests.get(url).ok: return False - except: + except Exception: return False parser = Parser_url(url) @@ -65,26 +65,21 @@ def validate_url(url: str): return False -def save_config_dict(config_dict: dict, name: str): +def save_config_dict(config_dict: dict, name: str) -> None: + """Сохранить конфиг в json""" try: file_name = f"{name}.json" with open(file_name, "w", encoding="utf-8") as config_file: json.dump(config_dict, config_file, indent=4, ensure_ascii=False) return file_name - except: - raise Exception(f"Ошибка сохранения конфига {file_name}!") + except Exception as exc: + raise exceptions.ConfigError(f"Ошибка сохранения конфига {file_name}!") from exc -def create_config(): - console.print("[green]Добро пожаловать в интерактивный конфигуратор mmparser!") - console.print("[cyan]В квадратных скобках указаны значения по умолчанию") - - user_input = None - config_dict["job_name"] = "" - while not config_dict["job_name"]: - user_input = console.input( - "URL, который будем парсить. Парсинг всегда начнется с 1 страницы. (Рекомендуемые: товар или поиск):\n" - ) +def get_job_name(config_dict: dict) -> None: + """Определить название задачи""" + while True: + user_input = console.input("URL, который будем парсить. Парсинг всегда начнется с 1 страницы. (Рекомендуемые: товар или поиск):\n") if not user_input: continue console.print("[yellow]Идет проверка url...") @@ -98,47 +93,28 @@ def create_config(): merchant = (parsed_input_url.get("merchant", {}) or {}).get("slug") unknown = "Не определено" config_dict["url"] = user_input - config_dict["job_name"] = utils.remove_chars( - search_text or collection_title or merchant or unknown - ) + config_dict["job_name"] = utils.remove_chars(search_text or collection_title or merchant or unknown) + break - use_telegram = inquirer.confirm( - message="Присылать уведомления в Telegram?", default=True - ).execute() + +def get_telegram_config(config_dict: dict) -> None: + """Получить конфигурацию Telegram из введенных пользователем данных""" + use_telegram = inquirer.confirm(message="Присылать уведомления в Telegram?", default=True).execute() if use_telegram: while True: - console.print( - "Telegram Bot Token и Telegram Chat Id в формате [bold cyan]token$id[/bold cyan]" - ) - console.print( - "Пример: [bold white]633434921:AAErf79oop8XbNNCHAssWKtUGM6QnJnXwWE[cyan]$[/cyan]34278514[/bold white]" - ) + console.print("Telegram Bot Token и Telegram Chat Id в формате [bold cyan]token$id[/bold cyan]") + console.print("Пример: [bold white]633434921:AAErf79oop8XbNNCHAssWKtUGM6QnJnXwWE[cyan]$[/cyan]34278514[/bold white]") user_input = console.input() console.print("[yellow]Проверяем конфиг...") - if telegram_utils.validate_credentials(user_input): + if telegram.validate_tg_credentials(user_input): console.print(f"[green]Введенный конфиг: {user_input}") config_dict["tg_config"] = user_input break console.print("[red]Некорректное значение!") - while True: - user_input = console.input(f"Название задачи \[{accent(config_dict['job_name'])}]:")or config_dict["job_name"] - safe_user_input = utils.remove_chars(user_input) - if not safe_user_input: - continue - if safe_user_input == user_input: - config_dict["job_name"] = safe_user_input - console.print(f"[green]{config_dict['job_name']}") - break - if safe_user_input != user_input: - console.print( - "[yellow]Из названия были убраны запрещенные символы, будет использовано:" - ) - config_dict["job_name"] = safe_user_input - console.print(f"[green]{config_dict['job_name']}") - break - console.print("[red]Не корркетное название") +def get_parsing_config(config_dict: dict) -> None: + """Получить конфигурацию парсинга из введенных пользователем данных""" if "/details/" not in config_dict["url"]: while True: warning = """[yellow]Данные о наличии других предложений у товара доступны только в поиске. @@ -167,10 +143,7 @@ def create_config(): ], ).execute() - if ( - choice - == "Когда в api есть информация, что есть другие предложения, или если ее нет совсем" - ): + if choice == "Когда в api есть информация, что есть другие предложения, или если ее нет совсем": break if choice == "Всегда": config_dict["all_cards"] = True @@ -180,9 +153,7 @@ def create_config(): break console.print("[red]Неверный ввод. Попробуйте еще раз.[/red]") while True: - user_input = console.input( - f"Обрабатывать только товары, название которых совпадает с regex \[{accent('Пропуск')}]:" - ) + user_input = console.input(f"Обрабатывать только товары, название которых совпадает с regex \[{accent('Пропуск')}]:") if user_input == "": config_dict["include"] = None break @@ -192,9 +163,7 @@ def create_config(): break console.print(f'[red]Неверное выражение "{user_input}" ![/red]') while True: - user_input = console.input( - f"Игнорировать товары, название которых совпадает с regex \[{accent('Пропуск')}]:" - ) + user_input = console.input(f"Игнорировать товары, название которых совпадает с regex \[{accent('Пропуск')}]:") if user_input == "": config_dict["exclude"] = None break @@ -204,10 +173,11 @@ def create_config(): break console.print(f'[red]Неверное выражение "{user_input}" ![/red]') + +def get_blacklist_config(config_dict: dict) -> None: + """Получить конфигурацию черного списка из введенных пользователем данных""" while True: - user_input = console.input( - f"Использовать файл с black-листом продавцов? \[{accent('Пропуск')}]:" - ) + user_input = console.input(f"Использовать файл с black-листом продавцов? \[{accent('Пропуск')}]:") if user_input == "": config_dict["blacklist"] = None break @@ -217,10 +187,11 @@ def create_config(): break console.print(f'[red]Файл "{user_input}" не найден![/red]') + +def get_cookie_config(config_dict: dict) -> None: + """Получить конфигурацию cookie из пользовательского ввода""" while True: - user_input = console.input( - f"Путь к файлу с cookies в формате JSON (Cookie-Editor - Export Json) \[{accent('Пропуск')}]:" - ) + user_input = console.input(f"Путь к файлу с cookies в формате JSON (Cookie-Editor - Export Json) \[{accent('Пропуск')}]:") if user_input == "": config_dict["cookie_file_path"] = None break @@ -232,81 +203,73 @@ def create_config(): console.print(f"[red]Ошибка чтения cookies из файла {user_input}!") console.print(f"[red]Файл {user_input} не найден") + +def get_address_config(config_dict: dict) -> None: + """Получить конфигурацию адреса из введенных пользователем данных""" if config_dict["cookie_file_path"]: - config_dict["address"] = console.input( - f"Адрес, будет использовано первое сопадение. \[{accent('Адрес из cookies')}]:" - ) + config_dict["address"] = console.input(f"Адрес, будет использовано первое сопадение. \[{accent('Адрес из cookies')}]:") else: - config_dict["address"] = console.input( - f"Адрес, будет использовано первое сопадение. \[{accent('Москва')}]:" - ) + config_dict["address"] = console.input(f"Адрес, будет использовано первое сопадение. \[{accent('Москва')}]:") + +def get_alert_config(config_dict: dict) -> None: + """Получить конфигурацию оповещения из пользовательского ввода""" if config_dict["tg_config"]: while True: - user_input = console.input( - f"Если цена товара равна или ниже данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:" - ) + user_input = console.input(f"Если цена товара равна или ниже данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:") if user_input == "": config_dict["price_value_alert"] = None break - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["price_value_alert"] = num - break + try: + config_dict["price_value_alert"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") + while True: - user_input = console.input( - f"Если цена-бонусы товара равна или ниже данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:" - ) + user_input = console.input(f"Если цена-бонусы товара равна или ниже данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:") if user_input == "": config_dict["price_bonus_value_alert"] = None break - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["price_bonus_value_alert"] = num - break + try: + config_dict["price_bonus_value_alert"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") while True: - user_input = console.input( - f"Если количество бонусов товара равно или выше данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:" - ) + user_input = console.input(f"Если количество бонусов товара равно или выше данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:") if user_input == "": config_dict["bonus_value_alert"] = None break - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["bonus_value_alert"] = num - break + try: + config_dict["bonus_value_alert"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") while True: - user_input = console.input( - f"Если процент бонусов товара равен или выше данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:" - ) + user_input = console.input(f"Если процент бонусов товара равен или выше данного значения, уведомлять в TG \[{accent('Не уведомлять')}]:") if user_input == "": config_dict["bonus_percent_alert"] = None break - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["bonus_percent_alert"] = num - break + try: + config_dict["bonus_percent_alert"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") while True: - user_input = console.input( - f"Время в часах, через которое подходящий по параметрам уведомлений товар будет повторно отправлен в TG \[{accent(config_dict['alert_repeat_timeout'])}]:" - ) + user_input = console.input(f"Время в часах, через которое подходящий по параметрам уведомлений товар будет повторно отправлен в TG \[{accent(config_dict['alert_repeat_timeout'])}]:") if user_input == "": + config_dict["alert_repeat_timeout"] = None break - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["alert_repeat_timeout"] = num - break + try: + config_dict["alert_repeat_timeout"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") + +def get_proxy_config(config_dict: dict) -> None: + """Получить конфигурацию прокси из введенных пользователем данных""" choice = inquirer.select( message="Использовать прокси?", choices=["Нет", "Из строки (один)", "Из файла (список)"], @@ -314,9 +277,7 @@ def create_config(): if choice == "Из строки (один)": while True: - user_input = console.input( - "Введите строку прокси в формате protocol://username:password@ip:port:" - ) + user_input = console.input("Введите строку прокси в формате protocol://username:password@ip:port:") if user_input: if utils.proxy_format_check(user_input): config_dict["proxy"] = user_input @@ -325,79 +286,106 @@ def create_config(): if choice == "Из файла (список)": while True: - user_input = console.input( - "Путь к файлу со списком прокси в формате protocol://username:password@ip:port:" - ) - path_is_valid = Path(user_input).exists() - if path_is_valid: + user_input = console.input("Путь к файлу со списком прокси в формате protocol://username:password@ip:port:") + if Path(user_input).exists(): console.print(f"[green]Файл найден: {user_input}") - config_dict["proxy_list"] = user_input + config_dict["proxy_file_path"] = user_input break console.print("[red]Файл не найден![/red]") + +def get_account_alert_config(config_dict: dict) -> None: + """Получить конфигурацию оповещения по ошибкам учетной записи из введенных пользователем данных""" if config_dict["cookie_file_path"] and config_dict["tg_config"]: config_dict["account_alert"] = inquirer.confirm( message="Если вход в аккаунт не выполнен, присылать уведомление в Telegram?", default=True, ).execute() - if config_dict["proxy"]: - config_dict["allow_direct"] = inquirer.confirm( - message="Использовать прямое соединение параллельно с прокси для ускорения работы в многопотоке?", - default=True, - ).execute() +def get_performance_config(config_dict: dict) -> None: + """Получить конфигурацию производительности из введенных пользователем данных""" while True: - user_input = console.input( - f"Количество потоков. По умолчанию: 1 на каждое соединиение \[{accent('По умолчанию')}]:" - ) + user_input = console.input(f"Количество потоков. По умолчанию: 1 на каждое соединиение \[{accent('По умолчанию')}]:") if user_input == "": break - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["threads"] = int(num) - break + try: + config_dict["threads"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") while True: - user_input = ( - console.input( - f"Задержка между запросами в секундах для одного соединения \[{accent(config_dict['delay'])}]:" - ) - or config_dict["delay"] - ) - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["delay"] = num - break + user_input = console.input(f"Задержка между запросами в секундах для одного соединения \[{accent(config_dict['delay'])}]:") or config_dict["delay"] + try: + config_dict["delay"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") while True: - user_input = ( - console.input( - f"Пауза в секундах в случае ошибки для одного соединения \[{accent(config_dict['error_delay'])}]:" - ) - or config_dict["error_delay"] - ) - if user_input: - num = utils.convert_float(user_input) - if num: - config_dict["error_delay"] = num - break + user_input = console.input(f"Пауза в секундах в случае ошибки для одного соединения \[{accent(config_dict['error_delay'])}]:") or config_dict["error_delay"] + try: + config_dict["error_delay"] = float(user_input) + break + except ValueError: console.print("[red]Неверный ввод![/red]") + +def get_merchant_blacklist_config(config_dict: dict) -> None: + """Получить конфигурацию черного списка продавцов из введенных пользователем данных""" config_dict["use_merchant_blacklist"] = inquirer.confirm( message="Использовать черный список продавцов с ограничением на списание бонусов?", default=False, ).execute() + +def create_config(): + console.print("[green]Добро пожаловать в интерактивный конфигуратор mmparser!") + console.print("[cyan]В квадратных скобках указаны значения по умолчанию") + + config_dict = { + "url": "", + "job_name": "Не определено", + "include": "", + "exclude": "", + "blacklist": "", + "all_cards": False, + "no_cards": False, + "cookie_file_path": "", + "address": "", + "proxy": "", + "allow_direct": True, + "proxy_file_path": "", + "tg_config": "", + "price_value_alert": "", + "price_bonus_value_alert": "", + "bonus_value_alert": "", + "bonus_percent_alert": "", + "alert_repeat_timeout": 0, + "use_merchant_blacklist": False, + "threads": 0, + "delay": 1.8, + "error_delay": 5, + "account_alert": False, + "log_level": "INFO", + } + + get_job_name(config_dict) + get_telegram_config(config_dict) + get_parsing_config(config_dict) + get_blacklist_config(config_dict) + get_cookie_config(config_dict) + get_address_config(config_dict) + get_alert_config(config_dict) + get_proxy_config(config_dict) + get_account_alert_config(config_dict) + get_performance_config(config_dict) + get_merchant_blacklist_config(config_dict) + file_name = save_config_dict(config_dict, config_dict["job_name"]) console.print("[green]Настройка завершена!") - console.print( - f'Вы можете запустить парсер командой [bold white]mmparser -cfg "{file_name}"[/bold white]' - ) + console.print(f'Вы можете запустить парсер командой [bold white]mmparser -config "{file_name}"[/bold white]') run = inquirer.confirm(message="Запустить сейчас?", default=True).execute() if run: console.print("[cyan]Запускаем парсер...") diff --git a/core/main.py b/core/main.py index 40a0ac6..97812bf 100644 --- a/core/main.py +++ b/core/main.py @@ -1,65 +1,70 @@ """cli""" + import argparse from pathlib import Path +from rich_argparse import RichHelpFormatter from core.parser_url import Parser_url from core.interactive_config import create_config from core.utils import read_json_file, print_logo +from .exceptions import ConfigError + -def run_url_parser(args, config = {}): +def run_url_parser(args: argparse.Namespace, config: dict = {}) -> None: parser_instance = Parser_url( - url = config.get('url') or args.url, - job_name = config.get('job_name') or args.job_name, - include = config.get('include') or args.include, - exclude = config.get('exclude') or args.exclude, - blacklist = config.get('blacklist') or args.blacklist, - all_cards = config.get('all_cards') or args.all_cards, - no_cards = config.get('no_cards') or args.no_cards, - cookie_file_path = config.get('cookie_file_path') or args.cookies, - address = config.get('address') or args.address, - proxy = config.get('proxy') or args.proxy, - allow_direct = config.get('allow_direct') or args.allow_direct, - proxy_file_path = config.get('proxy_file_path') or args.proxy_list, - tg_config = config.get('tg_config') or args.tg_config, - price_value_alert = config.get('price_value_alert') or args.price_value_alert, - price_bonus_value_alert = config.get('price_bonus_value_alert') or args.price_bonus_value_alert, - bonus_value_alert = config.get('bonus_value_alert') or args.bonus_value_alert, - bonus_percent_alert = config.get('bonus_percent_alert') or args.bonus_percent_alert, - alert_repeat_timeout = config.get('alert_repeat_timeout') or args.alert_repeat_timeout, - use_merchant_blacklist = config.get('use_merchant_blacklist') or args.use_merchant_blacklist, - threads = config.get('threads') or args.threads, - delay = config.get('delay') or args.delay, - error_delay = config.get('error_delay') or args.error_delay, - log_level = config.get('log_level') or args.log_level + url=config.get("url") or args.url, + job_name=config.get("job_name") or args.job_name, + include=config.get("include") or args.include, + exclude=config.get("exclude") or args.exclude, + blacklist=config.get("blacklist") or args.blacklist, + all_cards=config.get("all_cards") or args.all_cards, + no_cards=config.get("no_cards") or args.no_cards, + cookie_file_path=config.get("cookie_file_path") or args.cookies, + address=config.get("address") or args.address, + proxy=config.get("proxy") or args.proxy, + allow_direct=config.get("allow_direct") or args.allow_direct, + proxy_file_path=config.get("proxy_file_path") or args.proxy_list, + tg_config=config.get("tg_config") or args.tg_config, + price_value_alert=config.get("price_value_alert") or args.price_value_alert, + price_bonus_value_alert=config.get("price_bonus_value_alert") or args.price_bonus_value_alert, + bonus_value_alert=config.get("bonus_value_alert") or args.bonus_value_alert, + bonus_percent_alert=config.get("bonus_percent_alert") or args.bonus_percent_alert, + alert_repeat_timeout=config.get("alert_repeat_timeout") or args.alert_repeat_timeout, + use_merchant_blacklist=config.get("use_merchant_blacklist") or args.use_merchant_blacklist, + threads=config.get("threads") or args.threads, + delay=config.get("delay") or args.delay, + error_delay=config.get("error_delay") or args.error_delay, + log_level=config.get("log_level") or args.log_level, ) parser_instance.parse() + def main(): - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(prog="mmparser", description="Парсер/скрапер megamarket.ru", formatter_class=RichHelpFormatter) parser.add_argument("url", nargs="?", type=str, help="URL для парсинга") - parser.add_argument("-job", "--job-name", type=str, help="Название задачи, без этого параметра будет автоопределено") - parser.add_argument("-cfg", "--config", type=str, help="Путь к конфигу парсера") - parser.add_argument("-i", "--include", type=str, help="Парсить только товары, название которых совпадает с выражением") - parser.add_argument("-e", "--exclude", type=str, help="Пропускать товары, название которых совпадает с выражением") - parser.add_argument("-b", "--blacklist", type=str, help="Путь к файлу со списком игнорируемых продавцов") - parser.add_argument("-ac", "--all-cards", action='store_true', help="Всегда парсить карточки товаров") - parser.add_argument("-nc", "--no-cards", action='store_true', help="Не парсить карточки товаров") - parser.add_argument("-c", "--cookies", type=str, help="Путь к файлу с cookies в формате JSON (Cookie-Editor - Export Json)") - parser.add_argument("-aa", "--account-alert", type=str, help="Если вы используйте cookie, и вход в аккаунт не выполнен, присылать уведомление в TG") - parser.add_argument("-a", "--address", type=str, help="Адрес, будет использовано первое сопадение") - parser.add_argument("-p", "--proxy", type=str, help="Строка прокси в формате protocol://username:password@ip:port") - parser.add_argument("-pl", "--proxy-list", type=str, help="Путь к файлу с прокси в формате protocol://username:password@ip:port") - parser.add_argument("-ad", "--allow-direct", action="store_true", help="Использовать прямое соединение параллельно с прокси для ускорения работы в многопотоке") - parser.add_argument("-tc", "--tg-config",type=str, help="Telegram Bot Token и Telegram Chat Id в формате token$id") - parser.add_argument("-pva", "--price-value-alert", type=float, help="Если цена товара равна или ниже данного значения, уведомлять в TG") - parser.add_argument("-pbva", "--price-bonus-value-alert", type=float, help="Если цена-бонусы товара равна или ниже данного значения, уведомлять в TG") - parser.add_argument("-bva", "--bonus-value-alert", type=float, help="Если количество бонусов товара равно или выше данного значения, уведомлять в TG") - parser.add_argument("-bpa", "--bonus-percent-alert", type=float, help="Если процент бонусов товара равно или выше данного значения, уведомлять в TG") - parser.add_argument("-mb", "--use-merchant-blacklist", action="store_true", help="Использовать черный список продавцов с ограничением на списание бонусов") - parser.add_argument("-art", "--alert-repeat-timeout", type=float, help="Если походящий по параметрам товар уже был отправлен в TG, повторно уведомлять по истечении заданного времени, в часах") - parser.add_argument("-t", "--threads", type=int, help="Количество потоков. По умолчанию: 1 на каждое соединиение") - parser.add_argument("-d", "--delay", type=float, help="Задержка между запросами в секундах при работе в одном потоке. По умолчанию: 1.8") - parser.add_argument("-ed", "--error-delay", type=float, help="Задержка между запосами в секундах в случае ошибки при работе в одном потоке. По умолчанию: 5") - parser.add_argument('-log', '--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], default='INFO', help='Уровень лога. По умолчанию: INFO') + parser.add_argument("-job-name", type=str, help="Название задачи, без этого параметра будет автоопределено") + parser.add_argument("-config", type=str, help="Путь к конфигу парсера") + parser.add_argument("-include", type=str, help="Парсить только товары, название которых совпадает с выражением") + parser.add_argument("-exclude", type=str, help="Пропускать товары, название которых совпадает с выражением") + parser.add_argument("-blacklist", type=str, help="Путь к файлу со списком игнорируемых продавцов") + parser.add_argument("-all-cards", action="store_true", help="Всегда парсить карточки товаров") + parser.add_argument("-no-cards", action="store_true", help="Не парсить карточки товаров") + parser.add_argument("-cookies", type=str, help="Путь к файлу с cookies в формате JSON (Cookie-Editor - Export Json)") + parser.add_argument("-account-alert", type=str, help="Если вы используйте cookie, и вход в аккаунт не выполнен, присылать уведомление в TG") + parser.add_argument("-address", type=str, help="Адрес, будет использовано первое сопадение") + parser.add_argument("-proxy", type=str, help="Строка прокси в формате protocol://username:password@ip:port") + parser.add_argument("-proxy-list", type=str, help="Путь к файлу с прокси в формате protocol://username:password@ip:port") + parser.add_argument("-allow-direct", action="store_true", help="Использовать прямое соединение параллельно с прокси для ускорения работы в многопотоке") + parser.add_argument("-tg-config", type=str, help="Telegram Bot Token и Telegram Chat Id в формате token$id") + parser.add_argument("-price-value-alert", type=float, help="Если цена товара равна или ниже данного значения, уведомлять в TG") + parser.add_argument("-price-bonus-value-alert", type=float, help="Если цена-бонусы товара равна или ниже данного значения, уведомлять в TG") + parser.add_argument("-bonus-value-alert", type=float, help="Если количество бонусов товара равно или выше данного значения, уведомлять в TG") + parser.add_argument("-bonus-percent-alert", type=float, help="Если процент бонусов товара равно или выше данного значения, уведомлять в TG") + parser.add_argument("-use-merchant-blacklist", action="store_true", help="Использовать черный список продавцов с ограничением на списание бонусов") + parser.add_argument("-alert-repeat-timeout", type=float, help="Если походящий по параметрам товар уже был отправлен в TG, повторно уведомлять по истечении заданного времени, в часах") + parser.add_argument("-threads", type=int, help="Количество потоков. По умолчанию: 1 на каждое соединиение") + parser.add_argument("-delay", type=float, help="Задержка между запросами в секундах при работе в одном потоке. По умолчанию: 1.8") + parser.add_argument("-error-delay", type=float, help="Задержка между запосами в секундах в случае ошибки при работе в одном потоке. По умолчанию: 5") + parser.add_argument("-log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], default="INFO", help="Уровень лога. По умолчанию: INFO") args = parser.parse_args() print_logo() @@ -74,11 +79,12 @@ def main(): if Path(args.config).exists(): try: config = read_json_file(args.config) - except: - raise Exception('Ошибка чтения конфига!') + except Exception as exc: + raise ConfigError("Ошибка чтения конфига!") from exc run_url_parser(args, config) else: - raise Exception('Файл конфига не найден!') + raise ConfigError("Файл конфига не найден!") + if __name__ == "__main__": main() diff --git a/core/parser_base.py b/core/parser_base.py deleted file mode 100644 index 1a849a3..0000000 --- a/core/parser_base.py +++ /dev/null @@ -1,155 +0,0 @@ -"""base parser class""" - -import logging -from datetime import datetime -from pathlib import Path -import json -from time import sleep, time - -from curl_cffi import requests -from rich.logging import RichHandler -import core.telegram_utils as telegram_utils -from core.utils import proxy_format_check - - -class Parser_base: - def __init__( - self, - cookie_file_path: str = "", - proxy: str = "", - allow_direct: bool = False, - proxy_file_path: str = "", - tg_config: str = "", - delay: float = None, - error_delay: float = None, - log_level: str = "INFO", - ): - self.cookie_file_path = cookie_file_path - self.proxy = proxy - self.allow_direct = allow_direct - self.proxy_file_path = proxy_file_path - self.tg_config = tg_config - self.connection_success_delay = delay or 1.8 - self.connection_error_delay = error_delay or 10.0 - self.log_level = log_level - - self.start_time = datetime.now() - - self.region_id = "50" - self.session = None - self.proxies: list = [] - self.parsed_proxies: list = [] - self.cookie_dict: dict = None - self.profile: dict = {} - self.rich_progress = None - self.job_name: str = "" - - self.logger = None - - self.tg_client = None - - def _set_up(self): - self._logger_setup(self.log_level) - if self.tg_config: - if not telegram_utils.validate_credentials(self.tg_config): - raise Exception(f"Конфиг {self.tg_config} не прошел проверку!") - self.tg_client = telegram_utils.Telegram(self.tg_config, self.logger) - self._read_proxy_file() if self.proxy_file_path else None - self._proxies_set_up() - self._read_cookie_file() if self.cookie_file_path else None - - class Proxy: - def __init__(self, proxy: str | None): - self.proxy_string: str | None = proxy - self.usable_at: int = 0 - self.busy = False - - def _logger_setup(self, log_level): - logging.basicConfig( - level=log_level, - format="%(message)s", - datefmt="%H:%M:%S", - handlers=[RichHandler(rich_tracebacks=True)], - ) - self.logger = logging.getLogger("rich") - - def _read_proxy_file(self): - if Path(self.proxy_file_path).exists(): - proxy_file_contents: str = open(self.proxy_file_path, "r", encoding="utf-8").read() - self.parsed_proxies = [line for line in proxy_file_contents.split("\n") if line] - else: - raise Exception(f"Путь {self.cookie_file_path} не найден!") - - def _read_cookie_file(self): - if Path(self.cookie_file_path).exists(): - cookies = json.loads(open(self.cookie_file_path, "r", encoding="utf-8").read()) - self.cookie_dict = {cookie["name"]: cookie["value"] for cookie in cookies} - else: - raise Exception(f"Путь {self.cookie_file_path} не найден!") - - def _proxies_set_up(self): - if self.proxy: - is_valid_proxy = proxy_format_check(self.proxy) - if not is_valid_proxy: - raise Exception(f"Прокси {self.proxy} не верного формата!") - self.proxies = [self.Proxy(self.proxy)] - elif self.parsed_proxies: - for proxy in self.parsed_proxies: - is_valid_proxy = proxy_format_check(proxy) - if not is_valid_proxy: - raise Exception(f"Прокси {proxy} не верного формата!") - self.proxies = [self.Proxy(proxy) for proxy in self.parsed_proxies] - if self.proxies and self.allow_direct: - self.proxies.append(self.Proxy(None)) - elif not self.proxies: - self.proxies = [self.Proxy(None)] - - def _get_proxy(self) -> str: - while True: - free_proxies = [proxy for proxy in self.proxies if not proxy.busy] - if free_proxies: - oldest_proxy = min(free_proxies, key=lambda obj: obj.usable_at) - current_time = time() - if oldest_proxy.usable_at <= current_time: - return oldest_proxy - else: - sleep(oldest_proxy.usable_at - time()) - return self._get_proxy() - else: - # No free proxies, wait and retry - sleep(1) - - def _api_request(self, api_url: str, json_data: dict, tries: int = 10, delay: float = 0) -> dict: - json_data["auth"] = { - "locationId": self.region_id, - "appPlatform": "WEB", - "appVersion": 1710405202, - "experiments": {}, - "os": "UNKNOWN_OS", - } - for i in range(0, tries): - proxy: self.Proxy = self._get_proxy() - proxy.busy = True - self.logger.debug("Прокси : %s", proxy.proxy_string) - try: - response = requests.post(api_url, json=json_data, proxy=proxy.proxy_string, verify=False, impersonate="chrome") - response_data: dict = response.json() - except: - response = None - if response and response.status_code == 200 and not response_data.get("error"): - proxy.usable_at = time() + delay - proxy.busy = False - return response_data - if response and response.status_code == 200 and response_data.get("code") == 7: - self.logger.debug("Соединение %s: слишком частые запросы", proxy.proxy_string) - proxy.usable_at = time() + self.connection_error_delay - else: - sleep(1 * i) - proxy.busy = False - - raise Exception("Ошибка получения данных api") - - def _get_profile(self): - json_data = {} - response_json = self._api_request("https://megamarket.ru/api/mobile/v1/securityService/profile/get", json_data) - self.profile = response_json["profile"] diff --git a/core/parser_url.py b/core/parser_url.py index 21d75ba..76b58b8 100644 --- a/core/parser_url.py +++ b/core/parser_url.py @@ -1,33 +1,67 @@ -"""url""" +"""mmparser""" +import logging +from datetime import datetime +from time import sleep, time +from dataclasses import dataclass, field +from typing import Optional import threading import concurrent.futures -import datetime import sys import json import signal from pathlib import Path -from urllib.parse import urlparse, parse_qsl, parse_qs, unquote -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeRemainingColumn, -) -from core.utils import validate_regex, slugify, load_blacklist -import core.db as db -from core.parser_base import Parser_base - - -class Parser_url(Parser_base): +from urllib.parse import urlparse, parse_qsl, parse_qs, unquote, urljoin + +from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn +from rich.logging import RichHandler +from curl_cffi import requests + +from .exceptions import ConfigError, ApiError +from . import db_utils, utils +from .telegram import TelegramClient, validate_tg_credentials + + +@dataclass +class ParsedOffer: + title: str + url: str + image_url: str + price: float + price_bonus: int + bonus_amount: int + available_quantity: int + goods_id: str + delivery_date: datetime + merchant_id: str + merchant_name: str + merchant_rating: Optional[bool] = field(default=None) + notified: Optional[bool] = field(default=None) + scraped: Optional[datetime] = field(default=None) + + @property + def bonus_percent(self) -> int: + """Рассичать процент бонусов от цены""" + if self.price == 0: + return 0 + return int((self.bonus_amount / self.price) * 100) + + +class Connection: + def __init__(self, proxy: str | None): + self.proxy_string: str | None = proxy + self.usable_at: int = 0 + self.busy = False + + +class Parser_url: def __init__( self, url: str, job_name: str = "", include: str = "", exclude: str = "", - blacklist:str = "", + blacklist: str = "", all_cards: bool = False, no_cards: bool = False, cookie_file_path: str = "", @@ -48,66 +82,164 @@ def __init__( error_delay: float = None, log_level: str = "INFO", ): - super().__init__( - cookie_file_path, - proxy, - allow_direct, - proxy_file_path, - tg_config, - delay, - error_delay, - log_level, - ) - - self.url = url - self.job_name = job_name - self.include = include - self.exclude = exclude - self.blacklist_path = blacklist - self.all_cards = all_cards - self.no_cards = no_cards - self.address = address + self.cookie_file_path = cookie_file_path self.proxy = proxy - self.account_alert = account_alert - self.use_merchant_blacklist = use_merchant_blacklist - self.merchant_blacklist = load_blacklist() if use_merchant_blacklist else [] - self.price_value_alert = price_value_alert or float("-inf") - self.price_bonus_value_alert = price_bonus_value_alert or float("-inf") - self.bonus_value_alert = bonus_value_alert or float("inf") - self.bonus_percent_alert = bonus_percent_alert or float("inf") - self.alert_repeat_timeout = alert_repeat_timeout or 0 - self.threads = threads - - self.blacklist:list = [] + self.allow_direct = allow_direct + self.proxy_file_path = proxy_file_path + self.tg_config = tg_config + self.connection_success_delay = delay or 1.8 + self.connection_error_delay = error_delay or 10.0 + self.log_level = log_level + + self.start_time: datetime = None + + self.region_id = "50" + self.session = None + self.connections: list[Connection] = [] + self.parsed_proxies: set | None = None + self.cookie_dict: dict = None + self.profile: dict = {} + self.rich_progress = None + self.job_name: str = "" + + self.logger: logging.Logger = self._create_logger(self.log_level) + self.tg_client: TelegramClient = None + + self.url: str = url + self.job_name: str = job_name + self.include: str = include + self.exclude: str = exclude + self.blacklist_path: str = blacklist + self.all_cards: bool = all_cards + self.no_cards: bool = no_cards + self.address: str = address + self.proxy: str = proxy + self.account_alert: bool = account_alert + self.use_merchant_blacklist: bool = use_merchant_blacklist + self.merchant_blacklist: list = utils.load_blacklist() if use_merchant_blacklist else [] + self.price_value_alert: float = price_value_alert or float("-inf") + self.price_bonus_value_alert: float = price_bonus_value_alert or float("-inf") + self.bonus_value_alert: float = bonus_value_alert or float("inf") + self.bonus_percent_alert: float = bonus_percent_alert or float("inf") + self.alert_repeat_timeout: float = alert_repeat_timeout or 0 + self.threads: int = threads + + self.blacklist: list = [] self.parsed_url: dict = None self.scraped_tems_counter: int = 0 self.rich_progress = None self.job_id: int = None - self.address_id = None - - self.items_per_page: int = 44 + self.address_id: str = None self.lock = threading.Lock() self._set_up() - def _set_up(self): + def _create_logger(self, log_level: str) -> logging.Logger: + logging.basicConfig( + level=log_level, + format="%(message)s", + datefmt="%H:%M:%S", + handlers=[RichHandler(rich_tracebacks=True)], + ) + return logging.getLogger("rich") + + def _proxies_set_up(self) -> None: + """Настройка и валидация прокси""" + if self.proxy: + is_valid_proxy = utils.proxy_format_check(self.proxy) + if not is_valid_proxy: + raise ConfigError(f"Прокси {self.proxy} не верного формата!") + self.connections = [Connection(self.proxy)] + elif self.parsed_proxies: + for proxy in self.parsed_proxies: + is_valid_proxy = utils.proxy_format_check(proxy) + if not is_valid_proxy: + raise ConfigError(f"Прокси {proxy} не верного формата!") + self.connections = [Connection(proxy) for proxy in self.parsed_proxies] + if self.connections and self.allow_direct: + self.connections.append(Connection(None)) + elif not self.connections: + self.connections = [Connection(None)] + + def _get_connection(self) -> str: + """Получить самое позднее использованное `Соединение`""" + while True: + free_proxies = [proxy for proxy in self.connections if not proxy.busy] + if free_proxies: + oldest_proxy = min(free_proxies, key=lambda obj: obj.usable_at) + current_time = time() + if oldest_proxy.usable_at <= current_time: + return oldest_proxy + sleep(oldest_proxy.usable_at - time()) + return self._get_connection() + # No free proxies, wait and retry + sleep(1) + + def _api_request(self, api_url: str, json_data: dict, tries: int = 10, delay: float = 0) -> dict: + json_data["auth"] = { + "locationId": self.region_id, + "appPlatform": "WEB", + "appVersion": 1710405202, + "experiments": {}, + "os": "UNKNOWN_OS", + } + for i in range(0, tries): + proxy: Connection = self._get_connection() + proxy.busy = True + self.logger.debug("Прокси : %s", proxy.proxy_string) + try: + response = requests.post(api_url, json=json_data, proxy=proxy.proxy_string, verify=False, impersonate="chrome") + response_data: dict = response.json() + except Exception: + response = None + if response and response.status_code == 200 and not response_data.get("error"): + proxy.usable_at = time() + delay + proxy.busy = False + return response_data + if response and response.status_code == 200 and response_data.get("code") == 7: + self.logger.debug("Соединение %s: слишком частые запросы", proxy.proxy_string) + proxy.usable_at = time() + self.connection_error_delay + else: + sleep(1 * i) + proxy.busy = False + + raise ApiError("Ошибка получения данных api") + + def _get_profile(self) -> None: + """Получить и сохранить информацию профиля ММ""" + response_json = self._api_request("https://megamarket.ru/api/mobile/v1/securityService/profile/get", json_data={}) + self.profile = response_json["profile"] + + def _set_up(self) -> None: + """Парсинг в валидация конфигурации""" + if self.tg_config: + if not validate_tg_credentials(self.tg_config): + raise ConfigError(f"Конфиг {self.tg_config} не прошел проверку!") + self.tg_client = TelegramClient(self.tg_config, self.logger) + self.parsed_proxies = self.proxy_file_path and utils.parse_proxy_file(self.proxy_file_path) + self._proxies_set_up() + self.cookie_dict = self.cookie_file_path and utils.parse_cookie_file(self.cookie_file_path) + # Make Ctrl-C work when deamon threads are running signal.signal(signal.SIGINT, signal.SIG_DFL) - super()._set_up() - regex_check = validate_regex(self.include) if self.include else None + + regex_check = self.include and utils.validate_regex(self.include) if regex_check is False: - raise Exception(f'Неверное выражение "{self.include}"!') - regex_check = validate_regex(self.exclude) if self.exclude else None + raise ConfigError(f'Неверное выражение "{self.include}"!') + regex_check = self.exclude and utils.validate_regex(self.exclude) if regex_check is False: - raise Exception(f'Неверное выражение "{self.exclude}"!') + raise ConfigError(f'Неверное выражение "{self.exclude}"!') if self.blacklist_path: self._read_blacklist_file() - self.threads = self.threads or len(self.proxies) - if not Path(db.FILENAME).exists(): - db.create_db() - - def parse(self): + self.threads = self.threads or len(self.connections) + if not Path(db_utils.FILENAME).exists(): + db_utils.create_db() + + def parse(self) -> None: + """Метод запуска парсинга""" + utils.check_for_new_version() + self.start_time = datetime.now() self.logger.info("Целевой URL: %s", self.url) self.logger.info("Потоков: %s", self.threads) if self.cookie_file_path: @@ -116,81 +248,60 @@ def parse(self): self.logger.info("Аккаунт: %s", self.profile["phone"]) else: self.logger.warning("Вход в аккаунт не выполнен") - if self.account_alert: - self.tg_client.notify( - f"Вход в аккаунт {self.cookie_file_path} не выполнен!" - ) - raise Exception( - f"Вход в аккаунт {self.cookie_file_path} не выполнен!" - ) + if self.account_alert and self.tg_client: + self.tg_client.notify(f"Вход в аккаунт {self.cookie_file_path} не выполнен!") + raise Exception(f"Вход в аккаунт {self.cookie_file_path} не выполнен!") if self.address: - self._get_address_from_string() + self._get_address_from_string(self.address) elif self.profile.get("isAuthenticated"): self._get_profile_default_address() if (not self.address_id or not self.region_id) and self.cookie_dict: - self._get_cookie_address() + self._get_address_info(self.cookie_dict) self.parse_input_url() if self.parsed_url and not self.job_name: search_text = self.parsed_url.get("searchText", {}) - collection_title = (self.parsed_url.get("collection", {}) or {}).get( - "title" - ) + collection_title = (self.parsed_url.get("collection", {}) or {}).get("title") merchant = (self.parsed_url.get("merchant", {}) or {}).get("slug") unknown = "Не_определено" self.job_name = search_text or collection_title or merchant or unknown - self.job_name = slugify(self.job_name) + self.job_name = utils.slugify(self.job_name) if self.parsed_url["type"] == "TYPE_PRODUCT_CARD": self._parse_card() - self.logger.info( - "%s %s", self.job_name, self.start_time.strftime("%d-%m-%Y %H:%M:%S") - ) + self.logger.info("%s %s", self.job_name, self.start_time.strftime("%d-%m-%Y %H:%M:%S")) else: - self.job_id = db.new_job(self.job_name) - self.logger.info( - "%s %s", self.job_name, self.start_time.strftime("%d-%m-%Y %H:%M:%S") - ) - self._parse_all() + self.job_id = db_utils.new_job(self.job_name) + self.logger.info("%s %s", self.job_name, self.start_time.strftime("%d-%m-%Y %H:%M:%S")) + self._parse_multi_page() self.logger.info("Спаршено %s товаров", self.scraped_tems_counter) - db.finish_job(self.job_id) + db_utils.finish_job(self.job_id) def _read_blacklist_file(self): blacklist_file_contents: str = open(self.blacklist_path, "r", encoding="utf-8").read() self.blacklist = [line for line in blacklist_file_contents.split("\n") if line] - def _export_to_db( - self, - goodsId, - merchantId, - url, - title, - finalPrice, - finalPriceBonus, - bonusAmount, - bonusPercent, - availableQuantity, - deliveryPossibilities, - merchantName, - notified, - ): + def _export_to_db(self, parsed_offer: ParsedOffer) -> None: + """Экспорт одного предложения в базу данных""" with self.lock: - db.add_to_db( + db_utils.add_to_db( self.job_id, self.job_name, - goodsId, - merchantId, - url, - title, - finalPrice, - finalPriceBonus, - bonusAmount, - bonusPercent, - availableQuantity, - deliveryPossibilities, - merchantName, - notified, + parsed_offer.goods_id, + parsed_offer.merchant_id, + parsed_offer.url, + parsed_offer.title, + parsed_offer.price, + parsed_offer.price_bonus, + parsed_offer.bonus_amount, + parsed_offer.bonus_percent, + parsed_offer.available_quantity, + parsed_offer.delivery_date, + parsed_offer.merchant_name, + parsed_offer.merchant_rating, + parsed_offer.notified, ) - def parse_input_url(self, tries=10): + def parse_input_url(self, tries: int = 10) -> dict: + """Парсинг url мм с использованием api самого мм""" json_data = {"url": self.url} response_json = self._api_request( "https://megamarket.ru/api/mobile/v1/urlService/url/parse", @@ -200,39 +311,29 @@ def parse_input_url(self, tries=10): parsed_url = response_json["params"] parsed_url = self._filters_convert(parsed_url) parsed_url["type"] = response_json["type"] - sorting = int( - dict(parse_qsl(unquote(urlparse(self.url).fragment.lstrip("?")))).get( - "sort", 0 - ) - ) + sorting = int(dict(parse_qsl(unquote(urlparse(self.url).fragment.lstrip("?")))).get("sort", 0)) search_query_from_url = parse_qs(urlparse(self.url).query).get("q", "") or "" - search_query_from_url = ( - search_query_from_url[0] if search_query_from_url else None - ) + search_query_from_url = search_query_from_url[0] if search_query_from_url else None parsed_url["searchText"] = parsed_url["searchText"] or search_query_from_url parsed_url["sorting"] = sorting self.parsed_url = parsed_url return parsed_url - def _get_profile_default_address(self): + def _get_profile_default_address(self) -> None: + """Получить данные адреса аккаунта мм""" json_data = {} - response_json = self._api_request( - "https://megamarket.ru/api/mobile/v1/profileService/address/list", json_data - ) - address = [ - address - for address in response_json["profileAddresses"] - if address["isDefault"] is True - ] + response_json = self._api_request("https://megamarket.ru/api/mobile/v1/profileService/address/list", json_data) + address = [address for address in response_json["profileAddresses"] if address["isDefault"] is True] if address: self.address_id = address[0]["addressId"] self.region_id = address[0]["regionId"] self.logger.info(f"Регион: {address[0]['region']}") self.logger.info(f"Адрес: {address[0]['full']}") - def _get_cookie_address(self): - address = self.cookie_dict.get("address_info") - region = self.cookie_dict.get("region_info") + def _get_address_info(self, cookie_dict: dict) -> None: + """Получить данные адреса принадлежащие аккаунту из cookies""" + address = cookie_dict.get("address_info") + region = cookie_dict.get("region_info") if region: region = json.loads(unquote(region)) self.region_id = self.region_id or region["id"] @@ -242,30 +343,30 @@ def _get_cookie_address(self): self.address_id = self.address_id or address["addressId"] self.logger.info("Адрес: %s", address["full"]) - def _get_address_from_string(self): - json_data = {"count": 10, "isSkipRegionFilter": True, "query": self.address} + def _get_address_from_string(self, address: str) -> None: + """Установить адрес доставки по строке адреса""" + json_data = {"count": 10, "isSkipRegionFilter": True, "query": address} response_json = self._api_request( "https://megamarket.ru/api/mobile/v1/addressSuggestService/address/suggest", json_data, ) - address = response_json["items"] + address = response_json.get("items") if address: self.address_id = address[0]["addressId"] self.region_id = address[0]["regionId"] self.logger.info("Регион: %s", address[0]["region"]) self.logger.info("Адрес: %s", address[0]["full"]) else: - sys.exit(f"По запросу {self.address} адрес не найден!") + sys.exit(f"По запросу {address} адрес не найден!") - def _get_merchant_inn(self, merchant_id): + def _get_merchant_inn(self, merchant_id: str) -> str: + """Получить ИНН по ID продавца""" json_data = {"merchantId": merchant_id} - response_json = self._api_request( - "https://megamarket.ru/api/mobile/v1/partnerService/merchant/legalInfo/get", - json_data, - ) + response_json = self._api_request("https://megamarket.ru/api/mobile/v1/partnerService/merchant/legalInfo/get", json_data) return response_json["merchant"]["legalInfo"]["inn"] - def _parse_item(self, item): + def _parse_item(self, item: dict): + """Парсинг дефолтного предложения товара""" if item["favoriteOffer"]["merchantName"] in self.blacklist: self.logger.debug("Пропуск %s", item["favoriteOffer"]["merchantName"]) return @@ -276,48 +377,30 @@ def _parse_item(self, item): self.logger.debug("Пропуск %s", item["favoriteOffer"]["merchantName"]) return - delivery_possibilities = set() - for delivery in item["favoriteOffer"]["deliveryPossibilities"]: - delivery_info = f"{delivery['displayName']}, {delivery.get('displayDeliveryDate', '')} - {delivery.get('amount', 0)}р" - delivery_possibilities.add(delivery_info) - delivery_possibilities = (" \n").join(delivery_possibilities) - price_bonus = ( - item["favoriteOffer"]["finalPrice"] - item["favoriteOffer"]["bonusAmount"] - ) self.scraped_tems_counter += 1 - goodsId = item["goods"]["goodsId"].split("_")[0] - - notified = self._notify_if_notify_check( - item["goods"]["title"], - item["favoriteOffer"]["finalPrice"], - price_bonus, - item["favoriteOffer"]["bonusAmount"], - item["favoriteOffer"]["bonusPercent"], - item["goods"]["webUrl"], - item["favoriteOffer"]["merchantName"], - delivery_possibilities, - item["favoriteOffer"]["availableQuantity"], - item["goods"]["titleImage"], - goodsId, - item["favoriteOffer"]["merchantId"], - ) - self._export_to_db( - goodsId, - item["favoriteOffer"]["merchantId"], - item["goods"]["webUrl"], - item["goods"]["title"], - item["favoriteOffer"]["finalPrice"], - price_bonus, - item["favoriteOffer"]["bonusAmount"], - item["favoriteOffer"]["bonusPercent"], - item["favoriteOffer"]["availableQuantity"], - delivery_possibilities, - item["favoriteOffer"]["merchantName"], - notified, + delivery_date_iso: str = item["favoriteOffer"]["deliveryPossibilities"][0].get("displayDeliveryDate", "") + delivery_date = delivery_date_iso.split("T")[0] + + parsed_offer = ParsedOffer( + title=item["goods"]["title"], + price=item["favoriteOffer"]["finalPrice"], + delivery_date=delivery_date, + price_bonus=item["favoriteOffer"]["finalPrice"] - item["favoriteOffer"]["bonusAmount"], + goods_id=item["goods"]["goodsId"].split("_")[0], + bonus_amount=item["favoriteOffer"]["bonusAmount"], + url=item["goods"]["webUrl"], + available_quantity=item["favoriteOffer"]["availableQuantity"], + merchant_id=item["favoriteOffer"]["merchantId"], + merchant_name=item["favoriteOffer"]["merchantName"], + image_url=item["goods"]["titleImage"], ) - def _filters_convert(self, parsed_url): + parsed_offer.notified = self._notify_if_notify_check(parsed_offer) + self._export_to_db(parsed_offer) + + def _filters_convert(self, parsed_url: dict) -> dict: + """Конвертация фильтров каталога или поиска""" for url_filter in parsed_url["selectedListingFilters"]: if url_filter["type"] == "EXACT_VALUE": url_filter["type"] = 0 @@ -327,131 +410,83 @@ def _filters_convert(self, parsed_url): url_filter["type"] = 2 return parsed_url - def _parse_offers(self, item, offers): - for offer in offers: - if offer["merchantName"] in self.blacklist: + def _parse_offer(self, item: dict, offer: dict) -> None: + """Парсинг предложения товара""" + if offer["merchantName"] in self.blacklist: + self.logger.debug("Пропуск %s", offer["merchantName"]) + return + + if self.use_merchant_blacklist: + merchant_inn = self._get_merchant_inn(offer["merchantId"]) + if merchant_inn in self.merchant_blacklist: self.logger.debug("Пропуск %s", offer["merchantName"]) - continue + return - if self.use_merchant_blacklist: - merchant_inn = self._get_merchant_inn(offer["merchantId"]) - if merchant_inn in self.merchant_blacklist: - self.logger.debug("Пропуск %s", offer["merchantName"]) - continue - - delivery_possibilities = set() - for delivery in offer["deliveryPossibilities"]: - delivery_info = f"{delivery['displayName']}, {delivery.get('displayDeliveryDate', '')} - {delivery.get('amount', 0)}р" - delivery_possibilities.add(delivery_info) - delivery_possibilities = (" \n").join(delivery_possibilities) - price_bonus = offer["finalPrice"] - offer["bonusAmountFinalPrice"] - self.scraped_tems_counter += 1 - goodsId = item["goodsId"].split("_")[0] - notified = self._notify_if_notify_check( - item["title"], - offer["finalPrice"], - price_bonus, - offer["bonusAmountFinalPrice"], - offer["bonusPercent"], - item["webUrl"], - offer["merchantName"], - delivery_possibilities, - offer["availableQuantity"], - item["titleImage"], - goodsId, - offer["merchantId"], - ) - self._export_to_db( - goodsId, - offer["merchantId"], - item["webUrl"], - item["title"], - offer["finalPrice"], - price_bonus, - offer["bonusAmountFinalPrice"], - offer["bonusPercent"], - offer["availableQuantity"], - delivery_possibilities, - offer["merchantName"], - notified, - ) + delivery_date_iso: str = offer["deliveryPossibilities"][0]["date"] + delivery_date = delivery_date_iso.split("T")[0] + + # добавление merchantId в конец url + offer_url: str = item["webUrl"] + if offer_url.endswith("/"): + offer_url = offer_url[:-1] + offer_url = f'{offer_url}_{offer["merchantId"]}' + + parsed_offer = ParsedOffer( + delivery_date=delivery_date, + price_bonus=offer["finalPrice"] - offer["bonusAmountFinalPrice"], + goods_id=item["goodsId"].split("_")[0], + title=item["title"], + price=offer["finalPrice"], + bonus_amount=offer["bonusAmountFinalPrice"], + url=offer_url, + available_quantity=offer["availableQuantity"], + merchant_id=offer["merchantId"], + merchant_name=offer["merchantName"], + merchant_rating=offer["merchantSummaryRating"], + image_url=item["titleImage"], + ) - def _notify_if_notify_check( - self, - title, - finalPrice, - price_bonus, - bonusAmount, - bonusPercent, - webUrl, - merchantName, - delivery_possibilities, - availableQuantity, - titleImage, - goodsId, - merchantId, - ): + self.scraped_tems_counter += 1 + parsed_offer.notified = self._notify_if_notify_check(parsed_offer) + self._export_to_db(parsed_offer) + + def _notify_if_notify_check(self, parsed_offer: ParsedOffer): + """Отправить уведомление в tg если предложение подходит по параметрам""" time_diff = 0 last_notified = None if self.alert_repeat_timeout: - last_notified = db.get_last_notified( - goodsId, merchantId, finalPrice, bonusAmount - ) - last_notified = datetime.datetime.strptime(last_notified, "%Y-%m-%d %H:%M:%S") if last_notified else None + last_notified = db_utils.get_last_notified(parsed_offer.goods_id, parsed_offer.merchant_id, parsed_offer.price, parsed_offer.bonus_amount) + last_notified = datetime.strptime(last_notified, "%Y-%m-%d %H:%M:%S") if last_notified else None if last_notified: - now = datetime.datetime.now() + now = datetime.now() time_diff = now - last_notified if ( - ( - bonusPercent >= self.bonus_percent_alert - or bonusAmount >= self.bonus_value_alert - or finalPrice <= self.price_value_alert - or price_bonus <= self.price_bonus_value_alert - ) + (parsed_offer.bonus_percent >= self.bonus_percent_alert or parsed_offer.bonus_amount >= self.bonus_value_alert or parsed_offer.price <= self.price_value_alert or parsed_offer.price_bonus <= self.price_bonus_value_alert) and (not last_notified or (last_notified and time_diff.total_seconds() > self.alert_repeat_timeout * 3600)) and self.tg_client ): with concurrent.futures.ThreadPoolExecutor() as executor: - message = self._tg_message_format( - title, - finalPrice, - price_bonus, - bonusAmount, - bonusPercent, - webUrl, - merchantName, - delivery_possibilities, - availableQuantity, - ) - executor.submit(self.tg_client.notify, message, titleImage) + message = self._format_tg_message(parsed_offer) + executor.submit(self.tg_client.notify, message, parsed_offer.image_url) return True return False - def _tg_message_format( - self, - title, - finalPrice, - price_bonus, - bonusAmount, - bonusPercent, - webUrl, - merchantName, - delivery_possibilities, - availableQuantity, - ): + def _format_tg_message(self, parsed_offer: ParsedOffer) -> str: + """Форматировать данные для отправки в telegram""" return ( - f"🛍 Товар: {title}\n" - f"💰 Цена: {finalPrice}р\n" - f"💸 Цена-Бонусы: {price_bonus}\n" - f"🟢 Бонусы: {bonusAmount}\n" - f"🔢 Процент Бонусов: {bonusPercent}\n" - f"✅ Доступно: {availableQuantity or '?'}\n" - f"📦 Доставка: {delivery_possibilities}\n" - f"🛒 Продавец: {merchantName}\n" + f"🛍 Товар: {parsed_offer.title}\n" + f"💰 Цена: {parsed_offer.price}₽\n" + f"💸 Цена-Бонусы: {parsed_offer.price_bonus}\n" + f"🟢 Бонусы: {parsed_offer.bonus_amount}\n" + f"🔢 Процент Бонусов: {parsed_offer.bonus_percent}\n" + f"✅ Доступно: {parsed_offer.available_quantity or '?'}\n" + f"📦 Доставка: {parsed_offer.delivery_date}\n" + f"🛒 Продавец: {parsed_offer.merchant_name} {parsed_offer.merchant_rating}{'⭐' if parsed_offer.merchant_rating else ''}\n" f"☎️ Аккаунт: {self.profile.get('phone')}" ) - def _get_offers(self, goods_id, delay=0): + def _get_offers(self, goods_id: str, delay: int = 0) -> list[dict]: + """Получить список предложений товара""" json_data = { "addressId": self.address_id, "collectionId": None, @@ -464,21 +499,16 @@ def _get_offers(self, goods_id, delay=0): "requestVersion": 11, "shopInfo": {}, } - response_json = self._api_request( - "https://megamarket.ru/api/mobile/v1/catalogService/productOffers/get", - json_data, - delay=delay, - ) + response_json = self._api_request("https://megamarket.ru/api/mobile/v1/catalogService/productOffers/get", json_data, delay=delay) return response_json["offers"] - def _get_page(self, offset: int): + def _get_page(self, offset: int) -> dict: + """Получить страницу каталога или поиска""" json_data = { "requestVersion": 10, - "limit": self.items_per_page, + "limit": 44, "offset": offset, - "isMultiCategorySearch": self.parsed_url.get( - "isMultiCategorySearch", False - ), + "isMultiCategorySearch": self.parsed_url.get("isMultiCategorySearch", False), "searchByOriginalQuery": False, "selectedSuggestParams": [], "expandedFiltersIds": [], @@ -489,28 +519,11 @@ def _get_page(self, offset: int): "selectedFilters": self.parsed_url.get("selectedListingFilters", []), } if self.parsed_url.get("type", "") == "TYPE_MENU_NODE": - self.parsed_url["collection"] = ( - self.parsed_url["collection"] - or self.parsed_url["menuNode"]["collection"] - ) - json_data["collectionId"] = ( - self.parsed_url["collection"]["collectionId"] - if self.parsed_url["collection"] - else None - ) - json_data["searchText"] = ( - self.parsed_url["searchText"] if self.parsed_url["searchText"] else None - ) - json_data["selectedAssumedCollectionId"] = ( - self.parsed_url["collection"]["collectionId"] - if self.parsed_url["collection"] - else None - ) - json_data["merchant"] = ( - {"id": self.parsed_url["merchant"]["id"]} - if self.parsed_url["merchant"] - else None - ) + self.parsed_url["collection"] = self.parsed_url["collection"] or self.parsed_url["menuNode"]["collection"] + json_data["collectionId"] = self.parsed_url["collection"]["collectionId"] if self.parsed_url["collection"] else None + json_data["searchText"] = self.parsed_url["searchText"] if self.parsed_url["searchText"] else None + json_data["selectedAssumedCollectionId"] = self.parsed_url["collection"]["collectionId"] if self.parsed_url["collection"] else None + json_data["merchant"] = {"id": self.parsed_url["merchant"]["id"]} if self.parsed_url["merchant"] else None response_json = self._api_request( "https://megamarket.ru/api/mobile/v1/catalogService/catalog/search", @@ -518,62 +531,48 @@ def _get_page(self, offset: int): delay=self.connection_success_delay, ) - return response_json - - def _parse_page(self, offset: int) -> tuple[int, int, bool, int]: - response_json = self._get_page(offset) - if response_json.get("error") or not response_json.get("items"): - raise Exception() + if response_json.get("error"): + raise ApiError() if response_json.get("success") is True: - self.items_per_page = int(response_json.get("limit")) - page_progress = self.rich_progress.add_task( - f"[orange]Страница {int(offset/self.items_per_page)+1}" - ) - self.rich_progress.update(page_progress, total=len(response_json["items"])) - self.logger.debug("%s успех", offset) - for item in response_json["items"]: - item_title = item["goods"]["title"] - if self._exclude_check(item_title): - self.rich_progress.update(page_progress, advance=1) - continue - if item["isAvailable"] is True and self._include_check(item_title): - is_listing = self.parsed_url["type"] == "TYPE_LISTING" - if self.all_cards or ( - not self.no_cards - and ( - item["hasOtherOffers"] - or item["offerCount"] > 1 - or is_listing - ) - ): - self.logger.info("Парсим предложения %s", item_title) - offers = self._get_offers( - item["goods"]["goodsId"], - delay=self.connection_success_delay, - ) - self._parse_offers(item["goods"], offers) - else: - self._parse_item(item) - self.rich_progress.update(page_progress, advance=1) + return response_json + + def _parse_page(self, response_json: dict) -> tuple[int, int, bool]: + """Парсинг страницы каталога или поиска""" + items_per_page = int(response_json.get("limit")) + page_progress = self.rich_progress.add_task(f"[orange]Страница {int(int(response_json.get('offset'))/items_per_page)+1}") + self.rich_progress.update(page_progress, total=len(response_json["items"])) + for item in response_json["items"]: + item_title = item["goods"]["title"] + if self._exclude_check(item_title) or (item["isAvailable"] is not True) or (not self._include_check(item_title)): + # пропускаем, если товар не доступен или исключен + self.rich_progress.update(page_progress, advance=1) + continue + is_listing = self.parsed_url["type"] == "TYPE_LISTING" + if self.all_cards or (not self.no_cards and (item["hasOtherOffers"] or item["offerCount"] > 1 or is_listing)): + self.logger.info("Парсим предложения %s", item_title) + offers = self._get_offers(item["goods"]["goodsId"], delay=self.connection_success_delay) + for offer in offers: + self._parse_offer(item["goods"], offer) + else: + self._parse_item(item) + self.rich_progress.update(page_progress, advance=1) self.rich_progress.remove_task(page_progress) - parse_next_page = ( - response_json["items"] and response_json["items"][-1]["isAvailable"] - ) - parsed_page = offset if response_json.get("success") else False - return parsed_page, int(response_json["total"]), parse_next_page + parse_next_page = response_json["items"] and response_json["items"][-1]["isAvailable"] + return parse_next_page - def _exclude_check(self, title): + def _exclude_check(self, title: str) -> bool: if self.exclude: return self.exclude.match(title) return False - def _include_check(self, title): + def _include_check(self, title: str) -> bool: if self.include: return self.include.match(title) return True - def _create_progress_bar(self): + def _create_progress_bar(self) -> None: + """Создание и запуск полосы прогресса""" self.rich_progress = Progress( "{task.description}", SpinnerColumn(), @@ -583,70 +582,61 @@ def _create_progress_bar(self): ) self.rich_progress.start() - def _get_card_info(self, goods_id): + def _get_card_info(self, goods_id: str) -> dict: + """Получить карточку товара""" json_data = {"goodsId": goods_id, "merchantId": "0"} - response_json = self._api_request( - "https://megamarket.ru/api/mobile/v1/catalogService/productCardMainInfo/get", - json_data, - ) + response_json = self._api_request("https://megamarket.ru/api/mobile/v1/catalogService/productCardMainInfo/get", json_data) return response_json["goods"] - def _parse_card(self): - offers = self._get_offers(self.parsed_url["goods"]["goodsId"]) + def _parse_card(self) -> None: + """Парсинг карточки товара""" item = self._get_card_info(self.parsed_url["goods"]["goodsId"]) - self.job_name = slugify(item["title"]) - self.job_id = db.new_job(self.job_name) - self._parse_offers(item, offers) + offers = self._get_offers(self.parsed_url["goods"]["goodsId"]) + self.job_name = utils.slugify(item["title"]) + self.job_id = db_utils.new_job(self.job_name) + for offer in offers: + self._parse_offer(item, offer) - def _parse_first_page(self, start_offset): - parsed_page, total, parse_next_page = self._parse_page(start_offset) - if not isinstance(parsed_page, int): - self.logger.info("Ошибка получения первой страницы!") - return total, parse_next_page + def _process_page(self, offset: int, main_job) -> bool: + """Получение и парсинг страницы каталога или поиска""" + response_json = self._get_page(offset) + parse_next_page = self._parse_page(response_json) + self.rich_progress.update(main_job, advance=1) + return parse_next_page - def _parse_all(self): + def _parse_multi_page(self) -> None: + """Запуск и менеджмент парсинга каталога или поиска""" start_offset = 0 + response_json = self._get_page(start_offset) + if len(response_json["items"]) == 0 and response_json["processor"]["type"] in ("MENU_NODE", "COLLECTION"): + self.logger.debug("Редирект в каталог") + self.url = urljoin("https://megamarket.ru", response_json["processor"]["url"]) + return self.parse() + items_per_page = int(response_json.get("limit")) + item_count_total = int(response_json["total"]) + + pages_to_parse = list(range(start_offset, item_count_total, items_per_page)) self._create_progress_bar() - total, parse_next_page = self._parse_first_page(start_offset) - if not parse_next_page: - return - pages = [ - i - for i in range( - start_offset + self.items_per_page, total, self.items_per_page - ) - if i < total - 1 - ] - - main_job = self.rich_progress.add_task("[green]Общий прогресс") - self.rich_progress.update(main_job, total=len(pages) + 1, advance=1) - max_threads = self.threads or ( - len(pages) if len(pages) < self.threads else self.threads - ) - while pages: - with concurrent.futures.ThreadPoolExecutor( - max_workers=max_threads - ) as executor: - futures = { - executor.submit(self._parse_page, page): page for page in pages - } + main_job = self.rich_progress.add_task("[green]Общий прогресс", total=len(pages_to_parse)) + max_threads = min(len(pages_to_parse), self.threads) + while pages_to_parse: + with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor: + futures = {executor.submit(self._process_page, page, main_job): page for page in pages_to_parse} for future in concurrent.futures.as_completed(futures): try: - parsed_page, _, parse_next_page = future.result() + parse_next_page = future.result() except Exception: - parsed_page = False - if parsed_page: - self.rich_progress.update(main_job, advance=1) - pages.remove(parsed_page) if parsed_page in pages else None - if parse_next_page: - continue + continue + page = futures[future] + if page in pages_to_parse: + pages_to_parse.remove(page) + if not parse_next_page: self.logger.info("Дальше товары не в наличии, их не парсим") for fut in futures: future_page = futures[fut] - if future_page > parsed_page: - pages.remove( - future_page - ) if future_page in pages else None - self.rich_progress.update(main_job, total=len(pages)) + if future_page > page: + if future_page in pages_to_parse: + pages_to_parse.remove(future_page) + self.rich_progress.update(main_job, total=len(pages_to_parse)) fut.cancel() self.rich_progress.stop() diff --git a/core/telegram_utils.py b/core/telegram.py similarity index 71% rename from core/telegram_utils.py rename to core/telegram.py index a495820..58dcd7c 100644 --- a/core/telegram_utils.py +++ b/core/telegram.py @@ -1,6 +1,7 @@ from curl_cffi import requests -def validate_credentials(tg_config): + +def validate_tg_credentials(tg_config: str): def is_valid_token(bot_token): url = f"https://api.telegram.org/bot{bot_token}/getMe" return requests.get(url).ok @@ -8,9 +9,9 @@ def is_valid_token(bot_token): def is_valid_chat_id(bot_token, chat_id): url = f"https://api.telegram.org/bot{bot_token}/getChat?chat_id={chat_id}" return requests.get(url).ok + try: - bot_token = tg_config.split('$')[0] - chat_id = tg_config.split('$')[1] + bot_token, chat_id = tg_config.split("$") except IndexError: return False if not bot_token or not chat_id: @@ -19,22 +20,19 @@ def is_valid_chat_id(bot_token, chat_id): return False return True -class Telegram: + +class TelegramClient: def __init__(self, tg_config, logger): - self.bot_token = tg_config.split('$')[0] - self.chat_id = tg_config.split('$')[1] + self.bot_token = tg_config.split("$")[0] + self.chat_id = tg_config.split("$")[1] self.logger = logger if not self.bot_token or not self.chat_id: raise Exception("Не валидный конфиг Telegram!") - def notify(self, message, image_url = None): + + def notify(self, message, image_url=None): if image_url: base_url = f"https://api.telegram.org/bot{self.bot_token}/sendPhoto" - params = { - 'chat_id': self.chat_id, - 'caption': message, - 'photo': image_url, - 'parse_mode': 'HTML' - } + params = {"chat_id": self.chat_id, "caption": message, "photo": image_url, "parse_mode": "HTML"} try: response = requests.get(base_url, params=params) response.raise_for_status() @@ -43,14 +41,10 @@ def notify(self, message, image_url = None): self.logger.info(f"Ошибка отправки сообщения с картинкой: {e}") else: base_url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage" - params = { - 'chat_id': self.chat_id, - 'text': message, - 'parse_mode': 'HTML' - } + params = {"chat_id": self.chat_id, "text": message, "parse_mode": "HTML"} try: response = requests.get(base_url, params=params) response.raise_for_status() self.logger.info("Сообщение успешно отправлено!") except Exception as e: - self.logger.info(f"Ошибка отправки сообщения: {e}") \ No newline at end of file + self.logger.info(f"Ошибка отправки сообщения: {e}") diff --git a/core/utils.py b/core/utils.py index d0c4b79..f63af1a 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,9 +1,14 @@ -import pathlib +from pathlib import Path import re import json import time +import pkg_resources from curl_cffi import requests from rich.console import Console +from packaging import version + +from . import exceptions + try: from lxml import html except ImportError: @@ -13,7 +18,7 @@ BLACKLIST_URL = "https://megamarket.ru/promo/prodavtsy-s-oghranichieniiem-na-spisaniie-bonusov/" BLACKLIST_FILE = "merchant_blacklist.txt" -UPDATE_INTERVAL = 86400 # 24 hours +UPDATE_INTERVAL = 86400 # 24 часа def print_logo(): @@ -28,38 +33,36 @@ def print_logo(): console.print("[green bold] ❤️ Сказать спасибо автору: https://yoomoney.ru/to/410018051351692") console.print("") -def slugify(text): + +def slugify(text: str) -> str: # Convert to lowercase lowercase_text = text.lower() # Remove non-word characters and replace spaces with hyphens - slug = re.sub(r'[^\w\s-]', '_', lowercase_text).strip().replace(' ', '_') + slug = re.sub(r"[^\w\s-]", "_", lowercase_text).strip().replace(" ", "_") return slug -def proxy_format_check(string): - protocols = ['http', 'socks'] + +def proxy_format_check(string: str) -> bool: + protocols = ("http", "socks") for protocol in protocols: if string.startswith(protocol): return True return False -def validate_regex(pattern): + +def validate_regex(pattern: str) -> bool: try: re.compile(pattern) except re.error: return False return True -def convert_float(string): - try: - return float(string) - except ValueError: - return False -def read_json_file(file_path): +def read_json_file(file_path: str) -> dict | list | bool: try: - with open(file_path, 'r', encoding='utf-8') as file: + with open(file_path, "r", encoding="utf-8") as file: json_data = file.read() json_content = json.loads(json_data) return json_content @@ -67,13 +70,15 @@ def read_json_file(file_path): print(f"Error: {e}") return False -def remove_chars(text): - for ch in ['#', '?', '!', ':', '<', '>', '"', '/', '\\', '|', '*']: + +def remove_chars(text: str) -> str: + for ch in ["#", "?", "!", ":", "<", ">", '"', "/", "\\", "|", "*"]: if ch in text: - text = text.replace(ch, '') + text = text.replace(ch, "") return text -def parse_blacklist_page(): + +def parse_blacklist_page() -> set[str]: response = requests.get(BLACKLIST_URL) html_content = response.text @@ -81,33 +86,82 @@ def parse_blacklist_page(): if _has_lxml: tree = html.fromstring(html_content) - paragraphs = tree.xpath('//p/text()') + paragraphs = tree.xpath("//p/text()") for p in paragraphs: - inn_matches = re.findall(r'ИНН\s*(\d+)', p) + inn_matches = re.findall(r"ИНН\s*(\d+)", p) inns.extend(inn_matches) else: - content_match = re.search(r'

.*?', html_content, re.DOTALL) + content_match = re.search(r"

.*?", html_content, re.DOTALL) if not content_match: raise ValueError("Не удалось найти нужную секцию на странице") relevant_content = content_match.group(0) - inn_matches = re.findall(r'ИНН\s*(\d+)', relevant_content) + inn_matches = re.findall(r"ИНН\s*(\d+)", relevant_content) inns.extend(inn_matches) inns = set(inns) return inns -def load_blacklist(): - blacklist_path = pathlib.Path(BLACKLIST_FILE) +def load_blacklist() -> set[str]: + blacklist_path = Path(BLACKLIST_FILE) if blacklist_path.exists(): file_age = time.time() - blacklist_path.stat().st_mtime if file_age < UPDATE_INTERVAL: - with open(BLACKLIST_FILE, 'r') as f: + with open(BLACKLIST_FILE, "r", encoding="UTF-8") as f: return f.read().splitlines() inns = parse_blacklist_page() - with open(BLACKLIST_FILE, 'w') as f: - f.write('\n'.join(inns)) + with open(BLACKLIST_FILE, "w", encoding="UTF-8") as f: + f.write("\n".join(inns)) return inns + + +def get_current_version(package_name: str) -> str | None: + try: + current_version = pkg_resources.get_distribution(package_name).version + return current_version + except pkg_resources.DistributionNotFound: + return None + + +def get_latest_version(package_name: str) -> str | None: + url = f"https://api.github.com/repos/xob0t/{package_name}/releases/latest" + + try: + response = requests.get(url, impersonate="chrome", timeout=2) + response.raise_for_status() + data = response.json() + return data.get("tag_name", "") + except Exception: + return None + + +def check_for_new_version() -> None: + console = Console() + package_name = "mmparser" + current_version = get_current_version(package_name) + latest_version = get_latest_version(package_name) + + try: + if version.parse(current_version) < version.parse(latest_version): + console.print(f"[orange]Доступна новая версия [bold]{package_name}[/bold]. Текущая: v{current_version}, Последняя: {latest_version}") + else: + console.print(f"[green]Вы используете последнюю версию [bold]{package_name}[/bold] (v{current_version}).") + except Exception: + console.print("[red]Ошибка проверки новой версии.") + + +def parse_proxy_file(path: str) -> set[str]: + if Path(path).exists(): + proxy_file_contents: str = open(path, "r", encoding="utf-8").read() + return set(line for line in proxy_file_contents.split("\n") if line) + raise exceptions.ConfigError(f"Путь {path} не найден!") + + +def parse_cookie_file(path: str) -> dict: + if Path(path).exists(): + cookies: list = json.loads(open(path, "r", encoding="utf-8").read()) + return {cookie["name"]: cookie["value"] for cookie in cookies} + raise exceptions.ConfigError(f"Путь {path} не найден!") diff --git a/setup.py b/setup.py index 3300611..ecdc102 100644 --- a/setup.py +++ b/setup.py @@ -2,15 +2,17 @@ setup( name="mmparser", - version="0.6.6", + version="0.9.5", description="Парсер megamarket.ru", author="xob0t", url="https://github.com/xob0t/mmparser", packages=find_packages(), install_requires=[ "rich", + "rich-argparse", "curl-cffi", "InquirerPy", + "packaging", ], extras_require={ "lxml": ["lxml"],