Files
hotels/universal_crawler.py

528 lines
22 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Универсальный краулер для парсинга сайтов отелей с проверкой РКН
- Парсит сайт отеля (главная + depth 1)
- Сразу проверяет ИНН в реестре Роскомнадзора
- Сохраняет все данные в PostgreSQL
"""
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
# Конфигурация БД
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
RKN_CHECK_DELAY = 2 # Задержка перед проверкой РКН
# Типичные URL для проверки (важные страницы отелей)
TYPICAL_URLS = [
'/pravila', '/rules', '/terms', '/conditions',
'/services', '/uslugi', '/price', '/prices', '/ceny',
'/booking', '/book', '/bronirование', '/reserve',
'/faq', '/contacts', '/kontakty', '/about', '/o-nas',
'/policy', '/politika', '/privacy', '/oferta', '/offer',
'/dogovor', '/contract', '/agreement', '/soglashenie',
'/reviews', '/otzyvy', '/gallery', '/galereya',
'/rooms', '/nomera', '/accommodation', '/razmeshenie'
]
class TextCleaner:
"""Простая очистка HTML"""
@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 = 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)
class UniversalCrawler:
"""Универсальный краулер с проверкой РКН"""
def __init__(self, region_name: str):
self.region_name = region_name
self.visited_urls: Set[str] = set()
self.db_conn = None
self.rkn_page = None
# Настройка логирования
log_filename = f'crawler_{region_name.replace(" ", "_")}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
self.logger = logging.getLogger(f'crawler_{region_name}')
self.logger.setLevel(logging.INFO)
# Хендлеры
fh = logging.FileHandler(log_filename)
ch = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
fh.setFormatter(formatter)
ch.setFormatter(formatter)
self.logger.addHandler(fh)
self.logger.addHandler(ch)
async def connect_db(self):
"""Подключение к БД"""
try:
self.db_conn = psycopg2.connect(**DB_CONFIG)
self.logger.info("✓ Подключено к PostgreSQL")
# Добавляем колонки для РКН (если их нет)
cur = self.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;')
self.db_conn.commit()
cur.close()
except Exception as e:
self.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'}
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}'
self.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'}
await asyncio.sleep(1)
# Получаем текст
text = await self.rkn_page.evaluate('() => document.body.innerText')
# Проверяем результаты
if 'Не найдено' in text or 'не найдено' in text.lower():
self.logger.info(f"РКН: не найден")
return {'found': False, 'status': 'not_found'}
# Извлекаем данные (разные форматы: 41-14-000746 или 10-0107355)
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:
self.logger.info(f"РКН: найден {reg_number} ({reg_date})")
return {
'found': True,
'status': 'found',
'reg_number': reg_number,
'reg_date': reg_date
}
else:
self.logger.info(f" ⚠️ РКН: результат неясен")
return {'found': None, 'status': 'unclear'}
except Exception as e:
self.logger.error(f"РКН: ошибка {e}")
return {'found': False, 'status': 'error'}
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:
self.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:
self.logger.info(f" Парсинг (depth={depth}): {url[:60]}...")
response = await page.goto(url, timeout=PAGE_TIMEOUT, wait_until='networkidle')
if not response or response.status >= 400:
self.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)
# Получаем заголовок
title = await page.title()
# Получаем Last-Modified из заголовков
last_modified = response.headers.get('last-modified', None)
# Сохраняем в БД
await self.save_to_db(hotel_id, url, title, html, clean_text, response.status, depth, last_modified)
self.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
}
except Exception as e:
self.logger.error(f" ✗ Ошибка парсинга: {e}")
return {'success': False, 'error': str(e)}
async def check_typical_urls(self, page: Page, base_url: str) -> List[str]:
"""Проверяет типичные URL и возвращает существующие"""
found_urls = []
parsed_base = urlparse(base_url)
base_domain = f"{parsed_base.scheme}://{parsed_base.netloc}"
self.logger.info(f" 🔍 Проверка типичных URL...")
for typical_path in TYPICAL_URLS:
typical_url = base_domain + typical_path
# Пропускаем если уже посетили
if typical_url in self.visited_urls:
continue
try:
# Пробуем загрузить страницу (быстро, timeout=5сек)
response = await page.goto(typical_url, timeout=5000, wait_until='domcontentloaded')
if response and response.status == 200:
found_urls.append(typical_url)
self.logger.info(f" ✓ Найден: {typical_path}")
except Exception:
# Страница не существует или недоступна - это нормально
pass
self.logger.info(f" Найдено {len(found_urls)} типичных страниц")
return found_urls
async def find_internal_links(self, page: Page, base_url: str) -> List[str]:
"""Поиск внутренних ссылок"""
try:
links = await page.evaluate('() => Array.from(document.querySelectorAll("a[href]")).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]
self.logger.info(f" Найдено {len(internal_links)} внутренних ссылок")
return internal_links
except Exception as e:
self.logger.error(f" ✗ Ошибка поиска ссылок: {e}")
return []
async def save_to_db(self, hotel_id: str, url: str, title: str, html: str,
clean_text: str, status_code: int, depth: int, last_modified: str = None):
"""Сохранение данных в БД"""
try:
cur = self.db_conn.cursor()
# Проверяем есть ли уже эта страница
cur.execute('''
SELECT id FROM hotel_website_raw
WHERE hotel_id = %s AND url = %s
''', (hotel_id, url))
if cur.fetchone():
# Страница уже есть - пропускаем
cur.close()
return
# Парсим last_modified в datetime если есть
last_modified_dt = None
if last_modified:
try:
from email.utils import parsedate_to_datetime
last_modified_dt = parsedate_to_datetime(last_modified)
except Exception as e:
self.logger.warning(f" ⚠️ Не удалось распарсить Last-Modified: {e}")
# Сохраняем сырые данные
cur.execute('''
INSERT INTO hotel_website_raw
(hotel_id, url, page_title, html, status_code, response_time_ms, depth, crawled_at, last_modified)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
''', (hotel_id, url, title, html, status_code, 0, depth, datetime.now(), last_modified_dt))
# Сохраняем метаданные
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, url, 1, 0, len(clean_text), 0,
'completed', datetime.now(), datetime.now(),
len(clean_text), datetime.now()
))
self.db_conn.commit()
cur.close()
except Exception as e:
self.logger.error(f" ✗ Ошибка сохранения в БД: {e}")
self.db_conn.rollback()
async def crawl_hotel(self, hotel_data: Dict, browser) -> 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')
self.logger.info(f"\n{'='*70}")
self.logger.info(f"🏨 {hotel_name}")
self.logger.info(f"🌐 {website_url or 'Нет сайта'}")
if owner_inn:
self.logger.info(f"🔢 ИНН: {owner_inn}")
self.logger.info(f"{'='*70}")
if not website_url or website_url in ['-', 'Нет сайта', '']:
self.logger.info(" ⏭️ Пропуск - нет сайта")
return {'success': False, 'reason': 'no_website'}
# Нормализуем URL
if not website_url.startswith(('http://', 'https://')):
website_url = 'https://' + website_url
try:
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'
})
# 1. Краулинг главной страницы
result = await self.crawl_page(page, website_url, hotel_id, depth=0)
if not result['success']:
await page.close()
return result
# 2. Проверка типичных URL (правила, цены, контакты и т.д.)
typical_urls = await self.check_typical_urls(page, website_url)
# 3. Проверка в реестре РКН (если есть ИНН и сайт доступен)
if owner_inn:
rkn_result = await self.check_rkn_registry(owner_inn, browser)
self.save_rkn_result(hotel_id, rkn_result)
# 4. Краулинг типичных страниц
pages_crawled = 1
for typical_url in typical_urls:
if typical_url not in self.visited_urls:
self.visited_urls.add(typical_url)
await self.crawl_page(page, typical_url, hotel_id, depth=1)
pages_crawled += 1
# 5. Краулинг остальных внутренних страниц (если есть место)
internal_links = result.get('internal_links', [])
remaining_slots = MAX_PAGES_PER_SITE - pages_crawled
for link in internal_links[:remaining_slots]:
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 page.close()
self.logger.info(f"✓ Спарсено {pages_crawled} страниц")
return {'success': True, 'pages_crawled': pages_crawled}
except Exception as e:
self.logger.error(f"✗ Ошибка краулинга: {e}")
return {'success': False, 'error': str(e)}
async def main():
"""Основная функция"""
import sys
if len(sys.argv) < 2:
print("Использование: python universal_crawler.py <регион>")
print("Пример: python universal_crawler.py 'Камчатский край'")
sys.exit(1)
region_name = sys.argv[1]
crawler = UniversalCrawler(region_name)
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 %s
AND website_address IS NOT NULL
AND website_address != '-'
AND website_address != ''
ORDER BY full_name
''', (f'%{region_name}%',))
hotels = [{'id': row[0], 'full_name': row[1], 'website_address': row[2], 'owner_inn': row[3]}
for row in cur.fetchall()]
cur.close()
crawler.logger.info(f"\n{'='*70}")
crawler.logger.info(f"🚀 ЗАПУСК КРАУЛИНГА: {region_name}")
crawler.logger.info(f"📊 Отелей с сайтами: {len(hotels)}")
crawler.logger.info(f"⏱️ Примерное время: {len(hotels) * (5 + RKN_CHECK_DELAY) / 60:.1f} минут")
crawler.logger.info(f"{'='*70}")
# Открываем браузер один раз для всех отелей
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
# Краулинг отелей
successful = 0
failed = 0
rkn_found = 0
rkn_not_found = 0
for i, hotel in enumerate(hotels, 1):
crawler.logger.info(f"\n[{i}/{len(hotels)}] {'='*35}")
result = await crawler.crawl_hotel(hotel, browser)
if result['success']:
successful += 1
else:
failed += 1
await browser.close()
# Подсчитываем результаты РКН
cur = crawler.db_conn.cursor()
cur.execute('''
SELECT
COUNT(CASE WHEN rkn_registry_status = 'found' THEN 1 END) as found,
COUNT(CASE WHEN rkn_registry_status = 'not_found' THEN 1 END) as not_found,
COUNT(CASE WHEN rkn_registry_status = 'unclear' THEN 1 END) as unclear
FROM hotel_main
WHERE region_name ILIKE %s
''', (f'%{region_name}%',))
rkn_stats = cur.fetchone()
cur.close()
# Итоги
crawler.logger.info(f"\n{'='*70}")
crawler.logger.info("📊 ИТОГИ КРАУЛИНГА:")
crawler.logger.info(f" ✅ Успешно: {successful}/{len(hotels)}")
crawler.logger.info(f" ✗ Ошибки: {failed}/{len(hotels)}")
crawler.logger.info(f"\n📋 ИТОГИ ПРОВЕРКИ РКН:")
crawler.logger.info(f" ✅ Найдено в реестре: {rkn_stats[0]}")
crawler.logger.info(f"Не найдено: {rkn_stats[1]}")
crawler.logger.info(f" ❓ Неясно: {rkn_stats[2]}")
crawler.logger.info(f"{'='*70}")
except Exception as e:
crawler.logger.error(f"❌ Критическая ошибка: {e}")
finally:
crawler.close_db()
if __name__ == "__main__":
asyncio.run(main())