Проект аудита отелей: основные скрипты и документация
- Краулеры: 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
This commit is contained in:
530
kamchatka_crawler.py
Normal file
530
kamchatka_crawler.py
Normal file
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crawler для парсинга сайтов отелей Камчатского края с сохранением в PostgreSQL
|
||||
- Сохраняет сырой HTML (для будущей переобработки)
|
||||
- Сохраняет очищенный текст
|
||||
- Извлекает структурированные данные
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Set, Optional
|
||||
from urllib.parse import urljoin, urlparse, unquote
|
||||
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'kamchatka_crawler_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Конфигурация БД
|
||||
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 = 20
|
||||
PAGE_TIMEOUT = 45000
|
||||
NAVIGATION_TIMEOUT = 40000
|
||||
GROUP_ID = "hotel_kamchatka" # Для Graphiti
|
||||
RKN_CHECK_DELAY = 2 # Задержка перед проверкой РКН (секунды)
|
||||
|
||||
|
||||
class TextCleaner:
|
||||
"""Продвинутая очистка HTML с сохранением важных данных"""
|
||||
|
||||
# Теги для удаления (только мусор!)
|
||||
REMOVE_TAGS = {
|
||||
'script', 'style', 'meta', 'link', 'noscript', 'iframe', 'embed', 'object',
|
||||
'form', 'input', 'button', 'select', 'textarea', 'label',
|
||||
'canvas', 'svg', 'img', 'video', 'audio', 'source', 'track',
|
||||
'map', 'area', 'base', 'head', 'title'
|
||||
}
|
||||
|
||||
# Теги для сохранения контента (но удаления тега)
|
||||
PRESERVE_CONTENT_TAGS = {
|
||||
'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'a', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'del',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'tr', 'td', 'th',
|
||||
'blockquote', 'pre', 'code', 'br', 'hr'
|
||||
}
|
||||
|
||||
# Ключевые слова для сохранения контента
|
||||
CONTACT_KEYWORDS = {
|
||||
'телефон', 'phone', 'тел', 'контакт', 'contact', 'адрес', 'address',
|
||||
'email', 'почта', 'mail', 'факс', 'fax', 'инн', 'огрн', 'inn', 'ogrn',
|
||||
'режим работы', 'часы работы', 'working hours', 'время работы'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def clean_html(cls, html: str) -> str:
|
||||
"""Простая очистка HTML"""
|
||||
if not html:
|
||||
return ""
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Удаляем скрипты и стили
|
||||
for tag in soup.find_all(['script', 'style']):
|
||||
tag.decompose()
|
||||
|
||||
# Получаем чистый текст
|
||||
text = soup.get_text()
|
||||
|
||||
# Очистка текста
|
||||
text = cls._clean_text(text)
|
||||
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _clean_text(cls, text: str) -> str:
|
||||
"""Дополнительная очистка текста"""
|
||||
# Удаляем лишние пробелы и переносы
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def extract_structured_data(cls, text: str) -> Dict[str, List[str]]:
|
||||
"""Извлечение структурированных данных из текста"""
|
||||
data = {
|
||||
'phones': [],
|
||||
'emails': [],
|
||||
'inns': [],
|
||||
'ogrn': []
|
||||
}
|
||||
|
||||
# Телефоны
|
||||
phone_patterns = [
|
||||
r'\+?[78][\s\-\(\)]?\d{3}[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{2}[\s\-\(\)]?\d{2}',
|
||||
r'\+?7[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{2}[\s\-\(\)]?\d{2}',
|
||||
r'\d{3}[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{2}[\s\-\(\)]?\d{2}'
|
||||
]
|
||||
|
||||
for pattern in phone_patterns:
|
||||
matches = re.findall(pattern, text)
|
||||
data['phones'].extend(matches)
|
||||
|
||||
# Email
|
||||
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
||||
data['emails'] = re.findall(email_pattern, text)
|
||||
|
||||
# ИНН
|
||||
inn_pattern = r'\b\d{10,12}\b'
|
||||
inns = re.findall(inn_pattern, text)
|
||||
data['inns'] = [inn for inn in inns if len(inn) in [10, 12]]
|
||||
|
||||
# ОГРН
|
||||
ogrn_pattern = r'\b\d{13,15}\b'
|
||||
ogrns = re.findall(ogrn_pattern, text)
|
||||
data['ogrn'] = [ogrn for ogrn in ogrns if len(ogrn) in [13, 15]]
|
||||
|
||||
# Удаляем дубликаты
|
||||
for key in data:
|
||||
data[key] = list(set(data[key]))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class WebsiteCrawler:
|
||||
"""Краулер для сайтов отелей"""
|
||||
|
||||
def __init__(self):
|
||||
self.visited_urls: Set[str] = set()
|
||||
self.db_conn = None
|
||||
self.rkn_page = None # Отдельная страница для проверки РКН
|
||||
|
||||
async def connect_db(self):
|
||||
"""Подключение к БД"""
|
||||
try:
|
||||
self.db_conn = psycopg2.connect(**DB_CONFIG)
|
||||
logger.info(" ✓ Подключено к PostgreSQL")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка подключения к БД: {e}")
|
||||
raise
|
||||
|
||||
def close_db(self):
|
||||
"""Закрытие соединения с БД"""
|
||||
if self.db_conn:
|
||||
self.db_conn.close()
|
||||
|
||||
async def check_rkn_registry(self, inn: str, browser) -> Dict:
|
||||
"""Проверка ИНН в реестре РКН"""
|
||||
if not inn or inn == '-':
|
||||
return {
|
||||
'found': False,
|
||||
'status': 'no_inn',
|
||||
'message': 'ИНН не указан'
|
||||
}
|
||||
|
||||
try:
|
||||
# Создаем отдельную страницу для РКН
|
||||
if not self.rkn_page:
|
||||
self.rkn_page = await browser.new_page()
|
||||
await self.rkn_page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
await self.rkn_page.set_extra_http_headers({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
url = f'https://pd.rkn.gov.ru/operators-registry/operators-list/?act=search&inn={inn}'
|
||||
|
||||
logger.info(f" 🔍 РКН: проверка ИНН {inn}")
|
||||
|
||||
# Задержка перед запросом
|
||||
await asyncio.sleep(RKN_CHECK_DELAY)
|
||||
|
||||
# Загружаем страницу
|
||||
response = await self.rkn_page.goto(url, timeout=30000, wait_until='networkidle')
|
||||
|
||||
if response.status != 200:
|
||||
return {'found': False, 'status': 'error', 'message': f'HTTP {response.status}'}
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Получаем текст
|
||||
text = await self.rkn_page.evaluate('() => document.body.innerText')
|
||||
|
||||
# Проверяем результаты
|
||||
if 'Не найдено' in text or 'не найдено' in text.lower():
|
||||
logger.info(f" ❌ РКН: не найден")
|
||||
return {'found': False, 'status': 'not_found', 'message': 'Не найден в реестре'}
|
||||
|
||||
# Извлекаем данные
|
||||
reg_number_match = re.search(r'(\d{2}-\d{2,4}-\d{6,7})', text)
|
||||
reg_number = reg_number_match.group(1) if reg_number_match else None
|
||||
|
||||
date_match = re.search(r'Приказ.*?(\d{2}\.\d{2}\.\d{4})', text)
|
||||
reg_date = date_match.group(1) if date_match else None
|
||||
|
||||
if reg_number:
|
||||
logger.info(f" ✅ РКН: найден {reg_number} ({reg_date})")
|
||||
return {
|
||||
'found': True,
|
||||
'status': 'found',
|
||||
'reg_number': reg_number,
|
||||
'reg_date': reg_date
|
||||
}
|
||||
else:
|
||||
logger.info(f" ⚠️ РКН: результат неясен")
|
||||
return {'found': None, 'status': 'unclear', 'message': 'Результат неясен'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ РКН: ошибка {e}")
|
||||
return {'found': False, 'status': 'error', 'message': str(e)}
|
||||
|
||||
def save_rkn_result(self, hotel_id: str, result: Dict):
|
||||
"""Сохранение результата проверки РКН в БД"""
|
||||
try:
|
||||
cur = self.db_conn.cursor()
|
||||
|
||||
cur.execute('''
|
||||
UPDATE hotel_main
|
||||
SET
|
||||
rkn_registry_status = %s,
|
||||
rkn_registry_number = %s,
|
||||
rkn_registry_date = %s,
|
||||
rkn_checked_at = %s
|
||||
WHERE id = %s
|
||||
''', (
|
||||
result['status'],
|
||||
result.get('reg_number'),
|
||||
result.get('reg_date'),
|
||||
datetime.now(),
|
||||
hotel_id
|
||||
))
|
||||
|
||||
self.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка сохранения РКН: {e}")
|
||||
self.db_conn.rollback()
|
||||
|
||||
async def crawl_page(self, page: Page, url: str, hotel_id: str, depth: int = 0) -> Dict:
|
||||
"""Краулинг одной страницы"""
|
||||
try:
|
||||
logger.info(f" Парсинг (depth={depth}): {url} ...")
|
||||
|
||||
# Переходим на страницу
|
||||
response = await page.goto(url, timeout=PAGE_TIMEOUT, wait_until='networkidle')
|
||||
|
||||
if not response or response.status >= 400:
|
||||
logger.warning(f" ✗ Ошибка загрузки: {response.status if response else 'No response'}")
|
||||
return {'success': False, 'status_code': response.status if response else 0}
|
||||
|
||||
# Получаем HTML
|
||||
html = await page.content()
|
||||
|
||||
# Очищаем HTML
|
||||
clean_text = TextCleaner.clean_html(html)
|
||||
|
||||
# Извлекаем структурированные данные
|
||||
structured_data = TextCleaner.extract_structured_data(clean_text)
|
||||
|
||||
# Получаем заголовок
|
||||
title = await page.title()
|
||||
|
||||
# Сохраняем в БД
|
||||
await self.save_to_db(
|
||||
hotel_id=hotel_id,
|
||||
url=url,
|
||||
title=title,
|
||||
html=html,
|
||||
clean_text=clean_text,
|
||||
structured_data=structured_data,
|
||||
status_code=response.status,
|
||||
depth=depth
|
||||
)
|
||||
|
||||
logger.info(f" ✓ Сохранено {len(clean_text)} символов в БД")
|
||||
|
||||
# Ищем внутренние ссылки
|
||||
internal_links = await self.find_internal_links(page, url)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'status_code': response.status,
|
||||
'internal_links': internal_links,
|
||||
'text_length': len(clean_text)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка парсинга {url}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def find_internal_links(self, page: Page, base_url: str) -> List[str]:
|
||||
"""Поиск внутренних ссылок"""
|
||||
try:
|
||||
# Получаем все ссылки
|
||||
links = await page.evaluate('''
|
||||
() => {
|
||||
const links = Array.from(document.querySelectorAll('a[href]'));
|
||||
return links.map(link => link.href);
|
||||
}
|
||||
''')
|
||||
|
||||
# Фильтруем внутренние ссылки
|
||||
base_domain = urlparse(base_url).netloc
|
||||
internal_links = []
|
||||
|
||||
for link in links:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
if parsed.netloc == base_domain and link not in self.visited_urls:
|
||||
internal_links.append(link)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Ограничиваем количество ссылок
|
||||
internal_links = internal_links[:MAX_PAGES_PER_SITE]
|
||||
|
||||
logger.info(f" Найдено {len(internal_links)} внутренних ссылок")
|
||||
return internal_links
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка поиска ссылок: {e}")
|
||||
return []
|
||||
|
||||
async def save_to_db(self, hotel_id: str, url: str, title: str, html: str,
|
||||
clean_text: str, structured_data: Dict, status_code: int, depth: int):
|
||||
"""Сохранение данных в БД"""
|
||||
try:
|
||||
cur = self.db_conn.cursor()
|
||||
|
||||
# Сохраняем сырые данные
|
||||
cur.execute('''
|
||||
INSERT INTO hotel_website_raw
|
||||
(hotel_id, url, page_title, html, status_code, response_time_ms, depth, crawled_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
''', (
|
||||
hotel_id, url, title, html, status_code, 0, depth, datetime.now()
|
||||
))
|
||||
|
||||
# Сохраняем метаданные (используем правильную структуру таблицы)
|
||||
cur.execute('''
|
||||
INSERT INTO hotel_website_meta
|
||||
(hotel_id, domain, main_url, pages_crawled, pages_failed, total_size_bytes,
|
||||
internal_links_found, crawl_status, crawl_started_at, crawl_finished_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||
pages_crawled = hotel_website_meta.pages_crawled + 1,
|
||||
total_size_bytes = hotel_website_meta.total_size_bytes + %s,
|
||||
crawl_finished_at = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
''', (
|
||||
hotel_id,
|
||||
urlparse(url).netloc, # domain
|
||||
url, # main_url
|
||||
1, # pages_crawled
|
||||
0, # pages_failed
|
||||
len(clean_text), # total_size_bytes
|
||||
0, # internal_links_found (будет обновлено позже)
|
||||
'completed', # crawl_status
|
||||
datetime.now(), # crawl_started_at
|
||||
datetime.now(), # crawl_finished_at
|
||||
len(clean_text), # для ON CONFLICT
|
||||
datetime.now() # для ON CONFLICT
|
||||
))
|
||||
|
||||
self.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка сохранения в БД: {e}")
|
||||
self.db_conn.rollback()
|
||||
|
||||
async def crawl_hotel(self, hotel_data: Dict) -> Dict:
|
||||
"""Краулинг одного отеля"""
|
||||
hotel_id = hotel_data['id']
|
||||
hotel_name = hotel_data['full_name']
|
||||
website_url = hotel_data.get('website_address')
|
||||
owner_inn = hotel_data.get('owner_inn')
|
||||
|
||||
logger.info(f"\n{'='*70}")
|
||||
logger.info(f"🏨 «{hotel_name}»")
|
||||
logger.info(f"🌐 {website_url or 'Нет сайта'}")
|
||||
if owner_inn:
|
||||
logger.info(f"🔢 ИНН: {owner_inn}")
|
||||
logger.info(f"{'='*70}")
|
||||
|
||||
if not website_url or website_url in ['-', 'Нет сайта', '']:
|
||||
logger.info(" ⏭️ Пропуск - нет сайта")
|
||||
return {'success': False, 'reason': 'no_website'}
|
||||
|
||||
# Нормализуем URL
|
||||
if not website_url.startswith(('http://', 'https://')):
|
||||
website_url = 'https://' + website_url
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
# Настройки страницы
|
||||
await page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
await page.set_extra_http_headers({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
# Краулинг главной страницы
|
||||
result = await self.crawl_page(page, website_url, hotel_id, depth=0)
|
||||
|
||||
# Проверяем в реестре РКН (если есть ИНН)
|
||||
if owner_inn and result['success']:
|
||||
rkn_result = await self.check_rkn_registry(owner_inn, browser)
|
||||
self.save_rkn_result(hotel_id, rkn_result)
|
||||
|
||||
if not result['success']:
|
||||
await browser.close()
|
||||
return result
|
||||
|
||||
# Краулинг внутренних страниц
|
||||
internal_links = result.get('internal_links', [])
|
||||
pages_crawled = 1
|
||||
|
||||
for link in internal_links[:MAX_PAGES_PER_SITE]:
|
||||
if link not in self.visited_urls:
|
||||
self.visited_urls.add(link)
|
||||
await self.crawl_page(page, link, hotel_id, depth=1)
|
||||
pages_crawled += 1
|
||||
|
||||
await browser.close()
|
||||
|
||||
logger.info(f"✓ Спарсено {pages_crawled} страниц")
|
||||
return {'success': True, 'pages_crawled': pages_crawled}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Ошибка краулинга: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Основная функция"""
|
||||
logger.info("")
|
||||
logger.info("="*70)
|
||||
logger.info("🚀 ЗАПУСК КРАУЛИНГА КАМЧАТСКИХ ОТЕЛЕЙ С СОХРАНЕНИЕМ В POSTGRESQL")
|
||||
logger.info("="*70)
|
||||
|
||||
crawler = WebsiteCrawler()
|
||||
|
||||
try:
|
||||
# Подключаемся к БД
|
||||
await crawler.connect_db()
|
||||
|
||||
# Получаем камчатские отели с сайтами
|
||||
cur = crawler.db_conn.cursor()
|
||||
cur.execute('''
|
||||
SELECT id, full_name, website_address, owner_inn
|
||||
FROM hotel_main
|
||||
WHERE region_name ILIKE '%камчат%'
|
||||
AND website_address IS NOT NULL
|
||||
AND website_address != '-'
|
||||
AND website_address != ''
|
||||
ORDER BY full_name
|
||||
''')
|
||||
|
||||
hotels = [{'id': row[0], 'full_name': row[1], 'website_address': row[2], 'owner_inn': row[3]} for row in cur.fetchall()]
|
||||
cur.close()
|
||||
|
||||
# Добавляем колонки для РКН (если их нет)
|
||||
cur = crawler.db_conn.cursor()
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_status VARCHAR(50);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_number VARCHAR(50);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_date VARCHAR(20);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_checked_at TIMESTAMP;')
|
||||
crawler.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"📊 Отелей: {len(hotels)}")
|
||||
logger.info(f"💾 Таблицы: hotel_website_raw, hotel_website_meta")
|
||||
logger.info("="*70)
|
||||
|
||||
# Краулинг отелей
|
||||
successful = 0
|
||||
failed = 0
|
||||
|
||||
for i, hotel in enumerate(hotels, 1):
|
||||
logger.info(f"\n[{i}/{len(hotels)}] {'='*35}")
|
||||
|
||||
result = await crawler.crawl_hotel(hotel)
|
||||
|
||||
if result['success']:
|
||||
successful += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Итоги
|
||||
logger.info(f"\n{'='*70}")
|
||||
logger.info("📊 ИТОГИ:")
|
||||
logger.info(f" ✅ Успешно: {successful}/{len(hotels)}")
|
||||
logger.info(f" ✗ Ошибки: {failed}/{len(hotels)}")
|
||||
logger.info("="*70)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка: {e}")
|
||||
finally:
|
||||
crawler.close_db()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user