Files
hotels/smart_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

467 lines
19 KiB
Python
Executable File
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
"""
УМНЫЙ КРАУЛЕР С ПРИОРИТЕТАМИ
1. Сначала добивает почти готовые регионы (70%+)
2. Потом крупные регионы
3. Помечает битые сайты и не трогает их повторно
"""
import asyncio
import psycopg2
from psycopg2.extras import Json
from urllib.parse import unquote, urlparse
from playwright.async_api import async_playwright
from bs4 import BeautifulSoup
import re
import logging
from datetime import datetime
from typing import Set, List, Dict
import sys
# Конфигурация БД
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 = 15
PAGE_TIMEOUT = 30000
BATCH_SIZE = 50
MAX_CONCURRENT = 10 # Увеличено с 3 до 10 для ускорения
MAX_RETRIES = 2 # Максимум попыток для одного сайта
# Логирование
log_filename = f'smart_crawler_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_filename),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class TextCleaner:
"""Очистка HTML"""
@classmethod
def clean_html(cls, html: str) -> str:
if not html:
return ""
soup = BeautifulSoup(html, 'html.parser')
for tag in soup.find_all(['script', 'style', 'noscript']):
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)
def get_hotels_by_priority() -> List[Dict]:
"""
Получить отели по приоритетам:
1. Почти готовые регионы (70%+, осталось <100)
2. Средние регионы (50-70%)
3. Крупные регионы (>500 отелей)
4. Остальные
"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Приоритет 1: Добить почти готовые
logger.info("🎯 Приоритет 1: Почти готовые регионы (70%+)...")
cur.execute("""
WITH stats AS (
SELECT
m.region_name,
COUNT(DISTINCT m.id) as total,
COUNT(DISTINCT meta.hotel_id) as crawled,
ROUND(COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m.id) * 100, 1) as percent
FROM hotel_main m
LEFT JOIN hotel_website_meta meta ON m.id = meta.hotel_id
WHERE m.website_address IS NOT NULL
AND m.website_address != ''
GROUP BY m.region_name
HAVING COUNT(DISTINCT m.id) - COUNT(DISTINCT meta.hotel_id) > 0
AND ROUND(COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m.id) * 100, 1) >= 70
AND COUNT(DISTINCT m.id) - COUNT(DISTINCT meta.hotel_id) < 100
)
SELECT m.id, m.full_name, m.region_name, m.website_address
FROM hotel_main m
INNER JOIN stats s ON m.region_name = s.region_name
WHERE m.website_address IS NOT NULL
AND m.website_address != ''
AND m.id NOT IN (SELECT hotel_id FROM hotel_website_meta)
ORDER BY s.percent DESC, m.region_name, m.full_name
""")
priority1 = cur.fetchall()
logger.info(f" Найдено: {len(priority1)} отелей")
# Приоритет 2: Крупные регионы с частичной обработкой
logger.info("🎯 Приоритет 2: Крупные регионы (Москва, Краснодар, Крым)...")
cur.execute("""
SELECT m.id, m.full_name, m.region_name, m.website_address
FROM hotel_main m
WHERE m.website_address IS NOT NULL
AND m.website_address != ''
AND m.id NOT IN (SELECT hotel_id FROM hotel_website_meta)
AND m.region_name IN (
'Краснодарский край',
'г. Москва',
'Республика Крым',
'Московская область'
)
ORDER BY
CASE m.region_name
WHEN 'г. Москва' THEN 1
WHEN 'г. Санкт-Петербург' THEN 2
WHEN 'Краснодарский край' THEN 3
WHEN 'Московская область' THEN 4
WHEN 'Республика Крым' THEN 5
END,
m.full_name
""")
priority2 = cur.fetchall()
logger.info(f" Найдено: {len(priority2)} отелей")
# Приоритет 3: Все остальные
logger.info("🎯 Приоритет 3: Остальные регионы...")
cur.execute("""
SELECT m.id, m.full_name, m.region_name, m.website_address
FROM hotel_main m
WHERE m.website_address IS NOT NULL
AND m.website_address != ''
AND m.id NOT IN (SELECT hotel_id FROM hotel_website_meta)
AND m.region_name NOT IN (
SELECT DISTINCT region_name
FROM (
SELECT
m2.region_name,
COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m2.id) * 100 as percent
FROM hotel_main m2
LEFT JOIN hotel_website_meta meta ON m2.id = meta.hotel_id
WHERE m2.website_address IS NOT NULL AND m2.website_address != ''
GROUP BY m2.region_name
HAVING COUNT(DISTINCT m2.id) - COUNT(DISTINCT meta.hotel_id) > 0
AND COUNT(DISTINCT meta.hotel_id)::numeric / COUNT(DISTINCT m2.id) * 100 >= 70
) sub
)
AND m.region_name NOT IN (
'Краснодарский край', 'г. Москва', 'Республика Крым', 'Московская область'
)
ORDER BY m.region_name, m.full_name
""")
priority3 = cur.fetchall()
logger.info(f" Найдено: {len(priority3)} отелей")
cur.close()
conn.close()
# Объединяем в правильном порядке
all_hotels = []
for row in priority1 + priority2 + priority3:
all_hotels.append({
'id': row[0],
'full_name': row[1],
'region_name': row[2],
'website_address': row[3]
})
logger.info(f"\n📊 ИТОГО ОТЕЛЕЙ ДЛЯ КРАУЛИНГА: {len(all_hotels)}")
logger.info(f" Приоритет 1: {len(priority1)}")
logger.info(f" Приоритет 2: {len(priority2)}")
logger.info(f" Приоритет 3: {len(priority3)}")
return all_hotels
def mark_as_failed(hotel_id: str, error_type: str, error_message: str):
"""Помечает отель как проблемный (не пытаться снова)"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Записываем в meta со статусом failed
cur.execute("""
INSERT INTO hotel_website_meta (
hotel_id,
domain,
main_url,
pages_crawled,
crawl_status,
error_message,
crawl_finished_at
)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (hotel_id) DO UPDATE SET
crawl_status = EXCLUDED.crawl_status,
error_message = EXCLUDED.error_message,
crawl_finished_at = EXCLUDED.crawl_finished_at
""", (hotel_id, error_type, '', 0, 'failed', error_message))
conn.commit()
cur.close()
conn.close()
logger.info(f" 🔴 Помечен как failed: {error_type}")
except Exception as e:
logger.error(f" ❌ Ошибка пометки failed: {e}")
async def crawl_hotel(hotel: Dict, semaphore: asyncio.Semaphore, browser):
"""Краулинг одного отеля с обработкой ошибок"""
async with semaphore:
hotel_id = hotel['id']
hotel_name = hotel['full_name']
website = hotel['website_address']
region = hotel['region_name']
logger.info(f"🏨 {hotel_name[:50]} ({region})")
logger.info(f" URL: {website}")
try:
# Нормализация URL
if not website.startswith(('http://', 'https://')):
website = 'https://' + website
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
viewport={'width': 1920, 'height': 1080},
ignore_https_errors=True # Игнорируем SSL ошибки
)
page = await context.new_page()
visited_urls = set()
pages_data = []
# Главная страница
try:
response = await page.goto(website, wait_until='domcontentloaded', timeout=PAGE_TIMEOUT)
if response and response.ok:
await page.wait_for_timeout(2000)
html = await page.content()
cleaned_text = TextCleaner.clean_html(html)
pages_data.append({
'url': page.url,
'html': html,
'text': cleaned_text,
'status': response.status
})
visited_urls.add(page.url)
logger.info(f" ✅ Главная: {len(cleaned_text)} символов")
# Собираем ссылки
links = await page.eval_on_selector_all(
'a[href]',
'''elements => elements.map(e => e.href).filter(h => h && h.startsWith('http'))'''
)
# Фильтруем внутренние ссылки
base_domain = urlparse(website).netloc
internal_links = [
link for link in links
if urlparse(link).netloc == base_domain and link not in visited_urls
][:MAX_PAGES_PER_SITE - 1]
logger.info(f" 📄 Найдено {len(internal_links)} внутренних ссылок")
# Обходим внутренние страницы
for link in internal_links:
if len(pages_data) >= MAX_PAGES_PER_SITE:
break
try:
response = await page.goto(link, wait_until='domcontentloaded', timeout=PAGE_TIMEOUT)
if response and response.ok:
await page.wait_for_timeout(1000)
html = await page.content()
cleaned_text = TextCleaner.clean_html(html)
pages_data.append({
'url': page.url,
'html': html,
'text': cleaned_text,
'status': response.status
})
visited_urls.add(page.url)
except Exception as e:
# Игнорируем ошибки отдельных страниц
continue
else:
error_msg = f"HTTP {response.status}" if response else "No response"
logger.warning(f" ⚠️ Главная недоступна: {error_msg}")
mark_as_failed(hotel_id, 'http_error', error_msg)
await context.close()
return False
except Exception as e:
error_str = str(e)
# Определяем тип ошибки
if 'ERR_NAME_NOT_RESOLVED' in error_str:
error_type = 'dns_error'
logger.warning(f" 🔴 DNS ошибка (сайт не существует)")
elif 'ERR_CERT' in error_str or 'SSL' in error_str:
error_type = 'ssl_error'
logger.warning(f" 🔴 SSL ошибка")
elif 'ERR_CONNECTION_REFUSED' in error_str:
error_type = 'connection_refused'
logger.warning(f" 🔴 Подключение отклонено")
elif 'Timeout' in error_str or 'timeout' in error_str:
error_type = 'timeout'
logger.warning(f" 🔴 Таймаут")
else:
error_type = 'other_error'
logger.warning(f" ⚠️ Другая ошибка: {error_str[:100]}")
# Помечаем как failed
mark_as_failed(hotel_id, error_type, error_str[:500])
await context.close()
return False
await context.close()
# Сохраняем в БД
if pages_data:
save_to_db(hotel_id, hotel_name, region, website, pages_data)
logger.info(f" 💾 Сохранено {len(pages_data)} страниц")
return True
else:
mark_as_failed(hotel_id, 'no_content', 'Нет контента')
logger.warning(f" ⚠️ Нет данных")
return False
except Exception as e:
logger.error(f" ❌ Критическая ошибка: {e}")
mark_as_failed(hotel_id, 'critical_error', str(e)[:500])
return False
def save_to_db(hotel_id: str, hotel_name: str, region: str, website: str, pages_data: List[Dict]):
"""Сохранение в PostgreSQL"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Сохраняем метаданные
domain = urlparse(website).netloc
cur.execute("""
INSERT INTO hotel_website_meta (hotel_id, domain, main_url, pages_crawled, crawl_status, crawl_finished_at)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (hotel_id) DO UPDATE SET
pages_crawled = EXCLUDED.pages_crawled,
crawl_status = EXCLUDED.crawl_status,
crawl_finished_at = EXCLUDED.crawl_finished_at
""", (hotel_id, domain, website, len(pages_data), 'completed'))
# Сохраняем сырой HTML
for page in pages_data:
cur.execute("""
INSERT INTO hotel_website_raw (hotel_id, url, html, status_code, crawled_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (hotel_id, url) DO UPDATE SET
html = EXCLUDED.html,
status_code = EXCLUDED.status_code,
crawled_at = EXCLUDED.crawled_at
""", (hotel_id, page['url'], page['html'], page['status']))
# Сохраняем очищенный текст
for page in pages_data:
cur.execute("""
INSERT INTO hotel_website_processed (hotel_id, url, cleaned_text, processed_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (hotel_id, url) DO UPDATE SET
cleaned_text = EXCLUDED.cleaned_text,
processed_at = EXCLUDED.processed_at
""", (hotel_id, page['url'], page['text']))
conn.commit()
cur.close()
conn.close()
except Exception as e:
logger.error(f"❌ Ошибка сохранения в БД: {e}")
async def main():
"""Главная функция"""
logger.info("🚀 Запуск умного краулера с приоритетами")
# Получаем отели по приоритетам
hotels = get_hotels_by_priority()
total = len(hotels)
logger.info(f"\n📊 Найдено необработанных отелей: {total}")
if total == 0:
logger.info("Все отели уже обработаны!")
return
# Запускаем краулинг
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
processed = 0
success = 0
# Обрабатываем пачками
for i in range(0, total, BATCH_SIZE):
batch = hotels[i:i + BATCH_SIZE]
logger.info(f"\n📦 Пачка {i//BATCH_SIZE + 1}/{(total + BATCH_SIZE - 1)//BATCH_SIZE}")
logger.info(f" Отели {i+1}-{min(i+BATCH_SIZE, total)} из {total}")
tasks = [crawl_hotel(hotel, semaphore, browser) for hotel in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
batch_success = sum(1 for r in results if r is True)
success += batch_success
processed += len(batch)
logger.info(f"✅ Пачка: {batch_success}/{len(batch)} успешно")
logger.info(f"📊 Прогресс: {processed}/{total} ({processed*100//total}%)")
await browser.close()
logger.info(f"\n🎉 КРАУЛИНГ ЗАВЕРШЁН!")
logger.info(f" Обработано: {processed}")
logger.info(f" Успешно: {success} ({success*100//processed if processed > 0 else 0}%)")
logger.info(f" Ошибок: {processed - success}")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("\n⚠️ Прервано пользователем")
sys.exit(0)