Проект аудита отелей: основные скрипты и документация
- Краулеры: 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:
527
universal_crawler.py
Normal file
527
universal_crawler.py
Normal file
@@ -0,0 +1,527 @@
|
||||
#!/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())
|
||||
|
||||
Reference in New Issue
Block a user