467 lines
19 KiB
Python
467 lines
19 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
УМНЫЙ КРАУЛЕР С ПРИОРИТЕТАМИ
|
|||
|
|
1. Сначала добивает почти готовые регионы (70%+)
|
|||
|
|
2. Потом крупные регионы
|
|||
|
|
3. Помечает битые сайты и не трогает их повторно
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import psycopg2
|
|||
|
|
from psycopg2.extras import Json
|
|||
|
|
from urllib.parse import unquote, urlparse
|
|||
|
|
from playwright.async_api import async_playwright
|
|||
|
|
from bs4 import BeautifulSoup
|
|||
|
|
import re
|
|||
|
|
import logging
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Set, List, Dict
|
|||
|
|
import sys
|
|||
|
|
|
|||
|
|
# Конфигурация БД
|
|||
|
|
DB_CONFIG = {
|
|||
|
|
'host': "147.45.189.234",
|
|||
|
|
'port': 5432,
|
|||
|
|
'database': "default_db",
|
|||
|
|
'user': "gen_user",
|
|||
|
|
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Настройки краулинга
|
|||
|
|
MAX_PAGES_PER_SITE = 15
|
|||
|
|
PAGE_TIMEOUT = 30000
|
|||
|
|
BATCH_SIZE = 50
|
|||
|
|
MAX_CONCURRENT = 10 # Увеличено с 3 до 10 для ускорения
|
|||
|
|
MAX_RETRIES = 2 # Максимум попыток для одного сайта
|
|||
|
|
|
|||
|
|
# Логирование
|
|||
|
|
log_filename = f'smart_crawler_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|||
|
|
handlers=[
|
|||
|
|
logging.FileHandler(log_filename),
|
|||
|
|
logging.StreamHandler()
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TextCleaner:
|
|||
|
|
"""Очистка HTML"""
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def clean_html(cls, html: str) -> str:
|
|||
|
|
if not html:
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|||
|
|
for tag in soup.find_all(['script', 'style', 'noscript']):
|
|||
|
|
tag.decompose()
|
|||
|
|
|
|||
|
|
text = soup.get_text()
|
|||
|
|
text = re.sub(r'\s+', ' ', text)
|
|||
|
|
text = re.sub(r'\n\s*\n', '\n', text)
|
|||
|
|
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
|||
|
|
|
|||
|
|
return '\n'.join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_hotels_by_priority() -> List[Dict]:
|
|||
|
|
"""
|
|||
|
|
Получить отели по приоритетам:
|
|||
|
|
1. Почти готовые регионы (70%+, осталось <100)
|
|||
|
|
2. Средние регионы (50-70%)
|
|||
|
|
3. Крупные регионы (>500 отелей)
|
|||
|
|
4. Остальные
|
|||
|
|
"""
|
|||
|
|
conn = psycopg2.connect(**DB_CONFIG)
|
|||
|
|
cur = conn.cursor()
|
|||
|
|
|
|||
|
|
# Приоритет 1: Добить почти готовые
|
|||
|
|
logger.info("🎯 Приоритет 1: Почти готовые регионы (70%+)...")
|
|||
|
|
|
|||
|
|
cur.execute("""
|
|||
|
|
WITH stats AS (
|
|||
|
|
SELECT
|
|||
|
|
m.region_name,
|
|||
|
|
COUNT(DISTINCT m.id) as total,
|
|||
|
|
COUNT(DISTINCT meta.hotel_id) as crawled,
|
|||
|
|
ROUND(COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m.id) * 100, 1) as percent
|
|||
|
|
FROM hotel_main m
|
|||
|
|
LEFT JOIN hotel_website_meta meta ON m.id = meta.hotel_id
|
|||
|
|
WHERE m.website_address IS NOT NULL
|
|||
|
|
AND m.website_address != ''
|
|||
|
|
GROUP BY m.region_name
|
|||
|
|
HAVING COUNT(DISTINCT m.id) - COUNT(DISTINCT meta.hotel_id) > 0
|
|||
|
|
AND ROUND(COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m.id) * 100, 1) >= 70
|
|||
|
|
AND COUNT(DISTINCT m.id) - COUNT(DISTINCT meta.hotel_id) < 100
|
|||
|
|
)
|
|||
|
|
SELECT m.id, m.full_name, m.region_name, m.website_address
|
|||
|
|
FROM hotel_main m
|
|||
|
|
INNER JOIN stats s ON m.region_name = s.region_name
|
|||
|
|
WHERE m.website_address IS NOT NULL
|
|||
|
|
AND m.website_address != ''
|
|||
|
|
AND m.id NOT IN (SELECT hotel_id FROM hotel_website_meta)
|
|||
|
|
ORDER BY s.percent DESC, m.region_name, m.full_name
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
priority1 = cur.fetchall()
|
|||
|
|
logger.info(f" Найдено: {len(priority1)} отелей")
|
|||
|
|
|
|||
|
|
# Приоритет 2: Крупные регионы с частичной обработкой
|
|||
|
|
logger.info("🎯 Приоритет 2: Крупные регионы (Москва, Краснодар, Крым)...")
|
|||
|
|
|
|||
|
|
cur.execute("""
|
|||
|
|
SELECT m.id, m.full_name, m.region_name, m.website_address
|
|||
|
|
FROM hotel_main m
|
|||
|
|
WHERE m.website_address IS NOT NULL
|
|||
|
|
AND m.website_address != ''
|
|||
|
|
AND m.id NOT IN (SELECT hotel_id FROM hotel_website_meta)
|
|||
|
|
AND m.region_name IN (
|
|||
|
|
'Краснодарский край',
|
|||
|
|
'г. Москва',
|
|||
|
|
'Республика Крым',
|
|||
|
|
'Московская область'
|
|||
|
|
)
|
|||
|
|
ORDER BY
|
|||
|
|
CASE m.region_name
|
|||
|
|
WHEN 'г. Москва' THEN 1
|
|||
|
|
WHEN 'г. Санкт-Петербург' THEN 2
|
|||
|
|
WHEN 'Краснодарский край' THEN 3
|
|||
|
|
WHEN 'Московская область' THEN 4
|
|||
|
|
WHEN 'Республика Крым' THEN 5
|
|||
|
|
END,
|
|||
|
|
m.full_name
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
priority2 = cur.fetchall()
|
|||
|
|
logger.info(f" Найдено: {len(priority2)} отелей")
|
|||
|
|
|
|||
|
|
# Приоритет 3: Все остальные
|
|||
|
|
logger.info("🎯 Приоритет 3: Остальные регионы...")
|
|||
|
|
|
|||
|
|
cur.execute("""
|
|||
|
|
SELECT m.id, m.full_name, m.region_name, m.website_address
|
|||
|
|
FROM hotel_main m
|
|||
|
|
WHERE m.website_address IS NOT NULL
|
|||
|
|
AND m.website_address != ''
|
|||
|
|
AND m.id NOT IN (SELECT hotel_id FROM hotel_website_meta)
|
|||
|
|
AND m.region_name NOT IN (
|
|||
|
|
SELECT DISTINCT region_name
|
|||
|
|
FROM (
|
|||
|
|
SELECT
|
|||
|
|
m2.region_name,
|
|||
|
|
COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m2.id) * 100 as percent
|
|||
|
|
FROM hotel_main m2
|
|||
|
|
LEFT JOIN hotel_website_meta meta ON m2.id = meta.hotel_id
|
|||
|
|
WHERE m2.website_address IS NOT NULL AND m2.website_address != ''
|
|||
|
|
GROUP BY m2.region_name
|
|||
|
|
HAVING COUNT(DISTINCT m2.id) - COUNT(DISTINCT meta.hotel_id) > 0
|
|||
|
|
AND COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m2.id) * 100 >= 70
|
|||
|
|
) sub
|
|||
|
|
)
|
|||
|
|
AND m.region_name NOT IN (
|
|||
|
|
'Краснодарский край', 'г. Москва', 'Республика Крым', 'Московская область'
|
|||
|
|
)
|
|||
|
|
ORDER BY m.region_name, m.full_name
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
priority3 = cur.fetchall()
|
|||
|
|
logger.info(f" Найдено: {len(priority3)} отелей")
|
|||
|
|
|
|||
|
|
cur.close()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
# Объединяем в правильном порядке
|
|||
|
|
all_hotels = []
|
|||
|
|
for row in priority1 + priority2 + priority3:
|
|||
|
|
all_hotels.append({
|
|||
|
|
'id': row[0],
|
|||
|
|
'full_name': row[1],
|
|||
|
|
'region_name': row[2],
|
|||
|
|
'website_address': row[3]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
logger.info(f"\n📊 ИТОГО ОТЕЛЕЙ ДЛЯ КРАУЛИНГА: {len(all_hotels)}")
|
|||
|
|
logger.info(f" Приоритет 1: {len(priority1)}")
|
|||
|
|
logger.info(f" Приоритет 2: {len(priority2)}")
|
|||
|
|
logger.info(f" Приоритет 3: {len(priority3)}")
|
|||
|
|
|
|||
|
|
return all_hotels
|
|||
|
|
|
|||
|
|
|
|||
|
|
def mark_as_failed(hotel_id: str, error_type: str, error_message: str):
|
|||
|
|
"""Помечает отель как проблемный (не пытаться снова)"""
|
|||
|
|
try:
|
|||
|
|
conn = psycopg2.connect(**DB_CONFIG)
|
|||
|
|
cur = conn.cursor()
|
|||
|
|
|
|||
|
|
# Записываем в meta со статусом failed
|
|||
|
|
cur.execute("""
|
|||
|
|
INSERT INTO hotel_website_meta (
|
|||
|
|
hotel_id,
|
|||
|
|
domain,
|
|||
|
|
main_url,
|
|||
|
|
pages_crawled,
|
|||
|
|
crawl_status,
|
|||
|
|
error_message,
|
|||
|
|
crawl_finished_at
|
|||
|
|
)
|
|||
|
|
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|||
|
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
|||
|
|
crawl_status = EXCLUDED.crawl_status,
|
|||
|
|
error_message = EXCLUDED.error_message,
|
|||
|
|
crawl_finished_at = EXCLUDED.crawl_finished_at
|
|||
|
|
""", (hotel_id, error_type, '', 0, 'failed', error_message))
|
|||
|
|
|
|||
|
|
conn.commit()
|
|||
|
|
cur.close()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
logger.info(f" 🔴 Помечен как failed: {error_type}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f" ❌ Ошибка пометки failed: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def crawl_hotel(hotel: Dict, semaphore: asyncio.Semaphore, browser):
|
|||
|
|
"""Краулинг одного отеля с обработкой ошибок"""
|
|||
|
|
async with semaphore:
|
|||
|
|
hotel_id = hotel['id']
|
|||
|
|
hotel_name = hotel['full_name']
|
|||
|
|
website = hotel['website_address']
|
|||
|
|
region = hotel['region_name']
|
|||
|
|
|
|||
|
|
logger.info(f"🏨 {hotel_name[:50]} ({region})")
|
|||
|
|
logger.info(f" URL: {website}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Нормализация URL
|
|||
|
|
if not website.startswith(('http://', 'https://')):
|
|||
|
|
website = 'https://' + website
|
|||
|
|
|
|||
|
|
context = await browser.new_context(
|
|||
|
|
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|||
|
|
viewport={'width': 1920, 'height': 1080},
|
|||
|
|
ignore_https_errors=True # Игнорируем SSL ошибки
|
|||
|
|
)
|
|||
|
|
page = await context.new_page()
|
|||
|
|
|
|||
|
|
visited_urls = set()
|
|||
|
|
pages_data = []
|
|||
|
|
|
|||
|
|
# Главная страница
|
|||
|
|
try:
|
|||
|
|
response = await page.goto(website, wait_until='domcontentloaded', timeout=PAGE_TIMEOUT)
|
|||
|
|
|
|||
|
|
if response and response.ok:
|
|||
|
|
await page.wait_for_timeout(2000)
|
|||
|
|
|
|||
|
|
html = await page.content()
|
|||
|
|
cleaned_text = TextCleaner.clean_html(html)
|
|||
|
|
|
|||
|
|
pages_data.append({
|
|||
|
|
'url': page.url,
|
|||
|
|
'html': html,
|
|||
|
|
'text': cleaned_text,
|
|||
|
|
'status': response.status
|
|||
|
|
})
|
|||
|
|
visited_urls.add(page.url)
|
|||
|
|
|
|||
|
|
logger.info(f" ✅ Главная: {len(cleaned_text)} символов")
|
|||
|
|
|
|||
|
|
# Собираем ссылки
|
|||
|
|
links = await page.eval_on_selector_all(
|
|||
|
|
'a[href]',
|
|||
|
|
'''elements => elements.map(e => e.href).filter(h => h && h.startsWith('http'))'''
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Фильтруем внутренние ссылки
|
|||
|
|
base_domain = urlparse(website).netloc
|
|||
|
|
internal_links = [
|
|||
|
|
link for link in links
|
|||
|
|
if urlparse(link).netloc == base_domain and link not in visited_urls
|
|||
|
|
][:MAX_PAGES_PER_SITE - 1]
|
|||
|
|
|
|||
|
|
logger.info(f" 📄 Найдено {len(internal_links)} внутренних ссылок")
|
|||
|
|
|
|||
|
|
# Обходим внутренние страницы
|
|||
|
|
for link in internal_links:
|
|||
|
|
if len(pages_data) >= MAX_PAGES_PER_SITE:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
response = await page.goto(link, wait_until='domcontentloaded', timeout=PAGE_TIMEOUT)
|
|||
|
|
|
|||
|
|
if response and response.ok:
|
|||
|
|
await page.wait_for_timeout(1000)
|
|||
|
|
|
|||
|
|
html = await page.content()
|
|||
|
|
cleaned_text = TextCleaner.clean_html(html)
|
|||
|
|
|
|||
|
|
pages_data.append({
|
|||
|
|
'url': page.url,
|
|||
|
|
'html': html,
|
|||
|
|
'text': cleaned_text,
|
|||
|
|
'status': response.status
|
|||
|
|
})
|
|||
|
|
visited_urls.add(page.url)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
# Игнорируем ошибки отдельных страниц
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
else:
|
|||
|
|
error_msg = f"HTTP {response.status}" if response else "No response"
|
|||
|
|
logger.warning(f" ⚠️ Главная недоступна: {error_msg}")
|
|||
|
|
mark_as_failed(hotel_id, 'http_error', error_msg)
|
|||
|
|
await context.close()
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
error_str = str(e)
|
|||
|
|
|
|||
|
|
# Определяем тип ошибки
|
|||
|
|
if 'ERR_NAME_NOT_RESOLVED' in error_str:
|
|||
|
|
error_type = 'dns_error'
|
|||
|
|
logger.warning(f" 🔴 DNS ошибка (сайт не существует)")
|
|||
|
|
elif 'ERR_CERT' in error_str or 'SSL' in error_str:
|
|||
|
|
error_type = 'ssl_error'
|
|||
|
|
logger.warning(f" 🔴 SSL ошибка")
|
|||
|
|
elif 'ERR_CONNECTION_REFUSED' in error_str:
|
|||
|
|
error_type = 'connection_refused'
|
|||
|
|
logger.warning(f" 🔴 Подключение отклонено")
|
|||
|
|
elif 'Timeout' in error_str or 'timeout' in error_str:
|
|||
|
|
error_type = 'timeout'
|
|||
|
|
logger.warning(f" 🔴 Таймаут")
|
|||
|
|
else:
|
|||
|
|
error_type = 'other_error'
|
|||
|
|
logger.warning(f" ⚠️ Другая ошибка: {error_str[:100]}")
|
|||
|
|
|
|||
|
|
# Помечаем как failed
|
|||
|
|
mark_as_failed(hotel_id, error_type, error_str[:500])
|
|||
|
|
await context.close()
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
await context.close()
|
|||
|
|
|
|||
|
|
# Сохраняем в БД
|
|||
|
|
if pages_data:
|
|||
|
|
save_to_db(hotel_id, hotel_name, region, website, pages_data)
|
|||
|
|
logger.info(f" 💾 Сохранено {len(pages_data)} страниц")
|
|||
|
|
return True
|
|||
|
|
else:
|
|||
|
|
mark_as_failed(hotel_id, 'no_content', 'Нет контента')
|
|||
|
|
logger.warning(f" ⚠️ Нет данных")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f" ❌ Критическая ошибка: {e}")
|
|||
|
|
mark_as_failed(hotel_id, 'critical_error', str(e)[:500])
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def save_to_db(hotel_id: str, hotel_name: str, region: str, website: str, pages_data: List[Dict]):
|
|||
|
|
"""Сохранение в PostgreSQL"""
|
|||
|
|
try:
|
|||
|
|
conn = psycopg2.connect(**DB_CONFIG)
|
|||
|
|
cur = conn.cursor()
|
|||
|
|
|
|||
|
|
# Сохраняем метаданные
|
|||
|
|
domain = urlparse(website).netloc
|
|||
|
|
|
|||
|
|
cur.execute("""
|
|||
|
|
INSERT INTO hotel_website_meta (hotel_id, domain, main_url, pages_crawled, crawl_status, crawl_finished_at)
|
|||
|
|
VALUES (%s, %s, %s, %s, %s, NOW())
|
|||
|
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
|||
|
|
pages_crawled = EXCLUDED.pages_crawled,
|
|||
|
|
crawl_status = EXCLUDED.crawl_status,
|
|||
|
|
crawl_finished_at = EXCLUDED.crawl_finished_at
|
|||
|
|
""", (hotel_id, domain, website, len(pages_data), 'completed'))
|
|||
|
|
|
|||
|
|
# Сохраняем сырой HTML
|
|||
|
|
for page in pages_data:
|
|||
|
|
cur.execute("""
|
|||
|
|
INSERT INTO hotel_website_raw (hotel_id, url, html, status_code, crawled_at)
|
|||
|
|
VALUES (%s, %s, %s, %s, NOW())
|
|||
|
|
ON CONFLICT (hotel_id, url) DO UPDATE SET
|
|||
|
|
html = EXCLUDED.html,
|
|||
|
|
status_code = EXCLUDED.status_code,
|
|||
|
|
crawled_at = EXCLUDED.crawled_at
|
|||
|
|
""", (hotel_id, page['url'], page['html'], page['status']))
|
|||
|
|
|
|||
|
|
# Сохраняем очищенный текст
|
|||
|
|
for page in pages_data:
|
|||
|
|
cur.execute("""
|
|||
|
|
INSERT INTO hotel_website_processed (hotel_id, url, cleaned_text, processed_at)
|
|||
|
|
VALUES (%s, %s, %s, NOW())
|
|||
|
|
ON CONFLICT (hotel_id, url) DO UPDATE SET
|
|||
|
|
cleaned_text = EXCLUDED.cleaned_text,
|
|||
|
|
processed_at = EXCLUDED.processed_at
|
|||
|
|
""", (hotel_id, page['url'], page['text']))
|
|||
|
|
|
|||
|
|
conn.commit()
|
|||
|
|
cur.close()
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка сохранения в БД: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def main():
|
|||
|
|
"""Главная функция"""
|
|||
|
|
logger.info("🚀 Запуск умного краулера с приоритетами")
|
|||
|
|
|
|||
|
|
# Получаем отели по приоритетам
|
|||
|
|
hotels = get_hotels_by_priority()
|
|||
|
|
total = len(hotels)
|
|||
|
|
|
|||
|
|
logger.info(f"\n📊 Найдено необработанных отелей: {total}")
|
|||
|
|
|
|||
|
|
if total == 0:
|
|||
|
|
logger.info("✅ Все отели уже обработаны!")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Запускаем краулинг
|
|||
|
|
async with async_playwright() as p:
|
|||
|
|
browser = await p.chromium.launch(headless=True)
|
|||
|
|
|
|||
|
|
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
|
|||
|
|
|
|||
|
|
processed = 0
|
|||
|
|
success = 0
|
|||
|
|
|
|||
|
|
# Обрабатываем пачками
|
|||
|
|
for i in range(0, total, BATCH_SIZE):
|
|||
|
|
batch = hotels[i:i + BATCH_SIZE]
|
|||
|
|
|
|||
|
|
logger.info(f"\n📦 Пачка {i//BATCH_SIZE + 1}/{(total + BATCH_SIZE - 1)//BATCH_SIZE}")
|
|||
|
|
logger.info(f" Отели {i+1}-{min(i+BATCH_SIZE, total)} из {total}")
|
|||
|
|
|
|||
|
|
tasks = [crawl_hotel(hotel, semaphore, browser) for hotel in batch]
|
|||
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|||
|
|
|
|||
|
|
batch_success = sum(1 for r in results if r is True)
|
|||
|
|
success += batch_success
|
|||
|
|
processed += len(batch)
|
|||
|
|
|
|||
|
|
logger.info(f"✅ Пачка: {batch_success}/{len(batch)} успешно")
|
|||
|
|
logger.info(f"📊 Прогресс: {processed}/{total} ({processed*100//total}%)")
|
|||
|
|
|
|||
|
|
await browser.close()
|
|||
|
|
|
|||
|
|
logger.info(f"\n🎉 КРАУЛИНГ ЗАВЕРШЁН!")
|
|||
|
|
logger.info(f" Обработано: {processed}")
|
|||
|
|
logger.info(f" Успешно: {success} ({success*100//processed if processed > 0 else 0}%)")
|
|||
|
|
logger.info(f" Ошибок: {processed - success}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
try:
|
|||
|
|
asyncio.run(main())
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
logger.info("\n⚠️ Прервано пользователем")
|
|||
|
|
sys.exit(0)
|
|||
|
|
|