#!/usr/bin/env python3 """ Crawler для парсинга сайтов отелей - Главная страница - Внутренние ссылки depth=1 (в пределах домена) - Загрузка в Graphiti через порт 9200 """ import asyncio import json import logging import re import requests from datetime import datetime from typing import List, Dict, Set from urllib.parse import urljoin, urlparse from playwright.async_api import async_playwright, Page from bs4 import BeautifulSoup, Comment # Настройка логирования logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(f'crawler_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # Конфигурация GRAPHITI_UPLOAD_URL = "http://localhost:9200/upload" GROUP_ID = "hotel_spb" MAX_PAGES_PER_SITE = 20 # Ограничение для безопасности PAGE_TIMEOUT = 60000 # 60 секунд на страницу NAVIGATION_TIMEOUT = 45000 # 45 секунд на навигацию class TextCleaner: """Продвинутая очистка HTML и текста""" # Теги для удаления (навигация, реклама, служебное) REMOVE_TAGS = [ 'script', 'style', 'noscript', 'iframe', 'nav', 'header', 'footer', 'aside', 'menu', 'advertisement', 'cookie', 'banner' ] # Классы и ID для удаления (типичные названия для мусора) REMOVE_PATTERNS = [ 'nav', 'menu', 'sidebar', 'footer', 'header', 'cookie', 'banner', 'advertisement', 'popup', 'modal', 'social', 'share', 'widget' ] @staticmethod def clean_html(html: str) -> str: """Глубокая очистка HTML""" soup = BeautifulSoup(html, 'html.parser') # 1. Удаляем комментарии for comment in soup.find_all(string=lambda text: isinstance(text, Comment)): comment.extract() # 2. Удаляем скрипты, стили и другой мусор for tag_name in TextCleaner.REMOVE_TAGS: for tag in soup.find_all(tag_name): tag.decompose() # 3. Удаляем элементы по классам и ID for pattern in TextCleaner.REMOVE_PATTERNS: # По классу for tag in soup.find_all(class_=re.compile(pattern, re.I)): tag.decompose() # По ID for tag in soup.find_all(id=re.compile(pattern, re.I)): tag.decompose() # 4. Извлекаем текст text = soup.get_text(separator=' ', strip=True) # 5. Чистим пробелы и переносы text = re.sub(r'\s+', ' ', text) text = re.sub(r'\n\s*\n', '\n\n', text) return text.strip() @staticmethod def extract_structured_data(html: str) -> Dict: """Извлечь структурированные данные""" soup = BeautifulSoup(html, 'html.parser') text = soup.get_text() data = { 'phones': [], 'emails': [], 'inn': [], 'ogrn': [] } # Телефоны phones = re.findall(r'\+?[78][\s\-]?\(?(\d{3})\)?[\s\-]?(\d{3})[\s\-]?(\d{2})[\s\-]?(\d{2})', text) data['phones'] = list(set([''.join(p) for p in phones]))[:5] # Email emails = re.findall(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text) data['emails'] = list(set(emails))[:5] # ИНН (10 или 12 цифр) inn = re.findall(r'\b\d{10}\b|\b\d{12}\b', text) data['inn'] = list(set(inn))[:3] # ОГРН (13 или 15 цифр) ogrn = re.findall(r'\b\d{13}\b|\b\d{15}\b', text) data['ogrn'] = list(set(ogrn))[:3] return data class WebsiteCrawler: """Crawler для одного сайта отеля""" def __init__(self, hotel_id: str, hotel_name: str, website: str): self.hotel_id = hotel_id self.hotel_name = hotel_name self.website = self.normalize_url(website) self.domain = self.extract_domain(self.website) self.visited_urls: Set[str] = set() self.pages_data: List[Dict] = [] self.cleaner = TextCleaner() @staticmethod def normalize_url(url: str) -> str: """Нормализация URL""" if not url.startswith(('http://', 'https://')): url = 'https://' + url return url.rstrip('/') @staticmethod def extract_domain(url: str) -> str: """Извлечь домен из URL""" parsed = urlparse(url) return parsed.netloc.lower() def is_internal_link(self, url: str) -> bool: """Проверка, что ссылка внутренняя (тот же домен/поддомен)""" try: parsed = urlparse(url) link_domain = parsed.netloc.lower() # Проверяем домен или поддомен return (link_domain == self.domain or link_domain.endswith('.' + self.domain) or self.domain.endswith('.' + link_domain)) except: return False async def extract_page_data(self, page: Page, url: str) -> Dict: """Извлечь данные со страницы с продвинутой очисткой""" try: # Заголовок title = await page.title() # Получаем HTML html = await page.content() # Глубокая очистка через BeautifulSoup cleaned_text = self.cleaner.clean_html(html) # Извлекаем структурированные данные structured_data = self.cleaner.extract_structured_data(html) # Извлекаем ссылки links = await page.evaluate(""" () => { return Array.from(document.querySelectorAll('a[href]')) .map(a => a.href) .filter(href => href && !href.startsWith('mailto:') && !href.startsWith('tel:')); } """) # Проверяем наличие форм has_forms = await page.evaluate("() => document.querySelectorAll('form').length > 0") # Проверяем наличие онлайн-оплаты/бронирования has_booking = await page.evaluate(""" () => { const text = document.body.innerText.toLowerCase(); return text.includes('забронировать') || text.includes('бронирование') || text.includes('booking') || text.includes('оплатить') || text.includes('оплата онлайн'); } """) return { 'url': url, 'title': title, 'text': cleaned_text[:50000], # Ограничиваем размер 'links': list(set(links)), 'has_forms': has_forms, 'has_booking': has_booking, 'structured_data': structured_data, 'text_length': len(cleaned_text) } except Exception as e: logger.error(f"Ошибка извлечения данных с {url}: {e}") return None async def crawl_page(self, page: Page, url: str, depth: int = 0): """Парсинг одной страницы""" if url in self.visited_urls or len(self.visited_urls) >= MAX_PAGES_PER_SITE: return try: logger.info(f" Парсинг (depth={depth}): {url}") # Пробуем разные стратегии загрузки try: await page.goto(url, wait_until='domcontentloaded', timeout=NAVIGATION_TIMEOUT) await page.wait_for_timeout(3000) # Ждём загрузку динамики except Exception as e: logger.warning(f" Попытка загрузки через domcontentloaded не удалась, пробуем load") await page.goto(url, wait_until='load', timeout=NAVIGATION_TIMEOUT) await page.wait_for_timeout(2000) self.visited_urls.add(url) # Извлекаем данные page_data = await self.extract_page_data(page, url) if page_data: self.pages_data.append(page_data) logger.info(f" ✓ Извлечено {page_data['text_length']} символов") # Если depth=0 (главная), парсим внутренние ссылки if depth == 0: internal_links = [ link for link in page_data['links'] if self.is_internal_link(link) and link not in self.visited_urls ] logger.info(f" Найдено {len(internal_links)} внутренних ссылок") # Парсим внутренние ссылки (depth=1) for link in internal_links[:MAX_PAGES_PER_SITE - 1]: if len(self.visited_urls) >= MAX_PAGES_PER_SITE: break await self.crawl_page(page, link, depth=1) except Exception as e: logger.error(f" ✗ Ошибка парсинга {url}: {e}") async def crawl(self) -> List[Dict]: """Запуск парсинга сайта""" logger.info(f"\n{'='*70}") logger.info(f"🏨 Парсинг: {self.hotel_name}") logger.info(f"🌐 Сайт: {self.website}") logger.info(f"📍 Домен: {self.domain}") logger.info(f"{'='*70}") async with async_playwright() as p: browser = await p.chromium.launch(headless=True) page = await browser.new_page() # Парсим главную await self.crawl_page(page, self.website, depth=0) await browser.close() logger.info(f"✓ Спарсено {len(self.pages_data)} страниц") return self.pages_data def upload_to_graphiti(self) -> bool: """Загрузить данные в Graphiti""" if not self.pages_data: logger.warning("Нет данных для загрузки") return False # Собираем весь текст со всех страниц full_content = "" for page_data in self.pages_data: full_content += f"\n\n=== {page_data['title']} ({page_data['url']}) ===\n\n" full_content += page_data['text'] # Метаданные metadata = { 'hotel_id': self.hotel_id, 'hotel_name': self.hotel_name, 'website': self.website, 'domain': self.domain, 'pages_count': len(self.pages_data), 'urls': [p['url'] for p in self.pages_data], 'source': 'hotel_website_crawl' } # Загружаем в Graphiti payload = { 'group_id': GROUP_ID, 'title': f"Сайт: {self.hotel_name}", 'content': full_content[:100000], # Ограничение на размер 'metadata': metadata, 'chunk_size': 800, 'chunk_overlap': 200, 'auto_extract_entities': True, 'auto_create_relations': True } try: logger.info(f"📤 Загрузка в Graphiti (group_id={GROUP_ID})...") response = requests.post( GRAPHITI_UPLOAD_URL, json=payload, timeout=300 # 5 минут для больших сайтов ) if response.status_code == 200: result = response.json() logger.info(f" ✓ Успешно загружено:") logger.info(f" - Чанков: {result['chunks_created']}") logger.info(f" - Эпизодов: {result['episodes_created']}") logger.info(f" - Сущностей: {result['entities_extracted']}") logger.info(f" - Связей: {result['relations_created']}") logger.info(f" - Эмбеддингов: {result['embeddings_generated']}") return True else: logger.error(f" ✗ Ошибка загрузки: {response.status_code}") logger.error(f" {response.text[:200]}") return False except Exception as e: logger.error(f"✗ Ошибка загрузки в Graphiti: {e}") return False async def main(): """Главная функция""" import sys # Выбираем файл с отелями hotels_file = sys.argv[1] if len(sys.argv) > 1 else 'test_hotels_spb.json' # Загружаем список отелей with open(hotels_file, 'r', encoding='utf-8') as f: hotels = json.load(f) logger.info(f"\n{'='*70}") logger.info(f"🚀 ЗАПУСК ТЕСТОВОГО КРАУЛИНГА") logger.info(f"📊 Отелей: {len(hotels)}") logger.info(f"🎯 Group ID: {GROUP_ID}") logger.info(f"{'='*70}\n") success_count = 0 error_count = 0 for idx, hotel in enumerate(hotels, 1): logger.info(f"\n[{idx}/{len(hotels)}] ====================================") try: crawler = WebsiteCrawler( hotel_id=hotel['id'], hotel_name=hotel['name'], website=hotel['website'] ) # Парсим сайт pages = await crawler.crawl() if pages: # Загружаем в Graphiti if crawler.upload_to_graphiti(): success_count += 1 else: error_count += 1 else: error_count += 1 logger.error(f"✗ Не удалось спарсить сайт") # Небольшая задержка между отелями await asyncio.sleep(2) except Exception as e: logger.error(f"✗ Критическая ошибка для отеля {hotel['name']}: {e}") error_count += 1 logger.info(f"\n{'='*70}") logger.info(f"📊 ИТОГИ КРАУЛИНГА:") logger.info(f" ✅ Успешно: {success_count}/{len(hotels)}") logger.info(f" ✗ Ошибки: {error_count}/{len(hotels)}") logger.info(f"{'='*70}\n") if __name__ == "__main__": asyncio.run(main())