Files
hotels/website_crawler.py
Фёдор 0cf3297290 Проект аудита отелей: основные скрипты и документация
- Краулеры: smart_crawler.py, regional_crawler.py
- Аудит: audit_orel_to_excel.py, audit_chukotka_to_excel.py
- РКН проверка: check_rkn_registry.py, recheck_unclear_rkn.py
- Отчёты: create_orel_horizontal_report.py
- Обработка: process_all_hotels_embeddings.py
- Документация: README.md, DB_SCHEMA_REFERENCE.md
2025-10-16 10:52:09 +03:00

395 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())