395 lines
15 KiB
Python
395 lines
15 KiB
Python
|
|
#!/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())
|
|||
|
|
|