#!/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 = 3 # Уменьшено с 10 до 3 чтобы не грузить базу и браузер 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 WHERE crawl_status = 'completed') 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 WHERE crawl_status = 'completed') 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 WHERE crawl_status = 'completed') 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 browser_restarts = 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}") # Перезапускаем браузер каждые 1000 отелей (20 пачек) чтобы избежать утечек памяти if processed > 0 and processed % 1000 == 0: logger.info(f"🔄 Перезапуск браузера после {processed} отелей...") await browser.close() browser = await p.chromium.launch(headless=True) browser_restarts += 1 logger.info(f"✅ Браузер перезапущен (рестарт #{browser_restarts})") 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)