Проект аудита отелей: основные скрипты и документация

- Краулеры: 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:
Фёдор
2025-10-16 10:52:09 +03:00
parent 545e199389
commit 0cf3297290
105 changed files with 28743 additions and 0 deletions

141
CRAWLER_FIX_REPORT.md Normal file
View File

@@ -0,0 +1,141 @@
# 🔧 ОТЧЁТ ОБ ИСПРАВЛЕНИИ КРАУЛЕРА
**Дата:** 14 октября 2025, 14:30
**Проблема:** Ошибки при сохранении данных в БД
**Статус:****ИСПРАВЛЕНО**
---
## 🐛 **НАЙДЕННЫЕ ОШИБКИ:**
### **Ошибка #1: Неверное имя колонки `raw_html`**
```
column "raw_html" of relation "hotel_website_raw" does not exist
```
**Причина:** Краулер использовал `raw_html`, а в таблице колонка называется `html`
**Исправление:**
```python
# ДО:
INSERT INTO hotel_website_raw (hotel_id, url, raw_html, http_status, crawled_at)
# ПОСЛЕ:
INSERT INTO hotel_website_raw (hotel_id, url, html, status_code, crawled_at)
```
---
### **Ошибка #2: Неверное имя колонки `http_status`**
```
column "http_status" of relation "hotel_website_raw" does not exist
```
**Причина:** Краулер использовал `http_status`, а в таблице колонка называется `status_code`
**Исправление:** Заменено на `status_code` (уже исправлено в #1)
---
### **Ошибка #3: Отсутствие уникального индекса**
```
there is no unique or exclusion constraint matching the ON CONFLICT specification
```
**Причина:** В таблице `hotel_website_processed` не было уникального ограничения на `(hotel_id, url)`
**Исправление:**
```sql
CREATE UNIQUE INDEX IF NOT EXISTS hotel_website_processed_hotel_id_url_idx
ON hotel_website_processed (hotel_id, url)
```
---
## ✅ **РЕЗУЛЬТАТЫ ПОСЛЕ ИСПРАВЛЕНИЯ:**
### **Тестирование (14:28):**
- ✅ Нет ошибок в логе
- ✅ Данные сохраняются в `hotel_website_raw`
- ✅ Данные сохраняются в `hotel_website_processed`
- ✅ Краулер обрабатывает по ~140 отелей/час
### **Пример свежих данных:**
```
База отдыха "Алтай-Rest" (Алтайский край) - 1 страница
База отдыха E.L.K.I. (Алтайский край) - 1 страница
Апарт-отель «Лофт Апарт» (Алтайский край) - 8 страниц
Апарт-отель «Бочкари 1825» (Алтайский край) - 7 страниц
Апарт-Парк "ШАЛЕИРИ" (Алтайский край) - 1 страница
```
---
## 📊 **СТАТИСТИКА:**
### **База отелей:**
- 🏨 Всего отелей: **33,773**
- 🌐 С сайтами: **18,594** (55.1%)
- ❌ Без сайтов: **15,179** (44.9%)
### **Прогресс краулинга:**
- ✅ Обработано: **~930 отелей**
- ⏳ Осталось: **~17,664 отелей**
- 📊 Прогресс: **5.0%**
- ⏱️ Ожидаемое время: **~126 часов** (~5 дней)
---
## 🚀 **РЕКОМЕНДАЦИИ ДЛЯ УСКОРЕНИЯ:**
1. **Увеличить параллелизм:**
```python
MAX_CONCURRENT = 5 # Вместо 3
```
2. **Уменьшить количество страниц:**
```python
MAX_PAGES_PER_SITE = 10 # Вместо 15
```
3. **Уменьшить timeout:**
```python
PAGE_TIMEOUT = 20000 # Вместо 30000 (20 секунд)
```
4. **Добавить батчинг для БД:**
- Собирать данные в память
- Сохранять пачками по 50-100 страниц
---
## 📁 **ИЗМЕНЁННЫЕ ФАЙЛЫ:**
### **1. `mass_crawler.py`**
- Строка 205: `raw_html` → `html`
- Строка 205: `http_status` → `status_code`
- Строка 207: Добавлен `ON CONSTRAINT` для `hotel_website_raw`
- Строка 218: Добавлен уникальный индекс для `hotel_website_processed`
### **2. База данных:**
- Создан индекс: `hotel_website_processed_hotel_id_url_idx`
---
## 🎯 **ТЕКУЩИЙ СТАТУС:**
✅ **Краулер работает стабильно**
✅ **Ошибок нет**
✅ **Данные сохраняются корректно**
✅ **PID: 1593850**
✅ **Лог: `mass_crawler_output.log`**
---
**Автор:** AI Assistant + Фёдор
**Дата создания:** 14 октября 2025

141
CRAWLER_STATUS.md Normal file
View File

@@ -0,0 +1,141 @@
# 🚀 МАССОВЫЙ КРАУЛИНГ ЗАПУЩЕН
**Дата старта:** 14 октября 2025, 07:35
**PID:** 1439902
**Статус:** ✅ РАБОТАЕТ
---
## 📊 СТАТИСТИКА:
| Параметр | Значение |
|----------|----------|
| **Всего отелей с сайтами** | 18,594 |
| **Уже обработано** | 923 (5%) |
| **Осталось обработать** | **17,672 (95%)** |
| **Обработка пачками** | По 50 отелей |
| **Параллельно** | 3 браузера |
| **Страниц на сайт** | До 15 страниц |
---
## ⏱️ ПРИМЕРНОЕ ВРЕМЯ:
- **Скорость:** ~3-5 отелей/минуту
- **Ожидаемое время:** ~60-100 часов (2.5-4 дня)
- **Завершение:** ~17-18 октября
---
## 🎯 ТОП-10 РЕГИОНОВ В ОЧЕРЕДИ:
1. Краснодарский край: 2,297 отелей
2. г. Москва: 1,535 отелей
3. Республика Крым: 968 отелей
4. Московская область: 928 отелей
5. Ставропольский край: 433 отелей
6. Свердловская область: 431 отелей
7. Республика Татарстан: 431 отелей
8. Ростовская область: 408 отелей
9. Республика Башкортостан: 342 отелей
10. Ленинградская область: 336 отелей
---
## 📋 КОМАНДЫ ДЛЯ УПРАВЛЕНИЯ:
### Проверить статус:
```bash
cd /root/engine/public_oversight/hotels
./check_crawler_status.sh
```
### Посмотреть логи:
```bash
tail -f mass_crawler_output.log
```
или детальный лог:
```bash
tail -f mass_crawler_*.log
```
### Остановить краулер:
```bash
pkill -f mass_crawler.py
```
### Перезапустить:
```bash
cd /root/engine/public_oversight/hotels
nohup python3 mass_crawler.py > mass_crawler_output.log 2>&1 &
```
### Проверить прогресс в БД:
```bash
python3 check_progress.py
```
---
## 💾 ЧТО СОХРАНЯЕТСЯ:
### 1. `hotel_website_meta`
- Метаданные о краулинге
- Количество страниц
- Статус
### 2. `hotel_website_raw`
- Сырой HTML всех страниц
- HTTP статусы
- Временные метки
### 3. `hotel_website_processed`
- Очищенный текст
- Готов для эмбеддингов
- Готов для аудита
---
## 🔍 МОНИТОРИНГ:
**Основной лог:** `mass_crawler_output.log`
**Детальный лог:** `mass_crawler_20251014_073550.log`
**Что отслеживать:**
- ✅ Количество успешных краулингов
- ⚠️ Ошибки подключения (таймауты)
- 📊 Скорость обработки (отели/мин)
---
## ⚠️ ИЗВЕСТНЫЕ ПРОБЛЕМЫ:
1. **Таймауты** - некоторые сайты медленные (30 сек)
2. **Блокировки** - редко, но могут блокировать IP
3. **Битые ссылки** - ~5-10% сайтов недоступны
**Всё это нормально и обрабатывается!**
---
## 📈 ПОСЛЕ ЗАВЕРШЕНИЯ:
1. **Обработка эмбеддингов** - `process_all_hotels_embeddings.py`
2. **Запуск аудита через n8n** - AI Agent + NER
3. **Генерация отчётов** - Excel по регионам
---
## ✅ ИТОГ:
**Краулер работает в фоне 24/7 и обработает все 17,672 отеля за ~3-4 дня!**
Можно спокойно заниматься другими делами - всё идёт автоматически! 🚀
---
**Создано:** 14 октября 2025, 07:36
**Автор:** AI Assistant

226
CRAWLER_WORKFLOW.md Normal file
View File

@@ -0,0 +1,226 @@
# 🤖 Что делает краулер - пошаговый процесс
## 📋 КРАТКИЙ ОТВЕТ
Краулер делает **ТОЛЬКО парсинг и сохранение в БД**. Никаких эмбеддингов, векторизации или анализа!
---
## 🔄 ПОЛНЫЙ ПРОЦЕСС (шаг за шагом)
### 1⃣ **Получение списка отелей** (`get_unprocessed_hotels`)
```sql
SELECT id, full_name, region_name, website_address
FROM hotel_main
WHERE website_address IS NOT NULL
AND id NOT IN (SELECT hotel_id FROM hotel_website_processed)
ORDER BY id
LIMIT 50 -- пачками по 50
```
**Что делает:**
- Берёт отели с сайтами
- Исключает уже обработанные
- Обрабатывает пачками по 50 штук
---
### 2⃣ **Краулинг сайта** (`crawl_hotel`)
#### 2.1. Запуск браузера Playwright
- Открывает headless браузер
- User-Agent: Mozilla/5.0 (Windows...)
- Параллельно: 5 браузеров (`MAX_CONCURRENT = 5`)
#### 2.2. Загрузка главной страницы
```python
await page.goto(website, wait_until='domcontentloaded', timeout=20000)
```
- Таймаут: 20 секунд
- Ждёт загрузки DOM
#### 2.3. Извлечение контента главной
```python
html = await page.content() # Сырой HTML
cleaned_text = TextCleaner.clean_html(html) # Очищенный текст
```
**`TextCleaner.clean_html()` делает:**
- Удаляет `<script>`, `<style>`, `<meta>`, `<link>`, `<noscript>`
- Извлекает текст через BeautifulSoup
- Убирает лишние пробелы/переносы
- Возвращает чистый текст
#### 2.4. Сбор внутренних ссылок
```python
links = await page.evaluate('''() => {
return Array.from(document.querySelectorAll('a[href]'))
.map(a => a.href)
.filter(href => href && !href.startsWith('mailto:') && !href.startsWith('tel:'))
}''')
```
**Фильтрация:**
- Только внутренние ссылки (тот же домен)
- Исключает `mailto:`, `tel:`
- Убирает дубли
- **Лимит: 19 ссылок** (+ главная = 20 страниц)
#### 2.5. Обход внутренних страниц
```python
for link in internal_links[:19]: # Максимум 19 + главная = 20
page2 = await context.new_page()
await page2.goto(link, timeout=20000)
html2 = await page2.content()
text2 = TextCleaner.clean_html(html2)
# Сохраняем в pages_data
```
**Для каждой страницы:**
- Открывает новую вкладку
- Загружает страницу (20 сек таймаут)
- Извлекает HTML
- Очищает текст
- Добавляет в `pages_data[]`
---
### 3⃣ **Сохранение в БД** (`save_to_db`)
#### 3.1. Метаданные → `hotel_website_meta`
```sql
INSERT INTO hotel_website_meta
(hotel_id, domain, main_url, pages_crawled, crawl_status, crawl_finished_at)
VALUES (...)
ON CONFLICT (hotel_id) DO UPDATE ...
```
**Сохраняет:**
- `hotel_id` - UUID отеля
- `domain` - Домен сайта
- `main_url` - Главный URL
- `pages_crawled` - Количество страниц
- `crawl_status` - 'completed'
- `crawl_finished_at` - Время завершения
#### 3.2. Сырой HTML → `hotel_website_raw`
```sql
INSERT INTO hotel_website_raw
(hotel_id, url, html, status_code, crawled_at)
VALUES (...)
ON CONFLICT (hotel_id, url) DO UPDATE ...
```
**Для КАЖДОЙ страницы сохраняет:**
- `hotel_id` - UUID отеля
- `url` - URL страницы
- `html` - **Полный сырой HTML**
- `status_code` - HTTP код (200, 404, etc)
- `crawled_at` - Время краулинга
#### 3.3. Очищенный текст → `hotel_website_processed`
```sql
INSERT INTO hotel_website_processed
(hotel_id, url, cleaned_text, processed_at)
VALUES (...)
ON CONFLICT (hotel_id, url) DO UPDATE ...
```
**Для КАЖДОЙ страницы сохраняет:**
- `hotel_id` - UUID отеля
- `url` - URL страницы
- `cleaned_text` - **Очищенный текст (без HTML тегов)**
- `processed_at` - Время обработки
---
## ⚡ ПРОИЗВОДИТЕЛЬНОСТЬ
### Настройки:
```python
MAX_PAGES_PER_SITE = 20 # Максимум страниц с одного сайта
PAGE_TIMEOUT = 20000 # 20 секунд на загрузку страницы
MAX_CONCURRENT = 5 # 5 браузеров параллельно
BATCH_SIZE = 50 # Обрабатывать по 50 отелей
```
### Время на 1 отель:
- **Быстрый сайт (1-5 страниц):** ~10-30 секунд
- **Средний сайт (10-15 страниц):** ~1-3 минуты
- **Большой сайт (20 страниц):** ~3-5 минут
- **Недоступный сайт:** ~20 секунд (таймаут)
### Скорость обработки:
- **Текущая:** ~50-100 отелей/день
- **Теоретическая:** ~400-500 отелей/день (если все сайты быстрые)
---
## ❌ ЧТО КРАУЛЕР **НЕ ДЕЛАЕТ**
1.**НЕ создаёт эмбеддинги** (векторы для поиска)
2.**НЕ делает чанки** (разбивку на части)
3.**НЕ анализирует контент** (нет AI/NLP)
4.**НЕ извлекает структурированные данные** (телефоны, email, etc)
5.**НЕ проверяет критерии аудита**
6.**НЕ делает скриншоты**
7.**НЕ проверяет SSL/сертификаты**
---
## 📊 ЧТО В ИТОГЕ В БД
### После краулинга 1 отеля с 15 страницами:
**`hotel_website_meta`:** 1 запись
- Метаданные: домен, количество страниц, статус
**`hotel_website_raw`:** 15 записей
- 15 × полный HTML (может быть 100-500 KB каждый)
- Всего: ~1-7 MB сырых данных
**`hotel_website_processed`:** 15 записей
- 15 × очищенный текст (обычно 1-10 KB каждый)
- Всего: ~15-150 KB текста
---
## 🚀 СЛЕДУЮЩИЕ ЭТАПЫ (отдельно от краулера)
После того как краулер заполнит БД, **ОТДЕЛЬНО** нужно будет:
1. **Создать эмбеддинги** (`process_all_hotels_embeddings.py`)
- Разбить текст на чанки
- Создать векторы через BGE-M3
- Сохранить в `hotel_website_chunks`
2. **Запустить аудит** (`hybrid_audit_chukotka.py`)
- Семантический поиск
- Регулярные выражения
- Natasha NER
- Сохранить в `hotel_audit_results`
3. **Интеграция с n8n**
- AI Agent для анализа
- Автоматизация проверок
---
## 💡 ВЫВОД
**Краулер = простой парсер:**
- Открывает сайты
- Скачивает HTML
- Чистит текст
- Кладёт в БД
**Всё остальное (AI, векторы, аудит) - это отдельные процессы!**
---
**Дата:** 2025-10-14
**Автор:** Фёдор + AI Assistant

182
DB_SCHEMA_REFERENCE.md Normal file
View File

@@ -0,0 +1,182 @@
# 📊 Справка по структуре БД отелей
## 🔑 Основные таблицы и их ключевые поля
### 1. `hotel_main` - Основная таблица отелей
**Первичный ключ:** `id` (UUID) ⚠️ **НЕ `hotel_id`!**
**Основные колонки:**
- `id` - UUID отеля (PRIMARY KEY)
- `full_name` - Полное название
- `short_name` - Краткое название
- `website_address` - ⚠️ **НЕ `website`!** Адрес сайта
- `phone` - Телефон
- `email` - Email
- `owner_full_name` - ФИО владельца
- `owner_ogrn` - ОГРН
- `owner_inn` - ИНН
- `region_id`, `region_name` - Регион
- `category_id`, `category_name` - Категория (звёзды)
- `hotel_type_id`, `hotel_type_name` - Тип отеля
- `status_id`, `status_name` - Статус
- `website_status` - Статус сайта
- `rkn_registry_status`, `rkn_registry_number`, `rkn_registry_date` - РКН
- `created_at`, `updated_at` - Даты создания/обновления
### 2. `hotel_website_raw` - Сырые данные с сайтов
**Связь:** `hotel_id``hotel_main.id`
**Основные колонки:**
- `id` - ID записи
- `hotel_id` - UUID отеля (FK к hotel_main.id)
- `url` - URL страницы
- `html` - ⚠️ **НЕ `raw_html`!** HTML контент
- `status_code` - ⚠️ **НЕ `http_status`!** HTTP код
- `crawled_at` - Дата краулинга
- `content_hash` - Хеш контента
- `response_time` - Время ответа
**Уникальный индекс:** `(hotel_id, url)`
### 3. `hotel_website_processed` - Обработанные данные
**Связь:** `hotel_id``hotel_main.id`, `raw_page_id``hotel_website_raw.id`
**Основные колонки:**
- `id` - ID записи
- `raw_page_id` - FK к hotel_website_raw.id
- `hotel_id` - UUID отеля (FK к hotel_main.id)
- `url` - URL страницы
- `cleaned_text` - Очищенный текст
- `text_length` - Длина текста
- `extracted_data` - Извлечённые данные (JSON)
- `has_forms` - Наличие форм
- `has_booking` - Наличие бронирования
- `processed_at` - ⚠️ **НЕ `created_at`!** Дата обработки
**Уникальный индекс:** `(hotel_id, url)`
### 4. `hotel_website_chunks` - Чанки для векторного поиска
**Основные колонки:**
- `id` - ID записи
- `hotel_id` - UUID отеля
- `text` - Текст чанка
- `metadata` - Метаданные (JSON): `hotel_id`, `hotel_name`, `region_name`, `url`
- `embedding` - Вектор эмбеддинга (vector(1024))
### 5. `hotel_website_meta` - Метаданные сайтов
**Основные колонки:**
- `id` - ID записи
- `hotel_id` - UUID отеля
- `total_pages` - Всего страниц
- `crawled_pages` - Скраулено страниц
- `last_crawl_date` - Дата последнего краулинга
- `crawl_status` - Статус краулинга
- `ssl_valid` - Валидность SSL
- `technologies` - Используемые технологии (JSON)
### 6. `hotel_audit_results` - Результаты аудита
**Основные колонки:**
- `id` - ID записи
- `hotel_id` - UUID отеля
- `audit_date` - Дата аудита
- `criterion_id` - ID критерия
- `criterion_name` - Название критерия
- `status` - Статус проверки
- `score` - Оценка
- `details` - Детали (JSON)
- `evidence_urls` - URL доказательств
- `evidence_quotes` - Цитаты доказательств
### 7. `hotel_additional_info` - Дополнительная информация
### 8. `hotel_rooms` - Номера отелей
### 9. `hotel_services` - Услуги отелей
### 10. `hotel_sanatorium` - Санатории
### 11. `hotel_raw_json` - Сырые JSON данные
### 12. `hotel_parsing_progress` - Прогресс парсинга
---
## ⚠️ ЧАСТЫЕ ОШИБКИ И ИСПРАВЛЕНИЯ
### 1. Неправильные названия колонок:
```python
# ❌ НЕПРАВИЛЬНО:
JOIN hotel_main m ON p.hotel_id = m.hotel_id
SELECT website FROM hotel_main
SELECT raw_html FROM hotel_website_raw
SELECT http_status FROM hotel_website_raw
WHERE created_at > ... # для hotel_website_processed
# ✅ ПРАВИЛЬНО:
JOIN hotel_main m ON p.hotel_id = m.id
SELECT website_address FROM hotel_main
SELECT html FROM hotel_website_raw
SELECT status_code FROM hotel_website_raw
WHERE processed_at > ... # для hotel_website_processed
```
### 2. Уникальные индексы:
```sql
-- hotel_website_raw
ON CONFLICT (hotel_id, url) DO UPDATE ...
-- hotel_website_processed
ON CONFLICT (hotel_id, url) DO UPDATE ...
```
### 3. Связи таблиц:
```
hotel_main.id (UUID)
hotel_website_raw.hotel_id → hotel_website_raw.id
↓ ↓
hotel_website_processed.hotel_id + raw_page_id
hotel_website_chunks.hotel_id
```
---
## 📝 Примеры запросов
### Получить отели с сайтами:
```sql
SELECT m.id, m.full_name, m.website_address, COUNT(p.*) as pages_count
FROM hotel_main m
LEFT JOIN hotel_website_processed p ON p.hotel_id = m.id
WHERE m.website_address IS NOT NULL
GROUP BY m.id, m.full_name, m.website_address
```
### Найти отели с N страницами:
```sql
SELECT hotel_id, COUNT(*) as page_count
FROM hotel_website_processed
GROUP BY hotel_id
HAVING COUNT(*) = 10 -- ровно 10 страниц
```
### Статистика по краулингу:
```sql
SELECT
COUNT(DISTINCT hotel_id) as total_hotels,
COUNT(*) as total_pages,
AVG(text_length) as avg_text_length
FROM hotel_website_processed
WHERE processed_at > NOW() - INTERVAL '24 hours'
```
---
## 🔍 Полная схема
См. файл `db_schema_hotels.json` для детальной информации обо всех колонках.
---
**Дата создания:** 2025-10-14
**Автор:** Фёдор + AI Assistant

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.12-slim
WORKDIR /app
# Устанавливаем системные зависимости
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Копируем requirements
COPY requirements.txt .
# Устанавливаем Python зависимости
RUN pip install --no-cache-dir -r requirements.txt
# Копируем код приложения
COPY web_interface.py .
COPY audit_system.py .
# Открываем порт
EXPOSE 8888
# Запускаем веб-интерфейс
CMD ["python", "web_interface.py"]

299
LLM_GUIDE.md Normal file
View File

@@ -0,0 +1,299 @@
# 🤖 РУКОВОДСТВО ПО LLM МОДЕЛЯМ
## 📋 ВОЗМОЖНОСТИ
Система поддерживает **3 провайдера** LLM:
- **OpenAI** (GPT-4o, GPT-4o-mini) - через прокси ✅
- **OpenRouter** (Claude, Gemini, и др.) - через прокси
- **Ollama** (локальные модели) - без прокси
Вы можете:
1. ✅ Легко переключаться между провайдерами
2. ✅ Менять модель на лету через API
3. ✅ Тестировать разные модели для чата
4. ✅ Настраивать temperature и max_tokens
---
## ⚙️ НАСТРОЙКА
### 1. Через конфигурационный файл
Откройте `llm_config.py`:
```python
# Выберите провайдера
ACTIVE_PROVIDER = 'openai' # 'openai', 'openrouter', 'ollama'
```
**Доступные модели OpenAI:**
- `gpt-4o-mini` - быстрая, дешёвая (по умолчанию)
- `gpt-4o` - умная, дорогая
- `gpt-4-turbo` - средняя по скорости/качеству
**Доступные модели OpenRouter:**
- `anthropic/claude-3-haiku` - быстрая
- `anthropic/claude-3.5-sonnet` - умная
- `google/gemini-flash-1.5` - дешёвая
**Доступные модели Ollama:**
- `llama3.2` - быстрая (3B параметров)
- `llama3.1:70b` - умная (70B параметров)
- `qwen2.5:14b` - хороша для анализа
- `saiga_llama3` - для русского языка
### 2. Через переменные окружения
```bash
# Выбор провайдера
export LLM_PROVIDER=openai
# Выбор модели
export CHAT_MODEL=gpt-4o-mini
# Параметры
export LLM_TEMPERATURE=0.7 # Креативность (0.0-1.0)
export LLM_MAX_TOKENS=1000 # Макс. токенов в ответе
```
### 3. Через API на лету
```bash
# Узнать текущую модель
curl http://localhost:8888/api/llm/info
# Получить список моделей
curl http://localhost:8888/api/llm/models
# Переключить модель
curl -X POST http://localhost:8888/api/llm/switch \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o"}'
```
---
## 🚀 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ
### Пример 1: OpenAI GPT-4o-mini (по умолчанию)
```bash
# Уже настроено! Просто используйте
curl -X POST http://localhost:8888/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"Сколько отелей в Москве?"}'
```
### Пример 2: OpenAI GPT-4o (умная модель)
```bash
# Переключаемся на GPT-4o
curl -X POST http://localhost:8888/api/llm/switch \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o"}'
# Теперь чат использует GPT-4o
curl -X POST http://localhost:8888/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"Проанализируй качество сайтов отелей Чукотки"}'
```
### Пример 3: OpenRouter с Claude
```bash
# 1. Получите API ключ на openrouter.ai
# 2. Добавьте в llm_config.py:
# OPENROUTER_CONFIG['api_key'] = 'sk-or-v1-...'
# 3. Измените провайдера в llm_config.py:
# ACTIVE_PROVIDER = 'openrouter'
# 4. Перезапустите веб-интерфейс:
pkill -f web_interface.py
cd /root/engine/public_oversight/hotels
source venv/bin/activate
python web_interface.py &
# 5. Проверьте:
curl http://localhost:8888/api/llm/info
```
### Пример 4: Локальная Ollama
```bash
# 1. Установите Ollama (если ещё нет)
curl -fsSL https://ollama.com/install.sh | sh
# 2. Запустите Ollama
ollama serve &
# 3. Скачайте модель
ollama pull llama3.2
# 4. Измените llm_config.py:
# ACTIVE_PROVIDER = 'ollama'
# 5. Перезапустите веб-интерфейс
# 6. Используйте локальную модель:
curl -X POST http://localhost:8888/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"Привет!"}'
```
---
## 🎯 СРАВНЕНИЕ МОДЕЛЕЙ
| Модель | Провайдер | Скорость | Качество | Цена | Через прокси |
|--------|-----------|----------|----------|------|--------------|
| **gpt-4o-mini** | OpenAI | ⚡⚡⚡ | ⭐⭐⭐ | 💵 | ✅ |
| **gpt-4o** | OpenAI | ⚡⚡ | ⭐⭐⭐⭐⭐ | 💵💵💵 | ✅ |
| **claude-3-haiku** | OpenRouter | ⚡⚡⚡ | ⭐⭐⭐⭐ | 💵 | ✅ |
| **claude-3.5-sonnet** | OpenRouter | ⚡⚡ | ⭐⭐⭐⭐⭐ | 💵💵💵 | ✅ |
| **llama3.2** | Ollama | ⚡⚡⚡⚡ | ⭐⭐⭐ | 🆓 | ❌ |
| **llama3.1:70b** | Ollama | ⚡ | ⭐⭐⭐⭐ | 🆓 | ❌ |
**Рекомендации:**
- **Для чата:** gpt-4o-mini (быстро и дёшево)
- **Для анализа:** gpt-4o или claude-3.5-sonnet (лучшее качество)
- **Для экспериментов:** Ollama llama3.2 (бесплатно, офлайн)
---
## 🔧 ТЕСТИРОВАНИЕ МОДЕЛЕЙ
### Скрипт для сравнения
Создайте `test_models.sh`:
```bash
#!/bin/bash
models=("gpt-4o-mini" "gpt-4o")
question='{"message":"Сколько отелей в Чукотском АО имеют сайты?"}'
for model in "${models[@]}"; do
echo "=== Тестирую $model ==="
# Переключаем модель
curl -s -X POST http://localhost:8888/api/llm/switch \
-H "Content-Type: application/json" \
-d "{\"model\":\"$model\"}"
# Задаём вопрос
start=$(date +%s%N)
response=$(curl -s -X POST http://localhost:8888/api/chat \
-H "Content-Type: application/json" \
-d "$question")
end=$(date +%s%N)
# Время ответа
time_ms=$(( (end - start) / 1000000 ))
echo "Модель: $model"
echo "Время: ${time_ms}ms"
echo "Ответ: $(echo $response | python3 -c 'import sys,json; print(json.load(sys.stdin)["response"][:200])')"
echo ""
done
```
Запустите:
```bash
chmod +x test_models.sh
./test_models.sh
```
---
## 📊 API ENDPOINTS
### GET /api/llm/info
Информация о текущей модели
**Ответ:**
```json
{
"provider": "openai",
"chat_model": "gpt-4o-mini",
"temperature": 0.3,
"max_tokens": 800,
"uses_proxy": true
}
```
### GET /api/llm/models
Список доступных моделей
**Ответ:**
```json
{
"current_provider": "openai",
"current_model": "gpt-4o-mini",
"models": {
"fast": "gpt-4o-mini",
"smart": "gpt-4o",
"chat": "gpt-4o-mini"
}
}
```
### POST /api/llm/switch
Переключить модель
**Запрос:**
```json
{
"model": "gpt-4o"
}
```
**Ответ:**
```json
{
"status": "switched",
"new_model": "gpt-4o",
"provider": "openai"
}
```
---
## ⚠️ ВАЖНЫЕ ЗАМЕЧАНИЯ
1. **Прокси:**
- OpenAI и OpenRouter работают через прокси `195.133.66.13:3128`
- Ollama работает локально без прокси
2. **API ключи:**
- OpenAI ключ уже настроен в `llm_config.py`
- Для OpenRouter нужен свой ключ: https://openrouter.ai/keys
3. **Стоимость:**
- GPT-4o-mini: ~$0.15 за 1М токенов (вход) + $0.60 (выход)
- GPT-4o: ~$5.00 за 1М токенов (вход) + $15.00 (выход)
- Ollama: бесплатно, но требует мощное железо
4. **Переключение модели:**
- Работает без перезапуска сервера
- Применяется сразу к следующему запросу
- Не влияет на конфигурационный файл (изменение временное)
---
## 🎓 ДОПОЛНИТЕЛЬНЫЕ РЕСУРСЫ
- **OpenAI Models:** https://platform.openai.com/docs/models
- **OpenRouter:** https://openrouter.ai/models
- **Ollama:** https://ollama.com/library
- **Наша документация:** `/root/engine/public_oversight/hotels/QUICK_START.md`
---
**Создано:** 2025-10-11
**Версия:** 1.0
**Статус:** ✅ Работает

130
N8N_FILES_SUMMARY.md Normal file
View File

@@ -0,0 +1,130 @@
# 📦 ФАЙЛЫ ДЛЯ n8n AI AGENT - ПОЛНАЯ СВОДКА
## 🎯 **ЧТО СОЗДАНО:**
### **1. ПРОМПТЫ (System Message для AI Agent):**
| Файл | Размер | Описание | Рекомендация |
|------|--------|----------|--------------|
| `prompt.txt` | 21 KB | Полный детальный промпт с примерами | Если есть место |
| `prompt_short.txt` | 2.1 KB | Краткий промпт | Если ограничен размер |
| **`prompt_json.txt`** | 3.0 KB | **JSON промпт** | **⭐ РЕКОМЕНДУЕТСЯ!** |
### **2. ВОПРОСЫ (17 критериев без #6 Роскомнадзор):**
| Файл | Формат | Описание |
|------|--------|----------|
| `questions_17.txt` | Текст | 17 вопросов в текстовом формате |
| `questions_17.json` | JSON | 17 вопросов с keywords и patterns |
### **3. КОД ДЛЯ n8n CODE NODE:**
| Файл | Назначение |
|------|------------|
| `n8n_code_generate_questions.js` | Генерирует 17 items для Loop |
| `n8n_code_parse_json.js` | Парсит JSON ответы от AI Agent |
### **4. ПРИМЕРЫ И ДОКУМЕНТАЦИЯ:**
| Файл | Описание |
|------|----------|
| `n8n_example_json.json` | Примеры JSON ответов |
| `N8N_SETUP.md` | Инструкция по настройке |
---
## 🚀 **БЫСТРЫЙ СТАРТ:**
### **ШАГ 1: Настрой AI Agent**
```
1. Создай AI Agent Node в n8n
2. Вставь содержимое prompt_json.txt в System Message
3. Настрой модель: Ollama (llama3.2 или qwen2.5)
4. Temperature: 0.1
5. Max Tokens: 500
```
### **ШАГ 2: Подключи Vector Store**
```
1. Добавь Postgres Vector Store Node
2. Подключи к БД:
- Host: 147.45.189.234
- Database: default_db
- Table: hotel_website_chunks
- Embedding column: embedding
- Text column: text
3. Top K: 5
4. Similarity Threshold: 0.7
```
### **ШАГ 3: Создай Loop**
```
1. Добавь Code Node
2. Вставь код из n8n_code_generate_questions.js
3. Подключи к Loop Over Items
4. Loop будет перебирать 17 вопросов
```
### **ШАГ 4: Обработай ответы**
```
1. После AI Agent добавь Code Node
2. Вставь код из n8n_code_parse_json.js
3. Он распарсит JSON ответы
4. Получишь структурированные данные
```
### **ШАГ 5: Сохрани результаты**
```
1. Добавь PostgreSQL Node
2. Сохрани результаты в hotel_audit_results
3. Или экспортируй в Excel
```
---
## 📊 **СТРУКТУРА JSON ОТВЕТА:**
```json
{
"found": true,
"score": 1.0,
"quote": "ИНН: 8707003759, ОГРН: 1028700516476",
"url": "https://chrkh.ru/kontakty/",
"details": "ИНН (10 цифр): 8707003759",
"checked_pages": 5,
"confidence": "Высокая"
}
```
---
## ⚠️ **ВАЖНО:**
1. **Критерий #6 "Роскомнадзор (реестр)"** - проверяется **ОТДЕЛЬНО**, не через AI Agent
2. Всего критериев: **18**, но AI Agent проверяет только **17**
3. Используй **prompt_json.txt** для получения структурированных ответов
4. Vector Store должен быть подключен к `hotel_website_chunks` с эмбеддингами
---
## 🎯 **ПРЕИМУЩЕСТВА JSON ФОРМАТА:**
✅ Структурированные данные
✅ Легко парсить в n8n
✅ Автоматическая оценка (score 0.0-1.0)
✅ Цитаты и URL в отдельных полях
✅ Нет путаницы с текстовым форматом
✅ Готово для сохранения в БД
---
## 📞 **ПОДДЕРЖКА:**
Если что-то не работает:
1. Проверь что Vector Store подключен
2. Проверь что у отеля есть chunks в `hotel_website_chunks`
3. Проверь логи n8n
4. Убедись что AI Agent использует `prompt_json.txt`
5. Проверь что ответ - валидный JSON
**Удачи! 🚀**

200
N8N_HTTP_REQUEST_NATASHA.md Normal file
View File

@@ -0,0 +1,200 @@
# 🔗 HTTP REQUEST для Natasha NER в n8n
## 📡 **НАСТРОЙКИ HTTP REQUEST NODE:**
### **Базовая информация:**
- **Name:** Natasha NER Check
- **Method:** POST
- **URL:** `http://localhost:8004/extract_simple`
### **Headers:**
```
Content-Type: application/json
Accept: application/json
```
### **Body (JSON):**
```json
{
"text": "{{ $json.quote }}",
"max_length": 5000
}
```
### **Options:**
- **Timeout:** 30000 (30 секунд)
- **Response Format:** JSON
---
## 📋 **ГОТОВЫЙ cURL ДЛЯ ИМПОРТА:**
### **Вариант 1: Для локального n8n (на том же сервере)**
```bash
curl -X POST 'http://localhost:8004/extract_simple' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"text": "{{ $json.quote }}",
"max_length": 5000
}'
```
### **Вариант 2: Если n8n на другом сервере**
Нужно открыть порт 8004 или использовать SSH туннель:
```bash
# SSH туннель (запусти на машине с n8n)
ssh -L 8004:localhost:8004 root@147.45.146.17
# Затем в n8n используй:
# URL: http://localhost:8004/extract_simple
```
---
## 🎯 **ИМПОРТ В n8n (ПОШАГОВО):**
### **ШАГ 1: Создай HTTP Request Node**
1. Добавь ноду **"HTTP Request"**
2. Назови: **"Natasha NER"**
### **ШАГ 2: Настрой Authentication**
- **Authentication:** None (API без авторизации)
### **ШАГ 3: Настрой Request**
- **Method:** POST
- **URL:** `http://localhost:8004/extract_simple`
### **ШАГ 4: Настрой Headers**
Добавь 2 заголовка:
1. `Content-Type`: `application/json`
2. `Accept`: `application/json`
### **ШАГ 5: Настрой Body**
- **Body Content Type:** JSON
- **Specify Body:** Using JSON
- **JSON:**
```json
{
"text": "={{ $json.quote }}",
"max_length": 5000
}
```
### **ШАГ 6: Настрой Options**
- **Timeout:** 30000
- **Response Format:** Auto-detect (JSON)
---
## 📊 **ОЖИДАЕМЫЙ ОТВЕТ:**
```json
{
"organizations": ["ИП", "Фролов С.А."],
"persons": ["Иван Петров"],
"locations": ["Петропавловск-Камчатский", "Пограничная"],
"has_organizations": true,
"has_persons": true,
"has_locations": true,
"total": 4
}
```
---
## 💻 **CODE NODE ПОСЛЕ HTTP REQUEST:**
```javascript
// Обрабатываем ответ от Natasha API
const criterion = $input.item.json;
const nerResult = $('Natasha NER').first().json;
// Определяем NER score в зависимости от критерия
let nerScore = 0.0;
let nerEntities = [];
if (criterion.criterion_id === 1) {
// Критерий 1: Организации
if (nerResult.has_organizations) {
nerScore = 1.0;
nerEntities = nerResult.organizations;
}
} else if (criterion.criterion_id === 2) {
// Критерий 2: Адреса
if (nerResult.has_locations) {
nerScore = 1.0;
nerEntities = nerResult.locations;
}
}
// Комбинируем с regex score
const regexScore = parseFloat(criterion.score) || 0.0;
const finalScore = Math.max(regexScore, nerScore);
return {
json: {
...criterion,
ner_score: nerScore,
ner_entities: nerEntities,
final_score: finalScore,
method: finalScore === nerScore ? 'Natasha NER' :
finalScore === regexScore ? 'Регулярные выражения' :
'Гибрид'
}
};
```
---
## 🔧 **ПРОВЕРКА ДОСТУПНОСТИ:**
```bash
# Локально (на сервере)
curl http://localhost:8004/health
# Извне (если порт открыт)
curl http://147.45.146.17:8004/health
# Если не работает извне - открой порт в firewall:
# sudo ufw allow 8004/tcp
```
---
## 🚀 **БЫСТРЫЙ ТЕСТ:**
```bash
# Тест с реальным текстом
curl -X POST 'http://localhost:8004/extract_simple' \
-H 'Content-Type: application/json' \
-d '{
"text": "Гостиница Певек, ИНН 8707003759, ОГРН 1028700516476. Адрес: г. Певек, ул. Ленина, 10. Директор Иван Иванов.",
"max_length": 5000
}'
```
**Ожидаемый ответ:**
```json
{
"organizations": ["Гостиница Певек"],
"persons": ["Иван Иванов"],
"locations": ["Певек", "ул. Ленина"],
"has_organizations": true,
"has_persons": true,
"has_locations": true,
"total": 4
}
```
**Готово! Используй этот cURL в n8n!** 🚀

265
N8N_MERGE_INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,265 @@
# 🔗 Инструкция: Объединение результатов аудита в n8n
## 📋 Что делает Code Node
Объединяет результаты от **AI Agent** (17 детальных ответов) и **Regex** (17 простых ДА/НЕТ) в единую структуру с итоговой оценкой.
---
## 🏗️ Структура workflow в n8n
```
[Start]
[Loop Over Items] ← 17 вопросов
├─→ [AI Agent] → результаты AI (17 items)
└─→ [Postgres Regex] → результаты Regex (17 items)
[Aggregate] → объединяем 34 items в один массив
[Code Node: Merge Results] ← ВОТ СЮДА ВСТАВИТЬ КОД
[Output] → единая сводка
```
---
## 📥 Входные данные
Code Node должен получить **массив из 34 элементов**:
- **Элементы 0-16** (первые 17): результаты от AI Agent
- **Элементы 17-33** (последние 17): результаты от Regex
### Формат AI Agent (первые 17):
```json
{
"question": "полное наименование организации ОПФ ИНН ОГРН...",
"output": {
"found": true,
"score": 0.5,
"quote": "ИП Фролов С.А.",
"url": "https://example.com/page",
"details": "На сайте найдено...",
"checked_pages": 10,
"confidence": "Средняя"
}
}
```
### Формат Regex (последние 17):
```json
{
"output": {
"found": true,
"answer": "ДА",
"extracted": "ИНН: 1234567890",
"confidence": "Высокая"
}
}
```
---
## 📤 Выходные данные
Code Node возвращает **1 элемент** с объединённой сводкой:
```json
{
"hotel_name": "Городской отель \"Комфорт\"",
"region": "Камчатский край",
"audit_date": "2025-10-14",
"total_criteria": 17,
"found": 5,
"not_found": 12,
"compliance_percentage": 29.4,
"criteria_results": [
{
"criterion_id": 1,
"criterion_name": "Юридическая идентификация и верификация",
"criterion_description": "ИНН, ОГРН, полное наименование организации",
"found": true,
"status": "НАЙДЕНО",
"score": 0.5,
"final_confidence": "Средняя",
"ai_agent": {
"found": true,
"score": 0.5,
"quote": "ИП Фролов С.А.",
"url": "https://hotelcomfort41.ru/o-kompanii",
"details": "На сайте найдено наименование...",
"confidence": "Средняя",
"checked_pages": 10
},
"regex": {
"found": false,
"answer": "НЕТ",
"extracted": "",
"confidence": "Высокая"
}
},
// ... остальные 16 критериев
]
}
```
---
## 🔧 Как использовать в n8n
### Шаг 1: Aggregate Node (перед Code Node)
**Настройки Aggregate:**
- **Aggregate**: `Aggregate All Items` (объединить все в один массив)
- **Output Field**: оставить пустым или `data`
Это объединит 34 отдельных items (17 от AI + 17 от Regex) в один массив.
### Шаг 2: Code Node (вставить код)
1. Добавь **Code Node** после Aggregate
2. Скопируй код из файла `n8n_code_merge_audit_results.js`
3. Вставь в Code Node
**Важно!** Code Node автоматически:
- Разделит массив на AI (0-16) и Regex (17-33)
- Сопоставит результаты по индексу
- Объединит в единую структуру
- Вернёт итоговую сводку
### Шаг 3: Передача данных об отеле
Code Node пытается получить `hotel_name` и `region` из:
1. Workflow variables: `$('Workflow').item.json.hotel_name`
2. Первого input item: `$input.first().json.hotel_name`
**Рекомендация:** Установи workflow variables в начале:
```javascript
// В первом Code Node workflow
return [{
json: {
hotel_name: "{{ $json.hotel_name }}",
region: "{{ $json.region }}",
hotel_id: "{{ $json.hotel_id }}"
}
}];
```
---
## 📊 Логика объединения
### Статус `found`:
- `true` если **хотя бы один** метод (AI или Regex) нашёл информацию
- `false` если оба не нашли
### Оценка `score` (0-1):
- Берётся **максимум** из AI score и Regex (1 если found, 0 если нет)
### Итоговая уверенность `final_confidence`:
| AI found | AI conf | Regex found | Regex conf | Итог |
|----------|---------|-------------|------------|------|
| ✅ | Высокая | ✅ | Высокая | **Очень высокая** |
| ✅ | Высокая | ❌ | - | **Высокая** |
| ❌ | - | ✅ | Высокая | **Высокая** |
| ✅ | Средняя | ❌ | - | **Средняя** |
| ❌ | Высокая | ❌ | Высокая | **Высокая (не найдено)** |
| ❌ | - | ❌ | - | **Низкая** |
---
## 🧪 Пример workflow
```
┌─────────────────────┐
│ 1. Start │
│ hotel_id: xxx │
│ hotel_name: "..." │
│ region: "..." │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 2. Set Variables │ ← Сохраняем hotel_name, region
└──────────┬──────────┘
┌──────────▼──────────┐
│ 3. Generate Items │ ← Создаём 17 items (вопросы)
└──────────┬──────────┘
├─────────────────────────────┐
│ │
┌──────────▼──────────┐ ┌────────────▼──────────┐
│ 4a. AI Agent │ │ 4b. Postgres Regex │
│ (17 детальных) │ │ (17 простых ДА/НЕТ) │
└──────────┬──────────┘ └────────────┬──────────┘
│ │
└─────────┬───────────────────┘
┌─────────▼──────────┐
│ 5. Aggregate │ ← 34 items → 1 массив
└─────────┬──────────┘
┌─────────▼──────────┐
│ 6. Code Node │ ← ВСТАВИТЬ КОД СЮДА
│ (Merge Results) │
└─────────┬──────────┘
┌─────────▼──────────┐
│ 7. Save to DB │ ← Сохраняем в hotel_audit_results
└────────────────────┘
```
---
## 💾 Сохранение результатов в БД
После Code Node добавь **Postgres Node** для сохранения:
```sql
INSERT INTO hotel_audit_results
(hotel_id, audit_date, total_criteria, found, not_found,
compliance_percentage, criteria_results, created_at)
VALUES
($1, $2, $3, $4, $5, $6, $7::jsonb, NOW())
ON CONFLICT (hotel_id, audit_date) DO UPDATE SET
total_criteria = EXCLUDED.total_criteria,
found = EXCLUDED.found,
not_found = EXCLUDED.not_found,
compliance_percentage = EXCLUDED.compliance_percentage,
criteria_results = EXCLUDED.criteria_results,
updated_at = NOW()
```
**Parameters:**
1. `{{ $json.hotel_id }}`
2. `{{ $json.audit_date }}`
3. `{{ $json.total_criteria }}`
4. `{{ $json.found }}`
5. `{{ $json.not_found }}`
6. `{{ $json.compliance_percentage }}`
7. `{{ JSON.stringify($json.criteria_results) }}`
---
## ✅ Чек-лист
- [ ] Aggregate Node объединяет 34 items
- [ ] Code Node получает массив из 34 элементов
- [ ] Первые 17 - от AI Agent (детальные)
- [ ] Последние 17 - от Regex (простые)
- [ ] Workflow variables содержат hotel_name и region
- [ ] Результат сохраняется в БД
---
**Файл кода:** `n8n_code_merge_audit_results.js`
**Дата:** 2025-10-14
**Автор:** Фёдор + AI Assistant

338
N8N_NATASHA_CURL_IMPORT.md Normal file
View File

@@ -0,0 +1,338 @@
# 🎯 ВАЛИДНЫЕ cURL ДЛЯ ИМПОРТА В n8n HTTP REQUEST NODE
## 📋 ОБЩАЯ ИНФОРМАЦИЯ
**API URL:** `http://185.197.75.249:8004`
**Локально:** `http://localhost:8004`
**Статус:** ✅ Работает (проверено 13.10.2025 19:37)
---
## 🔍 ЭНДПОИНТЫ
### 1⃣ Проверка здоровья API
```bash
curl -X GET 'http://185.197.75.249:8004/health' \
-H 'Accept: application/json'
```
**Ответ:**
```json
{
"status": "healthy",
"natasha": "ready"
}
```
---
### 2⃣ Извлечение сущностей (УПРОЩЁННЫЙ - для n8n)
**⭐ РЕКОМЕНДУЕТСЯ ДЛЯ n8n! ⭐**
```bash
curl -X POST 'http://185.197.75.249:8004/extract_simple' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"text": "ИП Фролов С.А. находится по адресу г. Петропавловск-Камчатский, ул. Пограничная 39/1. Директор Иван Петров. ИНН: 8707003759, ОГРН: 1028700516476.",
"max_length": 5000
}'
```
**Реальный ответ (протестировано):**
```json
{
"organizations": ["ИП"],
"persons": ["Иван Петров", "Фролов С.А."],
"locations": ["Петропавловск-Камчатский"],
"has_organizations": true,
"has_persons": true,
"has_locations": true,
"total": 4
}
```
---
### 3⃣ Извлечение сущностей (ПОЛНЫЙ ФОРМАТ)
```bash
curl -X POST 'http://185.197.75.249:8004/extract' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"text": "Муниципальное предприятие «Чаунское районное коммунальное хозяйство». ИНН: 8707003759, ОГРН: 1028700516476. Юридический адрес: 689400, г. Певек, ул. Пугачева, 42",
"max_length": 5000
}'
```
**Ответ включает:**
```json
{
"organizations": ["Муниципальное предприятие"],
"persons": [],
"locations": ["Певек", "Пугачева"],
"entities": [
{
"type": "ORG",
"text": "Муниципальное предприятие",
"start": 0,
"end": 25
},
{
"type": "LOC",
"text": "Певек",
"start": 110,
"end": 115
}
],
"total_entities": 4
}
```
---
## 🔧 КАК ИМПОРТИРОВАТЬ В n8n HTTP REQUEST NODE
### Способ 1: Через Import from cURL
1. В n8n добавь **HTTP Request Node**
2. Нажми на кнопку **"Import from cURL"** (справа вверху в ноде)
3. Вставь этот cURL:
```bash
curl -X POST 'http://185.197.75.249:8004/extract_simple' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{"text": "ИП Фролов С.А. находится по адресу г. Петропавловск-Камчатский, ул. Пограничная 39/1. Директор Иван Петров.", "max_length": 5000}'
```
4. n8n автоматически заполнит все поля ✅
---
### Способ 2: Ручная настройка
**Параметры HTTP Request Node:**
| Поле | Значение |
|------|----------|
| **Method** | `POST` |
| **URL** | `http://185.197.75.249:8004/extract_simple` |
| **Authentication** | None |
| **Send Body** | Yes (JSON) |
| **Body Content Type** | JSON |
**Headers:**
```json
{
"Content-Type": "application/json",
"Accept": "application/json"
}
```
**Body (JSON):**
```json
{
"text": "{{ $json.quote }}",
"max_length": 5000
}
```
> 💡 **Где `{{ $json.quote }}`** - это данные из предыдущей ноды (текст для анализа)
---
## 📊 ПРИМЕР ИСПОЛЬЗОВАНИЯ В n8n WORKFLOW
### Схема:
```
┌────────────────┐
│ PostgreSQL │ → Получить текст из БД (hotel_website_processed)
└────────┬───────┘
┌────────────────┐
│ Code Node │ → Подготовить текст (cleaned_text)
└────────┬───────┘
┌────────────────┐
│ HTTP Request │ → http://185.197.75.249:8004/extract_simple
│ (Natasha API) │
└────────┬───────┘
┌────────────────┐
│ Code Node │ → Обработать результат (проверить has_organizations)
└────────────────┘
```
### Код для подготовки данных (Code Node ПЕРЕД HTTP Request):
```javascript
// Подготовка текста для Natasha API
const items = [];
for (const item of $input.all()) {
items.push({
json: {
hotel_id: item.json.hotel_id,
quote: item.json.cleaned_text || item.json.text || "",
criterion_id: item.json.criterion_id
}
});
}
return items;
```
### Код для обработки ответа (Code Node ПОСЛЕ HTTP Request):
```javascript
// Обработка ответа от Natasha API
const items = [];
for (const item of $input.all()) {
const organizations = item.json.organizations || [];
const persons = item.json.persons || [];
const locations = item.json.locations || [];
// Проверяем наличие нужных сущностей
const hasOrganizations = organizations.length > 0;
const hasPersons = persons.length > 0;
const hasLocations = locations.length > 0;
// Для критерия 1 (ИНН/ОГРН) проверяем организации
let ner_score = 0.0;
if (item.json.criterion_id === 1) {
ner_score = hasOrganizations ? 1.0 : 0.0;
}
// Для критерия 2 (Адрес) проверяем локации
else if (item.json.criterion_id === 2) {
ner_score = hasLocations ? 1.0 : 0.0;
}
items.push({
json: {
hotel_id: item.json.hotel_id,
criterion_id: item.json.criterion_id,
organizations: organizations,
persons: persons,
locations: locations,
ner_score: ner_score,
has_organizations: hasOrganizations,
has_persons: hasPersons,
has_locations: hasLocations,
total_entities: item.json.total || 0
}
});
}
return items;
```
---
## 🧪 ТЕСТИРОВАНИЕ
### Тест 1: Проверка API
```bash
curl http://185.197.75.249:8004/health
```
Ожидаем: `{"status":"healthy","natasha":"ready"}`
### Тест 2: Извлечение организации
```bash
curl -X POST http://185.197.75.249:8004/extract_simple \
-H 'Content-Type: application/json' \
-d '{"text": "ООО Рога и Копыта", "max_length": 5000}'
```
Ожидаем: `{"organizations": ["ООО"], ...}`
### Тест 3: Извлечение адреса
```bash
curl -X POST http://185.197.75.249:8004/extract_simple \
-H 'Content-Type: application/json' \
-d '{"text": "г. Москва, ул. Ленина, д. 1", "max_length": 5000}'
```
Ожидаем: `{"locations": ["Москва", "Ленина"], ...}`
---
## 🔥 ГОТОВЫЙ cURL ДЛЯ КОПИРОВАНИЯ
**Для критерия 1 (ИНН/ОГРН):**
```bash
curl -X POST 'http://185.197.75.249:8004/extract_simple' -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"text":"ИП Фролов С.А. ИНН: 8707003759, ОГРН: 1028700516476","max_length":5000}'
```
**Для критерия 2 (Адрес):**
```bash
curl -X POST 'http://185.197.75.249:8004/extract_simple' -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"text":"Юридический адрес: 689400, г. Певек, ул. Пугачева, 42","max_length":5000}'
```
---
## 📝 ДОКУМЕНТАЦИЯ API
**Swagger UI доступен по адресу:**
- Локально: `http://localhost:8004/docs`
- Извне: `http://185.197.75.249:8004/docs`
---
## ⚡ ПРОИЗВОДИТЕЛЬНОСТЬ
- **Скорость:** ~100-200ms на запрос
- **Лимит текста:** 5000 символов (настраивается через `max_length`)
- **Параллельность:** Поддерживает множество одновременных запросов
---
## 🐛 TROUBLESHOOTING
### Ошибка: Connection refused
**Решение:** Проверь, что API запущен:
```bash
ps aux | grep natasha_ner_api
```
Если не запущен:
```bash
cd /root/engine/public_oversight/hotels
python3 -m uvicorn natasha_ner_api:app --host 0.0.0.0 --port 8004 --reload
```
### Ошибка: 500 Internal Server Error
**Решение:** Проверь логи:
```bash
tail -f /root/engine/public_oversight/hotels/nohup.out
```
---
## ✅ ПРОВЕРЕНО
- ✅ API работает (13.10.2025 19:37)
- ✅ cURL валидный
- ✅ Протестирован на реальных данных
- ✅ Извлекает: организации, адреса, имена
- ✅ Готов для импорта в n8n
---
**Автор:** AI Assistant + Фёдор
**Дата:** 13 октября 2025
**Версия:** 1.0

218
N8N_SETUP.md Normal file
View File

@@ -0,0 +1,218 @@
# 🤖 НАСТРОЙКА AI AGENT В n8n ДЛЯ АУДИТА ОТЕЛЕЙ
## 📋 **ФАЙЛЫ ДЛЯ НАСТРОЙКИ:**
1. **`prompt.txt`** (21 KB) - Полный системный промпт
2. **`prompt_short.txt`** (2.1 KB) - Краткий промпт (если не влезет полный)
3. **`questions_17.txt`** (3 KB) - 17 вопросов в текстовом формате
4. **`questions_17.json`** (6.1 KB) - 17 вопросов в JSON формате
---
## 🔧 **ПОШАГОВАЯ НАСТРОЙКА n8n:**
### **ШАГ 1: Создай AI Agent Node**
1. Добавь ноду **"AI Agent"**
2. Выбери модель: **Ollama** (или GPT-4o-mini)
3. В поле **"System Message"** вставь содержимое из `prompt_short.txt`
### **ШАГ 2: Подключи Vector Store**
1. Добавь ноду **"Postgres Vector Store"**
2. Настрой подключение к БД:
- Host: `147.45.189.234`
- Port: `5432`
- Database: `default_db`
- User: `gen_user`
- Password: `2~~9_^kVsU?2^S`
3. Укажи таблицу: `hotel_website_chunks`
4. Колонка с эмбеддингами: `embedding`
5. Колонка с текстом: `text`
### **ШАГ 3: Создай Loop для 17 вопросов**
1. Добавь ноду **"Code"** с содержимым из `questions_17.json`
2. Код для генерации 17 items:
```javascript
const questions = $input.item.json.questions;
return questions.map(q => ({ json: q }));
```
3. Подключи к **"Loop Over Items"**
### **ШАГ 4: Настрой AI Agent в Loop**
Для каждого вопроса:
1. AI Agent получает вопрос из `{{ $json.question }}`
2. AI Agent ищет в Vector Store
3. AI Agent возвращает ответ в формате:
```
✅ ДА, найдено.
📄 Цитата: "..."
🔗 URL: https://...
📊 Детали: ...
```
### **ШАГ 5: Обработка ответов**
Добавь ноду **"Code"** для парсинга ответов:
```javascript
const answer = $input.item.json.output;
// Проверка на наличие информации
const isFound = answer.includes('✅ ДА') || answer.includes('найдено');
const isNotFound = answer.includes('❌ НЕТ') || answer.includes('не найдено');
// Извлечение цитаты
const quoteMatch = answer.match(/📄 Цитата: "(.+?)"/s);
const quote = quoteMatch ? quoteMatch[1] : '';
// Извлечение URL
const urlMatch = answer.match(/🔗 URL: (.+)/);
const url = urlMatch ? urlMatch[1].trim() : '';
// Оценка
let score = 0.0;
if (isFound && quote && url) {
score = 1.0;
} else if (isFound && quote) {
score = 0.5;
} else if (isNotFound) {
score = 0.0;
} else {
score = 0.2;
}
return {
json: {
criterion_id: $input.item.json.id,
criterion_name: $input.item.json.name,
question: $input.item.json.question,
ai_answer: answer,
score: score,
quote: quote,
url: url,
is_found: isFound
}
};
```
---
## 📊 **СТРУКТУРА WORKFLOW:**
```
┌─────────────────┐
│ Start │
└────────┬────────┘
┌─────────────────────────────┐
│ Code: Load 17 Questions │
│ (из questions_17.json) │
└────────┬────────────────────┘
┌─────────────────────────────┐
│ Loop Over Items │
│ (17 вопросов) │
└────────┬────────────────────┘
┌─────────────────────────────┐
│ AI Agent │
│ + Postgres Vector Store │
│ (ищет ответ в chunks) │
└────────┬────────────────────┘
┌─────────────────────────────┐
│ Code: Parse Answer │
│ (извлекает цитату, URL) │
└────────┬────────────────────┘
┌─────────────────────────────┐
│ Aggregate Results │
│ (собирает все 17 ответов) │
└────────┬────────────────────┘
┌─────────────────────────────┐
│ PostgreSQL: Save Results │
│ (сохраняет в БД) │
└─────────────────────────────┘
```
---
## 🎯 **КЛЮЧЕВЫЕ ПАРАМЕТРЫ:**
### **AI Agent:**
- **Temperature:** `0.1` (низкая, для точности)
- **Max Tokens:** `500` (достаточно для ответа)
- **Top K:** `5` (количество релевантных chunks)
### **Vector Store:**
- **Similarity Threshold:** `0.7` (порог релевантности)
- **Max Results:** `5` (максимум результатов)
---
## 🚀 **ЗАПУСК:**
1. Импортируй workflow в n8n
2. Укажи `hotel_id` в начальной ноде
3. Запусти workflow
4. Получи 17 оценок по критериям
5. Сохрани результаты в БД
---
## 📈 **ОЖИДАЕМЫЙ РЕЗУЛЬТАТ:**
Для каждого из 17 критериев получишь:
- ✅ Оценка (0.0 - 1.0)
- 📄 Цитата из текста
- 🔗 URL страницы
- 📊 Детали (найденные значения)
---
## ⚠️ **ВАЖНО:**
- Критерий #6 "Роскомнадзор (реестр)" проверяется **отдельно** (не через AI Agent)
- Всего критериев: **18**, но AI Agent проверяет только **17**
- Итоговый балл: сумма всех 18 критериев (включая #6)
---
## 🔍 **ОТЛАДКА:**
Если AI Agent не находит информацию:
1. Проверь подключение к Vector Store
2. Проверь наличие эмбеддингов для отеля в `hotel_website_chunks`
3. Увеличь `Top K` до 10
4. Уменьши `Similarity Threshold` до 0.5
5. Проверь промпт - используй `prompt_short.txt`
---
## 📞 **ПОДДЕРЖКА:**
Если что-то не работает:
1. Проверь логи n8n
2. Проверь что Vector Store подключен
3. Проверь что у отеля есть chunks в БД
4. Проверь формат ответа AI Agent
**Удачи! 🚀**

226
NATASHA_API_USAGE.md Normal file
View File

@@ -0,0 +1,226 @@
# 🤖 NATASHA NER API - ИСПОЛЬЗОВАНИЕ В n8n
## 🚀 **ЗАПУСК API:**
```bash
cd /root/engine/public_oversight/hotels
python3 -m uvicorn natasha_ner_api:app --host 0.0.0.0 --port 8004 --reload
```
**API будет доступен на:** `http://localhost:8004`
---
## 📡 **ENDPOINTS:**
### **1. GET /** - Информация о сервисе
```bash
curl http://localhost:8004/
```
### **2. GET /health** - Проверка здоровья
```bash
curl http://localhost:8004/health
```
### **3. POST /extract** - Полное извлечение сущностей
```bash
curl -X POST http://localhost:8004/extract \
-H "Content-Type: application/json" \
-d '{"text": "ИП Фролов С.А. находится по адресу г. Петропавловск-Камчатский"}'
```
**Ответ:**
```json
{
"organizations": ["ИП"],
"persons": ["Фролов С.А."],
"locations": ["Петропавловск-Камчатский"],
"entities": [
{
"type": "ORG",
"text": "ИП",
"start": 0,
"end": 2
},
{
"type": "PER",
"text": "Фролов С.А.",
"start": 3,
"end": 14
},
{
"type": "LOC",
"text": "Петропавловск-Камчатский",
"start": 35,
"end": 59
}
],
"total_entities": 3
}
```
### **4. POST /extract_simple** - Упрощённое извлечение (для n8n)
```bash
curl -X POST http://localhost:8004/extract_simple \
-H "Content-Type: application/json" \
-d '{"text": "ООО Гостиница Певек, ИНН 1234567890"}'
```
**Ответ:**
```json
{
"organizations": ["ООО", "Гостиница Певек"],
"persons": [],
"locations": [],
"has_organizations": true,
"has_persons": false,
"has_locations": false,
"total": 2
}
```
---
## 🔗 **ИСПОЛЬЗОВАНИЕ В n8n:**
### **HTTP REQUEST NODE:**
**Настройки:**
- **Method:** POST
- **URL:** `http://localhost:8004/extract_simple`
- **Body:**
```json
{
"text": "{{ $json.quote }}",
"max_length": 5000
}
```
**Headers:**
- `Content-Type: application/json`
---
## 💻 **CODE NODE для обработки NER результатов:**
Используй готовый файл: `n8n_code_natasha_ner.js`
Он:
1. ✅ Проверяет критерии 1 и 2 (ИНН/ОГРН и Адрес)
2. ✅ Вызывает Natasha API
3. ✅ Комбинирует с результатами регулярок
4. ✅ Возвращает улучшенную оценку
---
## 🎯 **WORKFLOW В n8n:**
```
┌─────────────────────────────┐
│ 1. Generate 17 Criteria │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 2. Loop Over Items │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 3. PostgreSQL: Regex Search │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 4. Code: Process Results │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 5. Code: Natasha NER Check │ ← 🆕 Вызывает Natasha API
│ (n8n_code_natasha_ner.js)│
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 6. Aggregate Results │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 7. Calculate Final Score │
└─────────────────────────────┘
```
---
## 📊 **ПРИМЕР РЕЗУЛЬТАТА:**
**Критерий 1 (ИНН/ОГРН):**
```json
{
"criterion_id": 1,
"criterion_name": "Юридическая идентификация",
"score": 0.0, // Регулярки не нашли
"ner_score": 1.0, // Natasha нашла!
"ner_entities": ["ООО Гостиница Певек", "ИП Фролов С.А."],
"final_score": 1.0, // MAX(0.0, 1.0) = 1.0
"method": "Natasha NER"
}
```
**Критерий 2 (Адрес):**
```json
{
"criterion_id": 2,
"criterion_name": "Адрес",
"score": 1.0, // Регулярки нашли
"ner_score": 1.0, // Natasha тоже нашла!
"ner_entities": ["Петропавловск-Камчатский", "ул. Пограничная"],
"final_score": 1.0, // MAX(1.0, 1.0) = 1.0
"method": "Гибрид (Regex + NER)"
}
```
---
## ⚡ **ПРЕИМУЩЕСТВА:**
**Natasha находит организации**, даже если нет ИНН/ОГРН
**Natasha находит адреса**, даже если формат нестандартный
**Работает через HTTP** - легко интегрировать в n8n
**Быстрая** - обрабатывает текст за ~100-200ms
**Локальная** - не нужен интернет
---
## 🔧 **НАСТРОЙКА В n8n:**
### **Вариант 1: Используй готовый Code Node**
Вставь код из `n8n_code_natasha_ner.js` - он всё сделает автоматически!
### **Вариант 2: HTTP Request Node**
1. Добавь **HTTP Request Node**
2. URL: `http://localhost:8004/extract_simple`
3. Method: POST
4. Body: `{"text": "{{ $json.quote }}"}`
5. Обработай ответ в следующей Code Node
---
## 📞 **ПРОВЕРКА РАБОТЫ:**
```bash
# Проверка здоровья
curl http://localhost:8004/health
# Тест на русском тексте
curl -X POST http://localhost:8004/extract_simple \
-H "Content-Type: application/json" \
-d '{"text": "Гостиница Певек находится по адресу г. Певек, ул. Ленина, 10"}'
```
**Готово! Natasha API запущен на порту 8004!** 🚀

137
PROGRESS_STATUS.md Normal file
View File

@@ -0,0 +1,137 @@
# 📊 ТЕКУЩЕЕ СОСТОЯНИЕ ПРОЕКТА "ОБЩЕСТВЕННЫЙ КОНТРОЛЬ ОТЕЛЕЙ"
**Дата:** 2025-10-11 01:06
**Статус:** В процессе
---
## 🎯 ОБЩИЙ ПРОГРЕСС
```
┌─────────────────────────────────────────────────────────────┐
│ ЭТАП 1: ПАРСИНГ РЕЕСТРА TOURISM.FSA.GOV.RU │
└─────────────────────────────────────────────────────────────┘
[████████████████████████████████████████] 100% ✅ ГОТОВО
✅ 33,773 отелей - базовые данные
⚙️ 10,500 отелей - детальная информация (31.1%)
└─ ETA: ~05:33 утра
└─ 3 параллельных потока активны
┌─────────────────────────────────────────────────────────────┐
│ ЭТАП 2: ФИЛЬТРАЦИЯ САНКТ-ПЕТЕРБУРГА │
└─────────────────────────────────────────────────────────────┘
[████████████████ ] 31.8% ⚙️
📊 Всего: 1,646 отелей
✅ Обработано: 523 отеля
🌐 С сайтами: 367 (70.2%)
🔮 Прогноз к утру: ~1,155 отелей с сайтами
┌─────────────────────────────────────────────────────────────┐
│ ЭТАП 3: КРАУЛИНГ САЙТОВ ОТЕЛЕЙ │
└─────────────────────────────────────────────────────────────┘
[█ ] 0.3% 🧪 ТЕСТ
🧪 Протестировано: 1 отель
📄 Спарсено: 15 страниц
💾 Сохранено: 2.9 MB сырого HTML в PostgreSQL
Извлечено:
✅ 3 телефона
✅ 3 email
✅ Формы обратной связи
✅ Онлайн-бронирование
┌─────────────────────────────────────────────────────────────┐
│ ЭТАП 4: ВЕКТОРИЗАЦИЯ В GRAPHITI │
└─────────────────────────────────────────────────────────────┘
[█ ] 0.3% 🧪 ТЕСТ
🧪 Протестировано: 1 отель
✅ 142 эпизода в Neo4j
✅ 28 сущностей
✅ 165 связей
✅ 104 эмбеддинга (1536-мерных)
✅ Semantic search работает
┌─────────────────────────────────────────────────────────────┐
│ ЭТАП 5: АУДИТ ПО 18 КРИТЕРИЯМ │
└─────────────────────────────────────────────────────────────┘
[ ] 0% 🧪 ТЕСТ
🧪 Протестировано: 8 критериев
✅ Semantic search находит релевантные данные
⚠️ Требуется LLM для точной классификации Да/Нет
Результаты теста:
✅ Контакты: найдены (score 0.349)
✅ ПДн политика: найдена (score 0.377)
✅ Режим работы: найден (score 0.329)
❌ Адрес: не найден
❌ Претензии: не найдено
```
---
## 💾 СТРУКТУРА БАЗЫ ДАННЫХ
### PostgreSQL (147.45.189.234:5432/default_db)
**Основные таблицы:**
- `hotel_main` - 33,773 записей (35 MB)
- `hotel_additional_info` - 10,500 записей
- `hotel_sanatorium` - 340 записей
- `hotel_services` - 165,918 записей
- `hotel_rooms` - 18,825 записей
- `hotel_raw_json` - 10,500 записей (backup)
**Краулинг сайтов:**
- `hotel_website_raw` - 15 страниц (сырой HTML)
- `hotel_website_meta` - 1 отель (метаданные)
- `hotel_website_processed` - 15 страниц (очищенный текст)
### Neo4j Graphiti (localhost:7687)
**Group ID: hotel_spb**
- `Episode` - 142 узла (чанки текста)
- `Entity` - 28 узлов (извлечённые сущности)
- `Relationships` - 165 связей
---
## 🚀 АКТИВНЫЕ ПРОЦЕССЫ
| Процесс | Статус | Прогресс | ETA |
|---------|--------|----------|-----|
| Детальный парсинг (3 потока) | ⚙️ Активен | 31.1% | ~05:33 |
| Universal API (порт 9200) | ✅ Работает | - | - |
| Search API (порт 9100) | ✅ Работает | - | - |
---
## 📋 СЛЕДУЮЩИЕ ШАГИ
1.**Дождаться завершения детального парсинга** (~4 часа)
2. 🌐 **Запустить краулинг всех питерских отелей с сайтами** (~1,155 отелей)
3. 🔮 **Векторизация в Graphiti** (автоматически при краулинге)
4. 🔍 **Аудит по 18 критериям** (semantic search + LLM)
5. 📊 **Экспорт результатов в Excel**
---
**Текущий приоритет:** Запустить краулинг на 5 тестовых отелях для проверки системы?

180
QUICK_START.md Normal file
View File

@@ -0,0 +1,180 @@
# 🚀 БЫСТРЫЙ СТАРТ - Система Аудита Отелей
## 🌐 ВЕБ-ИНТЕРФЕЙС
**URL:** http://185.197.75.249:8888
### Возможности:
- 📊 Дашборд с общей статистикой
- 🗺 Выбор региона и запуск аудита
- 🏨 База всех 33,773 отелей
- 💬 Чат-бот с GPT-4o-mini
- 📋 Управление критериями аудита
---
## 📊 ТЕКУЩЕЕ СОСТОЯНИЕ
### ✅ Готово:
**База отелей:**
- 33,773 отеля из реестра FSA.GOV.RU
- Детальная информация по всем
- ~448,000 услуг собрано
**Чукотский АО (ЗАВЕРШЁН):**
- 12 отелей проверено
- 4 сайта спарсено (50 страниц)
- 262 эпизода в Graphiti
- Excel отчёт создан
- Средний балл: 3.6/18
**Санкт-Петербург:**
- 1,646 отелей
- ~1,000 с сайтами (готовы к краулингу)
---
## 🔧 ОСНОВНЫЕ СКРИПТЫ
### Проверка прогресса:
```bash
cd /root/engine/public_oversight/hotels
./check_progress.sh
```
### Краулинг сайтов региона:
```bash
# 1. Экспортировать отели региона
python3 export_region.py "Регион" > region_hotels.json
# 2. Запустить краулинг
python website_crawler_db.py region_hotels.json
# 3. Загрузить в Graphiti (автоматически в краулере)
```
### Запуск аудита:
```bash
python audit_system.py "Название региона" "group_id"
# Пример:
python audit_system.py "Чукотский автономный округ" "hotel_chukotka"
```
### Запуск веб-интерфейса:
```bash
cd /root/engine/public_oversight/hotels
source venv/bin/activate
python web_interface.py
# Доступен на http://185.197.75.249:8888
```
---
## 📊 СТРУКТУРА ДАННЫХ
### PostgreSQL (147.45.189.234:5432/default_db)
**Основные таблицы:**
```
hotel_main - 33,773 отелей (базовые данные)
hotel_raw_json - детальная информация
hotel_services - услуги
hotel_rooms - номерной фонд
hotel_sanatorium - санаторная инфраструктура
hotel_website_raw - сырой HTML со страниц
hotel_website_processed - очищенный текст
hotel_website_meta - метаданные краулинга
hotel_audit_results - результаты аудита по 18 критериям
```
### Neo4j Graphiti (localhost:7687)
**Group IDs:**
- `hotel_chukotka` - Чукотский АО (262 эпизода)
- `hotel_spb` - Санкт-Петербург (477 эпизодов)
- `hotel_spb_v2` - Питер улучшенная версия (35 эпизодов)
---
## 🎯 18 КРИТЕРИЕВ АУДИТА
1. Юридическая идентификация и верификация (ИНН, ОГРН, ОПФ, ЕГРЮЛ/ЕГРИП)
2. Адрес (юридический/фактический)
3. Контакты (телефон, email)
4. Режим работы
5. Политика ПДн (152-ФЗ)
6. Роскомнадзор (реестр)
7. Договор-оферта / Правила оказания услуг
8. Рекламации и споры
9. Цены/прайс
10. Способы оплаты
11. Онлайн-оплата
12. Онлайн-бронирование
13. FAQ
14. Доступность для ЛОВЗ
15. Партнёры/бренды
16. Команда/сотрудники
17. Уголок потребителя
18. Актуальность документов
**Логика:**
- Нет сайта → автоматически "НЕТ" по всем критериям (0/18)
- Есть сайт → проверка через semantic search + keywords
---
## 📈 ПРИМЕРЫ РЕЗУЛЬТАТОВ
**Чукотский АО:**
- Гостевой дом из бруса: **15/18** (83.3%) 🏆
- Гостиница Певек: **15/18** (83.3%)
- Отель "Чукотка": **9/18** (50%)
- 8 отелей без сайтов: **0/18**
---
## ⚡ API ENDPOINTS
### Веб-интерфейс (порт 8888):
- `GET /` - главная страница
- `GET /api/stats` - общая статистика
- `GET /api/regions` - список регионов
- `GET /api/hotels?search=` - поиск отелей
- `POST /api/chat` - чат с GPT-4o-mini
- `GET /api/criteria` - список критериев
- `POST /api/audit/run` - запуск аудита
### Graphiti (порт 9200):
- `POST /upload` - загрузка данных
- `GET /health` - статус
### Search (порт 9100):
- `POST /search` - semantic search
- `GET /health` - статус
---
## 📞 ТЕХПОДДЕРЖКА
Логи:
```bash
tail -f /root/engine/public_oversight/hotels/crawler_*.log
tail -f /root/engine/public_oversight/hotels/scraper_*.log
tail -f /root/engine/public_oversight/hotels/web_interface.log
```
Перезапуск API:
```bash
pkill -f web_interface.py
cd /root/engine/public_oversight/hotels
source venv/bin/activate
nohup python web_interface.py > web_interface.log 2>&1 &
```

153
README.md Normal file
View File

@@ -0,0 +1,153 @@
# Парсер данных об отелях tourism.fsa.gov.ru
Полный сбор данных о 33,773 средствах размещения из реестра Федерального агентства по туризму.
## 📊 Статус
### Этап 1: Базовые данные ✅ ЗАВЕРШЁН
- **33,773 отелей** загружено
- Время: 4 минуты
- Таблица: `hotel_main`
### Этап 2: Детальные данные ⚙️ В ПРОЦЕССЕ
- Запущен: 2025-10-10 23:53
- Ожидаемое завершение: ~07:24
- Осталось: ~7 часов
## 📁 Структура базы данных
```
hotel_main -- Основная информация (33,773 записей)
├── id (UUID)
├── full_name, short_name
├── status, category (звёздность)
├── region, addresses
├── owner_ogrn, owner_inn
├── phone, email, website
└── photo_ids
hotel_additional_info -- Дополнительная информация о владельце
hotel_sanatorium -- Инфраструктура санаториев (бассейн, пляж, лицензии)
hotel_services -- Детальные услуги (оборудование, сервисы)
hotel_rooms -- Номерной фонд (категории, количество, оборудование)
hotel_raw_json -- Backup сырых JSON данных
```
## 🚀 Использование
### Проверить прогресс парсинга
```bash
cd /root/engine/public_oversight/hotels
./check_progress.sh
```
### Подключение к базе данных
```python
import psycopg2
conn = psycopg2.connect(
host="147.45.189.234",
port=5432,
database="default_db",
user="gen_user",
password="2~~9_^kVsU?2\S"
)
```
### Примеры запросов
```sql
-- Все отели в Краснодарском крае
SELECT full_name, category_name, phone, email
FROM hotel_main
WHERE region_name = 'Краснодарский край';
-- Отели 5 звёзд с контактами
SELECT full_name, website_address, phone, email
FROM hotel_main
WHERE category_name = 'пять звезд'
AND (phone IS NOT NULL OR email IS NOT NULL);
-- Санатории с бассейнами
SELECT m.full_name, s.swimming_pool_info
FROM hotel_main m
JOIN hotel_sanatorium s ON m.id = s.hotel_id
WHERE s.swimming_pool_info->>'availability' = 'true';
-- Услуги конкретного отеля
SELECT service_category_name, service_name
FROM hotel_services
WHERE hotel_id = 'bd2035e9-2dff-4871-b1f1-91ef1eaee7f3';
```
## 📈 Статистика
### География (топ-10)
1. Краснодарский край - 4,193
2. Москва - 3,078
3. Московская область - 1,721
4. Санкт-Петербург - 1,646
5. Республика Крым - 1,487
6. Республика Алтай - 834
7. Ростовская область - 793
8. Республика Татарстан - 747
9. Ставропольский край - 702
10. Республика Башкортостан - 683
### Типы средств размещения
- Гостиница: 27,147 (80.4%)
- База отдыха: 4,729 (14.0%)
- Санаторий: 1,178 (3.5%)
- Кемпинг: 552 (1.6%)
- Гостевой дом: 167 (0.5%)
### Звёздность
- Без категории: 22,657 (67.1%)
- Три звезды: 5,437 (16.1%)
- Четыре звезды: 2,290 (6.8%)
- Две звезды: 2,294 (6.8%)
- Пять звёзд: 432 (1.3%)
- Одна звезда: 663 (2.0%)
## 🔧 Техническая информация
### API Endpoints
- `/api/v1/resorts/hotels/showcase` - список отелей
- `/api/v1/resorts/hotels/{id}/main` - основная информация
- `/api/v1/resorts/common/{id}/additional-info` - дополнительная информация
- `/api/v1/resorts/hotels/{id}/sanatoriumDrawer` - санаторная информация
- `/api/v1/resorts/hotels/{id}/drawer` - детальные услуги
### Скрипты
- `scraper_safe.py` - сбор базовых данных (showcase)
- `scraper_detailed.py` - сбор детальной информации (4 endpoint'а на отель)
- `check_progress.sh` - мониторинг прогресса
- `create_tables.py` - создание схемы БД
- `check_db.py` - проверка подключения к БД
### Логи
- `scraper_YYYYMMDD_HHMMSS.log` - лог базового парсинга
- `scraper_detailed_YYYYMMDD_HHMMSS.log` - лог детального парсинга
- `full_scrape.log` - вывод базового парсинга
- `detailed_scrape.log` - вывод детального парсинга
## ⚠️ Важно
- Rate limiting: 10 запросов/сек (RATE_LIMIT_DELAY = 0.1)
- Checkpoint каждые 1000 отелей
- Batch INSERT по 100 записей
- Автоматическое восстановление при сбоях
- ON CONFLICT для безопасного перезапуска
## 📞 Контакты и источники
- Источник данных: https://tourism.fsa.gov.ru
- API Base: https://tourism.fsa.gov.ru/api/v1
- Реестр: Федеральное агентство по туризму РФ
- Дата сбора: 2025-10-10

495
SESSION_HISTORY.md Normal file
View File

@@ -0,0 +1,495 @@
# 📚 ИСТОРИЯ СЕССИИ - ГИБРИДНЫЙ АУДИТ ОТЕЛЕЙ
**Дата:** 13 октября 2025
**Участник:** Фёдор
**Задача:** Создание гибридного аудита отелей с семантическим поиском, регулярками и Natasha NER
---
## 🎯 **ЧТО СДЕЛАНО:**
### **1. СЕМАНТИЧЕСКИЙ ПОИСК (BGE-M3 Embeddings)**
**Создана таблица `hotel_website_chunks`:**
- Структура: `id`, `text`, `metadata` (JSONB), `embedding` (vector 1024)
- Metadata содержит: `hotel_id`, `url`, `hotel_name`, `region_name`
- Всего обработано: **64 отеля**, **52,334 chunks**
**Создан скрипт обработки:** `process_all_hotels_embeddings.py`
- Chunk Size: 600 символов
- Chunk Overlap: 100 символов
- Batch Size: 8 chunks
- Retry логика: 3 попытки с увеличением timeout
**BGE-M3 API:**
- URL: `http://147.45.146.17:8002/embed`
- API Key: `22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89`
- Модель: BAAI/bge-m3 (1024 размерность)
**Semantic Search API:** `semantic_search_api.py`
- Порт: 8001
- Эндпоинты: `/stats`, `/search`
- Поиск по vector similarity с фильтрами
**Интеграция в веб-интерфейс:**
- Добавлен семантический поиск в чат-бот
- Развёрнуто в продакшене: `hotel.klientprav.tech`
---
### **2. ГИБРИДНЫЙ АУДИТ (Семантика + Регулярки + Natasha)**
**Установлена Natasha:**
```bash
pip install natasha --break-system-packages
```
**Создан гибридный аудит:** `hybrid_audit_chukotka.py`
- Комбинирует 3 метода: семантику (40%), регулярки (40%), NER (20%)
- Генерирует Excel отчёты с цитатами и URL
- Протестирован на 4 отелях Чукотки
**Результаты тестов:**
- Гостиница «Певек»: 4.36/18 (24.2%)
- «Гостевой дом из бруса»: 3.16/18 (17.6%)
- Отель "Чукотка": 2.64/18 (14.7%)
- «База морских экспедиций Алеут»: 2.04/18 (11.3%)
---
### **3. ИНТЕГРАЦИЯ С n8n**
**Созданы промпты для AI Agent:**
- `prompt.txt` (21 KB) - полный детальный
- `prompt_short.txt` (2.1 KB) - краткий
- **`prompt_json.txt` (3.0 KB)** - **JSON формат (РЕКОМЕНДУЕТСЯ!)**
**17 вопросов для аудита:**
- `questions_17.txt` - текстовый формат
- `questions_17.json` - JSON формат
- **Критерий #6 (Роскомнадзор) проверяется отдельно!**
**Code Nodes для n8n:**
- `n8n_code_generate_questions.js` - генерирует 17 SQL запросов
- `n8n_code_parse_json.js` - парсит JSON ответы от AI Agent
- `n8n_code_check_regex.js` - проверяет регулярками
- `n8n_code_natasha_ner.js` - вызывает Natasha NER API
**Примеры и документация:**
- `n8n_example_json.json` - примеры ответов
- `N8N_SETUP.md` - инструкция по настройке
- `N8N_FILES_SUMMARY.md` - полная сводка файлов
- `N8N_HTTP_REQUEST_NATASHA.md` - настройка HTTP Request для Natasha
---
### **4. NATASHA NER API**
**Создан FastAPI сервис:** `natasha_ner_api.py`
- Порт: **8004**
- Эндпоинты:
- `GET /` - информация
- `GET /health` - проверка здоровья
- `POST /extract` - полное извлечение
- `POST /extract_simple` - упрощённое (для n8n)
**Запуск:**
```bash
cd /root/engine/public_oversight/hotels
python3 -m uvicorn natasha_ner_api:app --host 0.0.0.0 --port 8004 --reload
```
**Доступ из n8n:**
- Локально: `http://localhost:8004`
- Извне: `http://185.197.75.249:8004`
**Примеры cURL:** `natasha_curl_example.sh`
---
## 🗄️ **БАЗА ДАННЫХ:**
### **PostgreSQL:**
- Host: `147.45.189.234`
- Port: `5432`
- Database: `default_db`
- User: `gen_user`
- Password: `2~~9_^kVsU?2^S`
### **Таблицы:**
**1. `hotel_main`** - основная информация об отелях
- `id` (UUID)
- `full_name` (TEXT)
- `region_name` (TEXT)
**2. `hotel_website_processed`** - обработанные страницы
- `hotel_id` (UUID)
- `cleaned_text` (TEXT)
- `url` (TEXT)
**3. `hotel_website_chunks`** - chunks с эмбеддингами
- `id` (UUID)
- `text` (TEXT)
- `metadata` (JSONB) - содержит `hotel_id`, `url`, `hotel_name`, `region_name`
- `embedding` (vector 1024)
**4. `hotel_audit_results`** - результаты аудита
- `hotel_id` (UUID)
- `total_score` (FLOAT)
- `criteria_results` (JSONB)
---
## 📊 **18 КРИТЕРИЕВ АУДИТА:**
1. Юридическая идентификация и верификация (ИНН, ОГРН)
2. Адрес
3. Контакты (телефон, email)
4. Режим работы
5. Политика ПДн (152-ФЗ)
6. **Роскомнадзор (реестр)** ← проверяется отдельно!
7. Договор-оферта / Правила оказания услуг
8. Рекламации и споры
9. Цены/прайс
10. Способы оплаты
11. Онлайн-оплата
12. Онлайн-бронирование
13. FAQ
14. Доступность для ЛОВЗ
15. Партнёры/бренды
16. Команда/сотрудники
17. Уголок потребителя
18. Актуальность документов
---
## 🚀 **ЗАПУЩЕННЫЕ СЕРВИСЫ:**
### **1. Web Interface (продакшн):**
- URL: `http://hotel.klientprav.tech`
- Порт: 8000
- Процесс: `python3 -m uvicorn web_interface:app --host 0.0.0.0 --port 8000 --reload`
### **2. Semantic Search API:**
- URL: `http://localhost:8001`
- Порт: 8001
- Процесс: `python3 -m uvicorn semantic_search_api:app --host 0.0.0.0 --port 8001 --reload`
### **3. BGE-M3 Embedding API:**
- URL: `http://147.45.146.17:8002`
- API Key: `22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89`
### **4. Natasha NER API:**
- URL: `http://185.197.75.249:8004`
- Порт: 8004
- Процесс: `python3 -m uvicorn natasha_ner_api:app --host 0.0.0.0 --port 8004 --reload`
---
## 📁 **КЛЮЧЕВЫЕ ФАЙЛЫ:**
### **Скрипты:**
- `process_all_hotels_embeddings.py` - обработка всех отелей в chunks
- `check_progress.py` - мониторинг прогресса
- `hybrid_audit_chukotka.py` - гибридный аудит
- `semantic_audit_chukotka.py` - только семантический аудит
- `test_comfort_hotel.py` - тест на отеле Комфорт
### **API сервисы:**
- `semantic_search_api.py` - семантический поиск
- `natasha_ner_api.py` - Natasha NER
- `web_interface.py` - веб-интерфейс с чат-ботом
### **n8n интеграция:**
- `prompt_json.txt` - промпт для AI Agent (JSON формат)
- `questions_17.json` - 17 вопросов
- `n8n_code_generate_questions.js` - генерация SQL запросов
- `n8n_code_parse_json.js` - парсинг ответов AI Agent
- `n8n_code_check_regex.js` - проверка регулярками
- `n8n_code_natasha_ner.js` - вызов Natasha NER
- `natasha_curl_example.sh` - примеры cURL
### **Документация:**
- `N8N_SETUP.md` - настройка n8n
- `N8N_FILES_SUMMARY.md` - сводка файлов
- `N8N_HTTP_REQUEST_NATASHA.md` - настройка HTTP Request
- `NATASHA_API_USAGE.md` - использование Natasha API
- `QUICK_START.md` - быстрый старт проекта
- `PROGRESS_STATUS.md` - статус проекта
### **Отчёты:**
- `hybrid_audit_chukotka_20251013_162428.xlsx` - последний гибридный отчёт
- `semantic_audit_chukotka_20251013_141737.xlsx` - семантический отчёт
- `audit_Чукотский_автономный_округ_20251012_121144.xlsx` - старый отчёт
---
## 🔄 **WORKFLOW В n8n:**
```
┌─────────────────────────────┐
│ 1. Start (hotel_id) │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 2. Code: Generate 17 SQL │ ← n8n_code_generate_questions.js
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 3. Loop Over Items (17x) │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 4. PostgreSQL: Regex Search │ ← Ищет в hotel_website_processed
│ (с агрегацией GROUP BY) │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 5. Code: Process Results │ ← Обрабатывает пустые результаты
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 6. HTTP Request: Natasha │ ← http://185.197.75.249:8004
│ (только для критериев │ /extract_simple
│ 1 и 2) │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 7. Code: Combine Scores │ ← final_score = MAX(regex, ner)
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 8. Aggregate (17 → 1) │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ 9. PostgreSQL: Save Results │
└─────────────────────────────┘
```
---
## 🧪 **РЕЗУЛЬТАТЫ ТЕСТОВ:**
### **Тест 1: Семантический аудит Чукотки**
```
Гостиница «Певек»: 10.50/18 (58.3%)
«Гостевой дом из бруса»: 8.60/18 (47.8%)
Отель "Чукотка": 7.40/18 (41.1%)
«База морских экспедиций Алеут»: 2.00/18 (11.1%)
```
### **Тест 2: Гибридный аудит Чукотки**
```
Гостиница «Певек»: 4.36/18 (24.2%)
«Гостевой дом из бруса»: 3.16/18 (17.6%)
Отель "Чукотка": 2.64/18 (14.7%)
«База морских экспедиций Алеут»: 2.04/18 (11.3%)
```
### **Тест 3: Отель "Комфорт" (Камчатка)**
```
n8n AI Agent: 6.0/17 (35.3%)
Регулярки: 5.0/17 (29.4%)
Разница: AI лучше на 1.0 балл
```
**Вывод:** Гибридный подход (AI + Regex + NER) даёт лучшие результаты!
---
## 🔧 **ТЕХНИЧЕСКИЙ СТЕК:**
### **Backend:**
- Python 3.12
- FastAPI
- PostgreSQL 14+ с pgvector
- psycopg2
- Sentence Transformers (BGE-M3)
- Natasha (NER)
### **Frontend:**
- HTML/CSS/JavaScript
- Веб-интерфейс на FastAPI
### **Automation:**
- n8n (workflow automation)
- Ollama (LLM для AI Agent)
---
## 📝 **ВАЖНЫЕ КОМАНДЫ:**
### **Запуск сервисов:**
```bash
# Web Interface (продакшн)
cd /root/engine/public_oversight/hotels
python3 -m uvicorn web_interface:app --host 0.0.0.0 --port 8000 --reload
# Semantic Search API
python3 -m uvicorn semantic_search_api:app --host 0.0.0.0 --port 8001 --reload
# Natasha NER API
python3 -m uvicorn natasha_ner_api:app --host 0.0.0.0 --port 8004 --reload
# Обработка эмбеддингов (фоновый процесс)
nohup python3 process_all_hotels_embeddings.py > embeddings_processing.log 2>&1 &
```
### **Мониторинг:**
```bash
# Проверка прогресса эмбеддингов
python3 check_progress.py
# Проверка запущенных процессов
ps aux | grep uvicorn
ps aux | grep python3
# Логи
tail -f embeddings_processing.log
```
### **Тестирование:**
```bash
# Гибридный аудит Чукотки
python3 hybrid_audit_chukotka.py
# Тест отеля Комфорт
python3 test_comfort_hotel.py
# Проверка Natasha API
curl http://localhost:8004/health
```
---
## 🌐 **ДОСТУП К СЕРВИСАМ:**
### **Из локальной сети:**
- Web Interface: `http://185.197.75.249:8000`
- Semantic Search: `http://185.197.75.249:8001`
- Natasha NER: `http://185.197.75.249:8004`
### **Из интернета:**
- Продакшн: `http://hotel.klientprav.tech`
### **SSH доступ:**
```bash
ssh root@185.197.75.249
cd /root/engine/public_oversight/hotels
```
---
## 📊 **СТАТИСТИКА:**
### **База данных:**
- Всего отелей: **116**
- Всего страниц: **52,702**
- Обработано отелей: **64**
- Всего chunks: **52,334**
- Осталось обработать: **52 отеля**
### **Регионы:**
- Всего регионов с эмбеддингами: **зависит от обработки**
- Протестированы: Чукотский АО, Камчатский край
---
## 🎯 **СЛЕДУЮЩИЕ ШАГИ:**
### **1. Завершить обработку эмбеддингов:**
```bash
# Продолжить обработку оставшихся 52 отелей
python3 process_all_hotels_embeddings.py
```
### **2. Настроить n8n workflow:**
- Импортировать промпт из `prompt_json.txt`
- Настроить Loop с 17 вопросами
- Подключить PostgreSQL для регулярок
- Добавить HTTP Request для Natasha NER
### **3. Открыть порт 8004 для Natasha API:**
```bash
sudo ufw allow 8004/tcp
```
### **4. Масштабировать на все регионы:**
- Запустить аудит для всех 116 отелей
- Сгенерировать отчёты по регионам
- Сохранить результаты в БД
---
## 🐛 **ИЗВЕСТНЫЕ ПРОБЛЕМЫ:**
1. **API таймауты:** Решено через retry логику и уменьшение batch size
2. **Дубли в PostgreSQL результатах:** Решено через `GROUP BY` и агрегацию
3. **n8n AI Agent не всегда ищет в базе:** Решено через улучшенный промпт
4. **Регулярки `\b` не работают в PostgreSQL:** Заменены на `\y` или убраны
---
## 📞 **КОНТАКТЫ И ССЫЛКИ:**
- **BGE-M3 API:** `http://147.45.146.17:8002/docs`
- **Graphiti API:** `http://185.197.75.249:9100/docs` (не используется для аудита)
- **Продакшн:** `http://hotel.klientprav.tech`
- **Сервер:** `185.197.75.249`
---
## 💾 **BACKUP И ВОССТАНОВЛЕНИЕ:**
### **Важные файлы для бэкапа:**
```bash
# Конфигурация
.env
# Скрипты
process_all_hotels_embeddings.py
hybrid_audit_chukotka.py
semantic_search_api.py
natasha_ner_api.py
web_interface.py
# n8n интеграция
prompt_json.txt
questions_17.json
n8n_code_*.js
# Документация
*.md
```
### **Восстановление сессии:**
1. SSH на сервер: `ssh root@185.197.75.249`
2. Перейти в проект: `cd /root/engine/public_oversight/hotels`
3. Проверить сервисы: `ps aux | grep uvicorn`
4. Запустить недостающие сервисы (см. "Запуск сервисов")
5. Проверить прогресс: `python3 check_progress.py`
---
## 🎉 **ДОСТИЖЕНИЯ:**
✅ Создана система семантического поиска с BGE-M3
✅ Интегрирована Natasha NER для извлечения сущностей
✅ Создан гибридный аудит (3 метода)
✅ Подготовлена полная интеграция с n8n
✅ Развёрнуто в продакшене
✅ Протестировано на реальных отелях
---
**Дата создания:** 13 октября 2025
**Автор:** AI Assistant + Фёдор
**Версия:** 1.0

152
SMART_CRAWLER_STATUS.md Normal file
View File

@@ -0,0 +1,152 @@
# 🚀 УМНЫЙ КРАУЛЕР С ПРИОРИТЕТАМИ - ЗАПУЩЕН
**Дата старта:** 14 октября 2025, 21:02
**PID:** 1776119
**Статус:** ✅ РАБОТАЕТ
**Лог:** `smart_crawler_output.log`
---
## 🎯 **СТРАТЕГИЯ КРАУЛИНГА:**
### **1⃣ ПРИОРИТЕТ 1: Почти готовые регионы (70%+)**
**Отелей:** 295
**Регионы (12):**
- Воронежская область - осталось 18 (89%)
- Амурская область - осталось 7 (89%)
- Брянская область - осталось 8 (86%)
- Алтайский край - осталось 41 (85%)
- Владимирская область - осталось 25 (85%)
- Орловская область - осталось 6 (83%)
- Архангельская область - осталось 23 (82%)
- Волгоградская область - осталось 42 (82%)
- Камчатский край - осталось 20 (80%)
- Вологодская область - осталось 33 (80%)
- Белгородская область - осталось 21 (77%)
- Астраханская область - осталось 51 (76%)
**Время:** ~1-2 часа
---
### **2⃣ ПРИОРИТЕТ 2: Крупные регионы**
**Отелей:** 5,533
**Регионы (5):**
1. г. Москва - 1,340 отелей
2. Краснодарский край - 2,297 отелей
3. Московская область - 928 отелей
4. Республика Крым - 968 отелей
5. г. Санкт-Петербург - осталось 153
**Время:** ~20-30 часов
---
### **3⃣ ПРИОРИТЕТ 3: Остальные регионы**
**Отелей:** 10,213
**Время:** ~40-50 часов
---
## 📊 **ОБЩАЯ СТАТИСТИКА:**
- **Всего к обработке:** 16,041 отелей
- **Приоритет 1:** 295 отелей (2%)
- **Приоритет 2:** 5,533 отелей (35%)
- **Приоритет 3:** 10,213 отелей (63%)
**Общее время:** ~60-80 часов (2.5-3.5 дня)
---
## ✅ **ЧТО УЛУЧШЕНО:**
### **1. Умная приоритизация:**
- ✅ Сначала **добиваем почти готовые** регионы (70%+)
- ✅ Потом **крупные** (Москва, Краснодар, Крым)
-В конце остальные
### **2. Пометка битых сайтов:**
- 🔴 **dns_error** - DNS не разрешается (сайт не существует)
- 🔴 **ssl_error** - Проблемы с SSL сертификатом
- 🔴 **connection_refused** - Сервер отклонил подключение
- 🔴 **timeout** - Таймаут (медленный сайт)
- 🔴 **http_error** - HTTP ошибка (403, 404, 500 и т.д.)
- 🔴 **no_content** - Нет контента
- 🔴 **critical_error** - Критическая ошибка
### **3. Не трогаем повторно:**
Битые сайты записываются в `hotel_website_meta` со статусом `failed` и больше не обрабатываются!
---
## 🔧 **КОМАНДЫ:**
### Проверить статус:
```bash
./check_crawler_status.sh
```
### Смотреть логи:
```bash
tail -f smart_crawler_output.log
```
### Остановить:
```bash
pkill -f smart_crawler.py
```
### Перезапустить:
```bash
nohup python3 smart_crawler.py > smart_crawler_output.log 2>&1 &
```
---
## 📈 **ОЖИДАЕМЫЕ ЭТАПЫ:**
| Этап | Отелей | Время | Завершение |
|------|--------|-------|------------|
| **Приоритет 1** | 295 | ~2 часа | 14.10 ~23:00 |
| **Приоритет 2** | 5,533 | ~30 часов | 16.10 ~03:00 |
| **Приоритет 3** | 10,213 | ~50 часов | 17.10 ~05:00 |
---
## 💾 **ЧТО СОХРАНЯЕТСЯ:**
### **Успешно скрауленные:**
1. `hotel_website_meta` - метаданные (crawl_status = 'completed')
2. `hotel_website_raw` - сырой HTML
3. `hotel_website_processed` - очищенный текст
### **Проблемные сайты:**
1. `hotel_website_meta` - запись с:
- `crawl_status = 'failed'`
- `error_message = 'ERR_NAME_NOT_RESOLVED'` (и т.д.)
- `pages_crawled = 0`
**Повторно НЕ обрабатываются!**
---
## 🏆 **ПРЕИМУЩЕСТВА:**
✅ Добивает почти готовые регионы → быстрые результаты
✅ Помечает битые сайты → не тратим время повторно
✅ Приоритизация → важные регионы первыми
✅ Сохраняет прогресс → можно перезапустить в любой момент
---
**Краулер работает в фоне! Проверим логи через час!** 🚀
---
**Создано:** 14 октября 2025, 21:03
**Автор:** AI Assistant

244
add_model.py Normal file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Скрипт для добавления новых LLM моделей
"""
import requests
import json
from typing import Dict, List
# Конфигурация
API_BASE = "http://localhost:8888"
def add_model_to_config(model_name: str, model_id: str, description: str = ""):
"""
Добавить модель в конфигурацию llm_config.py
Args:
model_name: Человекочитаемое имя (например, 'fast', 'smart')
model_id: ID модели в API (например, 'gpt-4o-mini')
description: Описание модели
"""
config_file = "/root/engine/public_oversight/hotels/llm_config.py"
# Читаем текущий конфиг
with open(config_file, 'r', encoding='utf-8') as f:
content = f.read()
# Находим секцию models и добавляем новую модель
if "'models': {" in content:
# Ищем место для вставки
models_start = content.find("'models': {")
models_end = content.find("},", models_start) + 2
# Формируем новую строку
new_model_line = f" '{model_name}': '{model_id}', # {description}\n"
# Вставляем перед закрывающей скобкой
new_content = content[:models_end-2] + new_model_line + content[models_end-2:]
# Записываем обратно
with open(config_file, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f"✅ Модель '{model_name}' добавлена в конфиг")
return True
else:
print("Не найдена секция models в конфиге")
return False
def test_model(model_id: str) -> Dict:
"""
Протестировать модель
Args:
model_id: ID модели для тестирования
Returns:
Результат теста
"""
# Переключаемся на модель
switch_response = requests.post(
f"{API_BASE}/api/llm/switch",
json={"model": model_id}
)
if switch_response.status_code != 200:
return {"error": f"Ошибка переключения: {switch_response.status_code}"}
# Тестируем модель
test_prompt = "Сколько отелей в Чукотском автономном округе?"
start_time = requests.utils.time.time()
chat_response = requests.post(
f"{API_BASE}/api/chat",
json={"message": test_prompt}
)
end_time = requests.utils.time.time()
if chat_response.status_code == 200:
data = chat_response.json()
return {
"model": model_id,
"response_time": round((end_time - start_time) * 1000, 2),
"response_length": len(data['response']),
"response_preview": data['response'][:200] + "...",
"success": True
}
else:
return {"error": f"Ошибка чата: {chat_response.status_code}"}
def list_available_models() -> List[str]:
"""Получить список доступных моделей"""
response = requests.get(f"{API_BASE}/api/llm/models")
if response.status_code == 200:
data = response.json()
return list(data['models'].values())
else:
return []
def add_custom_model():
"""Интерактивное добавление модели"""
print("=" * 60)
print("🤖 ДОБАВЛЕНИЕ НОВОЙ LLM МОДЕЛИ")
print("=" * 60)
# Получаем информацию о модели
model_name = input("Введите имя модели (например, 'custom'): ").strip()
model_id = input("Введите ID модели (например, 'gpt-4o-mini'): ").strip()
description = input("Введите описание (опционально): ").strip()
if not model_name or not model_id:
print("❌ Имя и ID модели обязательны!")
return
print(f"\n📋 Добавляем модель:")
print(f" Имя: {model_name}")
print(f" ID: {model_id}")
print(f" Описание: {description}")
confirm = input("\nПродолжить? (y/N): ").strip().lower()
if confirm != 'y':
print("❌ Отменено")
return
# Добавляем в конфиг
if add_model_to_config(model_name, model_id, description):
print(f"\n🧪 Тестируем модель {model_id}...")
# Тестируем
result = test_model(model_id)
if result.get('success'):
print(f"✅ Модель работает!")
print(f" Время ответа: {result['response_time']}ms")
print(f" Длина ответа: {result['response_length']} символов")
print(f" Превью: {result['response_preview']}")
else:
print(f"❌ Ошибка тестирования: {result.get('error')}")
print(f"\n🔄 Перезапустите веб-интерфейс для применения изменений:")
print(f" pkill -f web_interface.py")
print(f" cd /root/engine/public_oversight/hotels")
print(f" source venv/bin/activate")
print(f" python web_interface.py &")
def quick_add_popular_models():
"""Быстрое добавление популярных моделей"""
popular_models = [
("gpt35", "gpt-3.5-turbo", "GPT-3.5 Turbo (классика)"),
("gpt4v", "gpt-4-vision-preview", "GPT-4 Vision (анализ изображений)"),
("claude", "claude-3-haiku", "Claude 3 Haiku (через OpenRouter)"),
("gemini", "gemini-pro", "Gemini Pro (через OpenRouter)"),
]
print("🚀 Быстрое добавление популярных моделей:")
for i, (name, model_id, desc) in enumerate(popular_models, 1):
print(f"{i}. {desc}")
choice = input("\nВыберите номер модели (1-4) или 0 для выхода: ").strip()
try:
choice_idx = int(choice) - 1
if 0 <= choice_idx < len(popular_models):
name, model_id, desc = popular_models[choice_idx]
add_model_to_config(name, model_id, desc)
print(f"✅ Модель {desc} добавлена!")
else:
print("❌ Неверный выбор")
except ValueError:
print("❌ Введите число")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
command = sys.argv[1]
if command == "list":
print("📋 Доступные модели:")
models = list_available_models()
for model in models:
print(f" - {model}")
elif command == "test":
if len(sys.argv) > 2:
model_id = sys.argv[2]
print(f"🧪 Тестируем модель: {model_id}")
result = test_model(model_id)
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print("❌ Укажите ID модели для тестирования")
elif command == "add":
if len(sys.argv) > 3:
name, model_id = sys.argv[2], sys.argv[3]
description = sys.argv[4] if len(sys.argv) > 4 else ""
add_model_to_config(name, model_id, description)
else:
print("❌ Использование: python add_model.py add <name> <model_id> [description]")
else:
print("❌ Неизвестная команда")
else:
# Интерактивный режим
print("🤖 УПРАВЛЕНИЕ LLM МОДЕЛЯМИ")
print("1. Добавить модель вручную")
print("2. Быстрое добавление популярных моделей")
print("3. Список доступных моделей")
print("4. Тест модели")
choice = input("\nВыберите действие (1-4): ").strip()
if choice == "1":
add_custom_model()
elif choice == "2":
quick_add_popular_models()
elif choice == "3":
models = list_available_models()
print("📋 Доступные модели:")
for model in models:
print(f" - {model}")
elif choice == "4":
model_id = input("Введите ID модели для тестирования: ").strip()
if model_id:
result = test_model(model_id)
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print("❌ Неверный выбор")

630
audit_chukotka_to_excel.py Executable file
View File

@@ -0,0 +1,630 @@
#!/usr/bin/env python3
"""
Аудит отелей Чукотки через n8n webhook + сохранение в Excel
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import requests
import time
import json
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from openpyxl.chart import BarChart, PieChart, Reference
from openpyxl.chart.label import DataLabelList
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
WEBHOOK_URL = "https://n8n.clientright.pro/webhook/6be4a7b9-a016-4252-841f-0ebca367914f"
def get_chukotka_hotels():
"""Получить отели Чукотки с chunks и данными РКН"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT
h.id::text AS hotel_id,
h.full_name AS hotel_name,
h.region_name,
h.website_address,
h.rkn_registry_status,
h.rkn_registry_number,
h.rkn_registry_date,
h.rkn_checked_at,
COUNT(hwc.id) AS chunks_count
FROM hotel_main h
INNER JOIN hotel_website_chunks hwc ON hwc.metadata->>'hotel_id' = h.id::text
WHERE h.region_name = 'Чукотский автономный округ'
GROUP BY h.id, h.full_name, h.region_name, h.website_address,
h.rkn_registry_status, h.rkn_registry_number, h.rkn_registry_date, h.rkn_checked_at
ORDER BY h.full_name
""")
hotels = cur.fetchall()
cur.close()
conn.close()
return hotels
def save_audit_to_db(hotel_id: str, hotel_name: str, region: str, audit_result: dict):
"""Сохранить результаты аудита в БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Формируем данные для сохранения
criteria_results = audit_result.get('criteria_results', [])
total_score = audit_result.get('found', 0)
max_score = audit_result.get('total_criteria', 17)
score_percentage = audit_result.get('compliance_percentage', 0)
website = audit_result.get('website', '')
has_website = bool(website and website != 'НЕТ САЙТА')
# Добавляем РКН данные в criteria_results для полноты
rkn_criterion = {
'criterion_id': 6,
'criterion_name': 'РКН Реестр',
'found': audit_result.get('rkn_status', '').lower() == 'found',
'rkn_status': audit_result.get('rkn_status'),
'rkn_number': audit_result.get('rkn_number'),
'rkn_date': audit_result.get('rkn_date')
}
# Вставляем РКН критерий на позицию 6 (после критерия 5)
criteria_with_rkn = criteria_results[:5] + [rkn_criterion] + criteria_results[5:]
# Сохраняем в БД (обновляем если уже есть)
cur.execute("""
INSERT INTO hotel_audit_results (
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_version
) VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s,
'v1.0_with_rkn'
)
ON CONFLICT (hotel_id, audit_version)
DO UPDATE SET
region_name = EXCLUDED.region_name,
hotel_name = EXCLUDED.hotel_name,
website = EXCLUDED.website,
has_website = EXCLUDED.has_website,
criteria_results = EXCLUDED.criteria_results,
total_score = EXCLUDED.total_score,
max_score = EXCLUDED.max_score,
score_percentage = EXCLUDED.score_percentage,
audit_date = CURRENT_TIMESTAMP
""", (
hotel_id, region, hotel_name, website, has_website,
json.dumps(criteria_with_rkn, ensure_ascii=False),
total_score, max_score, score_percentage
))
conn.commit()
cur.close()
conn.close()
print(f" 💾 Сохранено в БД")
except Exception as e:
print(f" ⚠️ Ошибка сохранения в БД: {e}")
def audit_hotel(hotel_id: str, hotel_name: str) -> dict:
"""Запустить аудит отеля через webhook"""
try:
print(f" 🔍 Аудит: {hotel_name[:50]}...")
response = requests.post(
WEBHOOK_URL,
json={"hotel_id": hotel_id},
timeout=400 # 6+ минут таймаут для обхода Nginx
)
if response.status_code == 200:
data = response.json()
print(f" ✅ Готово! Найдено: {data[0]['found']}/{data[0]['total_criteria']}")
return data[0]
else:
print(f" ❌ Ошибка {response.status_code}")
return None
except requests.Timeout:
print(f" ⏱️ Таймаут (>180 сек)")
return None
except Exception as e:
print(f" ❌ Ошибка: {e}")
return None
def create_excel_report(results: list, filename: str = "audit_chukotka.xlsx"):
"""Создать Excel отчёт в горизонтальном формате"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит"
# Стили
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=10)
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# ЗАГОЛОВКИ (строка 1)
# Базовые колонки
col = 1
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
for header in base_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
col += 1
# Заголовки критериев (каждый критерий - 3 колонки)
if results and 'criteria_results' in results[0]:
for criterion_idx, criterion in enumerate(results[0]['criteria_results']):
# Вставляем РКН заголовки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
if 'Номер' in header:
ws.column_dimensions[get_column_letter(col)].width = 30
elif 'Ссылка' in header:
ws.column_dimensions[get_column_letter(col)].width = 50
else:
ws.column_dimensions[get_column_letter(col)].width = 20
col += 1
criterion_name = f"{criterion['criterion_id']}. {criterion['criterion_name']}"
# Колонка 1: Статус (ДА/НЕТ)
cell = ws.cell(row=1, column=col, value=criterion_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 35
col += 1
# Колонка 2: URL
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Апрув URL")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 40
col += 1
# Колонка 3: Цитата/Детали
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Комментарий")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 50
col += 1
# Высота строки заголовков
ws.row_dimensions[1].height = 40
# ДАННЫЕ (строки 2+)
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
# Колонка A: Название отеля
cell = ws.cell(row=row_idx, column=col, value=result['hotel_name'])
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Колонка B: Сайт
cell = ws.cell(row=row_idx, column=col, value=result.get('website', 'НЕТ САЙТА'))
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка C: Есть сайт
has_website = "Да" if result.get('website') and result.get('website') != 'НЕТ САЙТА' else "Нет"
cell = ws.cell(row=row_idx, column=col, value=has_website)
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
# Колонка D: Балл (количество найденных)
cell = ws.cell(row=row_idx, column=col, value=result['found'])
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
# Колонка E: Процент
perc_cell = ws.cell(row=row_idx, column=col, value=f"{result['compliance_percentage']:.1f}%")
perc_cell.border = border
perc_cell.alignment = Alignment(horizontal='center', vertical='center')
if result['compliance_percentage'] >= 70:
perc_cell.fill = found_fill
elif result['compliance_percentage'] < 50:
perc_cell.fill = not_found_fill
col += 1
# Данные по критериям (каждый критерий - 3 колонки)
for criterion_idx, criterion in enumerate(result.get('criteria_results', [])):
# Вставляем РКН колонки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
# Колонка 1: Статус (ДА/НЕТ)
rkn_status = result.get('rkn_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = not_found_fill # Красный - плохо если в реестре
else:
rkn_status_cell.fill = found_fill # Зелёный - хорошо если НЕ в реестре
col += 1
# Колонка 2: Номер и дата
rkn_number = result.get('rkn_number', '')
rkn_date = result.get('rkn_date', '')
rkn_info = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Колонка 3: Ссылка на реестр
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 1: Статус (ДА/НЕТ)
status = "ДА" if criterion['found'] else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion['found']:
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# Колонка 2: URL
url = criterion['ai_agent']['url'] if criterion['ai_agent']['url'] else '-'
cell = ws.cell(row=row_idx, column=col, value=url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 3: Комментарий/Цитата
if criterion['found']:
# Приоритет: AI детали → AI цитата → Regex извлечение → "Найдено"
comment = ""
# Если AI нашёл - берём его данные
if criterion['ai_agent']['found']:
comment = criterion['ai_agent']['details'] or criterion['ai_agent']['quote']
# Если AI не нашёл, но regex нашёл - берём regex
if not comment or "отсутствует" in comment.lower() or "не найден" in comment.lower():
if criterion['regex']['found'] and criterion['regex']['extracted']:
comment = f"[Regex] {criterion['regex']['extracted']}"
# Если всё ещё пусто
if not comment:
comment = "Найдено"
# Ограничиваем длину
comment = comment[:200] + "..." if len(comment) > 200 else comment
else:
comment = "Не найдено"
cell = ws.cell(row=row_idx, column=col, value=comment)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
# Ширина базовых колонок
ws.column_dimensions['A'].width = 40 # Отель
ws.column_dimensions['B'].width = 25 # Сайт
ws.column_dimensions['C'].width = 12 # Есть сайт
ws.column_dimensions['D'].width = 8 # Балл
ws.column_dimensions['E'].width = 10 # Процент
# Закрепить первую строку
ws.freeze_panes = 'A2'
# Создаём дашборд на отдельном листе
create_dashboard(wb, results)
wb.save(filename)
print(f"\n✅ Excel отчёт сохранён: {filename}")
def create_dashboard(wb, results):
"""Создать дашборд с графиками и статистикой"""
# Создаём новый лист для дашборда
ws = wb.create_sheet("📊 Дашборд", 0) # Вставляем первым
# Стили
title_font = Font(size=16, bold=True, color="366092")
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
value_font = Font(size=14, bold=True)
green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# Заголовок
ws['A1'] = '📊 ДАШБОРД АУДИТА ОТЕЛЕЙ'
ws['A1'].font = title_font
ws.merge_cells('A1:F1')
# Общая статистика (строки 3-10)
row = 3
ws[f'A{row}'] = 'ОБЩАЯ СТАТИСТИКА'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
total_hotels = len(results)
hotels_with_website = sum(1 for r in results if r.get('website') and r.get('website') != 'НЕТ САЙТА')
hotels_without_website = total_hotels - hotels_with_website
# Считаем РКН
hotels_in_rkn = sum(1 for r in results if r.get('rkn_status', '').lower() == 'found')
avg_score = sum(r['compliance_percentage'] for r in results) / total_hotels if total_hotels > 0 else 0
stats = [
('Всего отелей:', total_hotels, None),
('С сайтами:', hotels_with_website, green_fill),
('Без сайтов:', hotels_without_website, red_fill),
('В реестре РКН:', hotels_in_rkn, red_fill if hotels_in_rkn > 0 else green_fill),
('Средний балл:', f"{avg_score:.1f}%", yellow_fill if avg_score < 50 else green_fill),
]
for label, value, fill in stats:
ws[f'A{row}'] = label
ws[f'B{row}'] = value
ws[f'B{row}'].font = value_font
if fill:
ws[f'B{row}'].fill = fill
ws[f'B{row}'].alignment = Alignment(horizontal='center')
row += 1
# Данные для графика "Наличие сайтов" (строки 12-15)
row = 12
ws[f'A{row}'] = 'Категория'
ws[f'B{row}'] = 'Количество'
ws[f'A{row}'].fill = header_fill
ws[f'B{row}'].fill = header_fill
ws[f'A{row}'].font = header_font
ws[f'B{row}'].font = header_font
row += 1
ws[f'A{row}'] = 'С сайтами'
ws[f'B{row}'] = hotels_with_website
row += 1
ws[f'A{row}'] = 'Без сайтов'
ws[f'B{row}'] = hotels_without_website
row += 1
ws[f'A{row}'] = 'В реестре РКН'
ws[f'B{row}'] = hotels_in_rkn
# Круговая диаграмма "Наличие сайтов"
pie = PieChart()
labels = Reference(ws, min_col=1, min_row=13, max_row=15)
data = Reference(ws, min_col=2, min_row=12, max_row=15)
pie.add_data(data, titles_from_data=True)
pie.set_categories(labels)
pie.title = "Распределение отелей"
pie.height = 10
pie.width = 15
# Добавляем метки данных
pie.dataLabels = DataLabelList()
pie.dataLabels.showPercent = True
pie.dataLabels.showVal = True
ws.add_chart(pie, "D3")
# Статистика по критериям (строки 18+)
row = 18
ws[f'A{row}'] = 'СТАТИСТИКА ПО КРИТЕРИЯМ'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:C{row}')
row += 1
ws[f'A{row}'] = 'Критерий'
ws[f'B{row}'] = 'Найдено'
ws[f'C{row}'] = 'Не найдено'
for col in ['A', 'B', 'C']:
ws[f'{col}{row}'].fill = header_fill
ws[f'{col}{row}'].font = header_font
row += 1
# Собираем статистику по каждому критерию
if results and 'criteria_results' in results[0]:
criteria_stats = []
for criterion in results[0]['criteria_results']:
criterion_id = criterion['criterion_id']
criterion_name = f"{criterion_id}. {criterion['criterion_name'][:30]}"
found_count = sum(1 for r in results
for c in r['criteria_results']
if c['criterion_id'] == criterion_id and c['found'])
not_found_count = total_hotels - found_count
criteria_stats.append((criterion_name, found_count, not_found_count))
# Добавляем РКН как критерий #6
rkn_found = sum(1 for r in results if r.get('rkn_status', '').lower() != 'found') # НЕ в реестре = хорошо
rkn_not_found = total_hotels - rkn_found
# Вставляем РКН на позицию 6
criteria_stats_with_rkn = criteria_stats[:5] + [('6. РКН Реестр (чисто)', rkn_found, rkn_not_found)] + criteria_stats[5:]
start_row = row
for criterion_name, found, not_found in criteria_stats_with_rkn:
ws[f'A{row}'] = criterion_name
ws[f'B{row}'] = found
ws[f'C{row}'] = not_found
row += 1
# Столбчатая диаграмма по критериям
chart = BarChart()
chart.type = "col"
chart.style = 10
chart.title = "Результаты по критериям"
chart.y_axis.title = 'Количество отелей'
chart.x_axis.title = 'Критерии'
data = Reference(ws, min_col=2, min_row=start_row-1, max_row=row-1, max_col=3)
cats = Reference(ws, min_col=1, min_row=start_row, max_row=row-1)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
chart.height = 15
chart.width = 25
ws.add_chart(chart, f"E{start_row}")
# Распределение по баллам (строки row+2)
row += 2
ws[f'A{row}'] = 'РАСПРЕДЕЛЕНИЕ ПО БАЛЛАМ'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
ws[f'A{row}'] = 'Диапазон'
ws[f'B{row}'] = 'Количество'
ws[f'A{row}'].fill = header_fill
ws[f'B{row}'].fill = header_fill
ws[f'A{row}'].font = header_font
ws[f'B{row}'].font = header_font
row += 1
# Распределение по диапазонам
ranges = [
('0-25%', 0, 25),
('26-50%', 26, 50),
('51-75%', 51, 75),
('76-100%', 76, 100)
]
start_row = row
for range_name, min_val, max_val in ranges:
count = sum(1 for r in results
if min_val <= r['compliance_percentage'] <= max_val)
ws[f'A{row}'] = range_name
ws[f'B{row}'] = count
row += 1
# Столбчатая диаграмма распределения
chart2 = BarChart()
chart2.type = "col"
chart2.style = 11
chart2.title = "Распределение по баллам"
chart2.y_axis.title = 'Количество отелей'
data = Reference(ws, min_col=2, min_row=start_row-1, max_row=row-1)
cats = Reference(ws, min_col=1, min_row=start_row, max_row=row-1)
chart2.add_data(data, titles_from_data=True)
chart2.set_categories(cats)
chart2.height = 10
chart2.width = 15
ws.add_chart(chart2, f"D{start_row-1}")
# Настройка ширины колонок
ws.column_dimensions['A'].width = 35
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
print(" 📊 Дашборд создан")
def main():
print("🚀 ЗАПУСК АУДИТА ЧУКОТКИ\n" + "="*60)
# Получаем отели
hotels = get_chukotka_hotels()
print(f"📊 Найдено отелей Чукотки с chunks: {len(hotels)}\n")
if not hotels:
print("❌ Нет отелей для аудита")
return
# Аудитируем
results = []
for idx, hotel in enumerate(hotels, 1):
print(f"\n[{idx}/{len(hotels)}] {hotel['hotel_name']}")
print(f" 🔗 {hotel['website_address']}")
print(f" 📦 Chunks: {hotel['chunks_count']}")
audit_result = audit_hotel(hotel['hotel_id'], hotel['hotel_name'])
if audit_result:
audit_result['website'] = hotel['website_address']
# Добавляем данные РКН
audit_result['rkn_status'] = hotel.get('rkn_registry_status')
audit_result['rkn_number'] = hotel.get('rkn_registry_number')
audit_result['rkn_date'] = hotel.get('rkn_registry_date')
audit_result['rkn_checked_at'] = hotel.get('rkn_checked_at')
# Сохраняем в БД
save_audit_to_db(
hotel['hotel_id'],
hotel['hotel_name'],
hotel['region_name'],
audit_result
)
results.append(audit_result)
# Пауза между запросами
if idx < len(hotels):
time.sleep(2)
else:
print(f" ⚠️ Пропускаем отель")
# Создаём Excel
if results:
print(f"\n📊 ИТОГОВАЯ СТАТИСТИКА\n" + "="*60)
print(f"Обработано отелей: {len(results)}/{len(hotels)}")
avg_compliance = sum(r['compliance_percentage'] for r in results) / len(results)
print(f"Средний % соответствия: {avg_compliance:.1f}%")
filename = f"audit_chukotka_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
create_excel_report(results, filename)
else:
print("\n❌ Нет результатов для отчёта")
if __name__ == '__main__':
main()

219
audit_orel_to_excel.py Normal file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Аудит отелей Орловской области через n8n webhook + сохранение в Excel
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import requests
import time
import json
from datetime import datetime
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
WEBHOOK_URL = "https://n8n.clientright.pro/webhook/6be4a7b9-a016-4252-841f-0ebca367914f"
def get_orel_hotels():
"""Получить отели Орловской области с chunks и данными РКН"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT
h.id::text AS hotel_id,
h.full_name AS hotel_name,
h.region_name,
h.website_address,
h.rkn_registry_status,
h.rkn_registry_number,
h.rkn_registry_date,
h.rkn_checked_at,
COUNT(hwc.id) AS chunks_count
FROM hotel_main h
LEFT JOIN hotel_website_chunks hwc ON hwc.metadata->>'hotel_id' = h.id::text
WHERE h.region_name = 'Орловская область'
GROUP BY h.id, h.full_name, h.region_name, h.website_address,
h.rkn_registry_status, h.rkn_registry_number, h.rkn_registry_date, h.rkn_checked_at
ORDER BY h.full_name
""")
hotels = cur.fetchall()
cur.close()
conn.close()
return hotels
def save_audit_to_db(hotel_id: str, hotel_name: str, region: str, audit_result: dict):
"""Сохранить результаты аудита в БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Формируем данные для сохранения
criteria_results = audit_result.get('criteria_results', [])
total_score = audit_result.get('found', 0)
max_score = audit_result.get('total_criteria', 17)
score_percentage = audit_result.get('compliance_percentage', 0)
website = audit_result.get('website', '')
has_website = bool(website and website != 'НЕТ САЙТА')
# Добавляем РКН данные в criteria_results для полноты
rkn_criterion = {
'criterion_id': 6,
'criterion_name': 'РКН Реестр',
'found': audit_result.get('rkn_status', '').lower() == 'found',
'rkn_status': audit_result.get('rkn_status'),
'rkn_number': audit_result.get('rkn_number'),
'rkn_date': audit_result.get('rkn_date')
}
# Вставляем РКН критерий на позицию 6 (после критерия 5)
criteria_with_rkn = criteria_results[:5] + [rkn_criterion] + criteria_results[5:]
# Сохраняем в БД (обновляем если уже есть)
cur.execute("""
INSERT INTO hotel_audit_results (
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_version
) VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s,
'v1.0_with_rkn'
)
ON CONFLICT (hotel_id, audit_version)
DO UPDATE SET
region_name = EXCLUDED.region_name,
hotel_name = EXCLUDED.hotel_name,
website = EXCLUDED.website,
has_website = EXCLUDED.has_website,
criteria_results = EXCLUDED.criteria_results,
total_score = EXCLUDED.total_score,
max_score = EXCLUDED.max_score,
score_percentage = EXCLUDED.score_percentage,
audit_date = CURRENT_TIMESTAMP
""", (
hotel_id, region, hotel_name, website, has_website,
json.dumps(criteria_with_rkn, ensure_ascii=False),
total_score, max_score, score_percentage
))
conn.commit()
cur.close()
conn.close()
print(f" 💾 Сохранено в БД")
except Exception as e:
print(f" ⚠️ Ошибка сохранения в БД: {e}")
def audit_hotel(hotel_id: str, hotel_name: str) -> dict:
"""Запустить аудит отеля через webhook"""
try:
print(f" 🔍 Аудит: {hotel_name[:50]}...")
response = requests.post(
WEBHOOK_URL,
json={"hotel_id": hotel_id},
timeout=400 # 6+ минут таймаут для обхода Nginx
)
if response.status_code == 200:
data = response.json()
print(f" ✅ Готово! Найдено: {data[0]['found']}/{data[0]['total_criteria']}")
return data[0]
else:
print(f" ❌ Ошибка {response.status_code}: {response.text[:200]}")
return None
except requests.Timeout:
print(f" ⏱️ Таймаут (>400 сек)")
return None
except Exception as e:
print(f" ❌ Ошибка: {e}")
return None
def main():
print("🚀 ЗАПУСК АУДИТА ОРЛОВСКОЙ ОБЛАСТИ\n" + "="*60)
# Получаем отели
hotels = get_orel_hotels()
print(f"📊 Найдено отелей Орловской области: {len(hotels)}")
# Разделяем на отели с chunks и без
hotels_with_chunks = [h for h in hotels if h['chunks_count'] > 0]
hotels_without_chunks = [h for h in hotels if h['chunks_count'] == 0]
print(f"С chunks: {len(hotels_with_chunks)}")
print(f" ⚠️ Без chunks: {len(hotels_without_chunks)}")
if hotels_without_chunks:
print(f"\n⚠️ Отели БЕЗ chunks (будут пропущены):")
for hotel in hotels_without_chunks[:10]:
print(f" - {hotel['hotel_name']}")
if len(hotels_without_chunks) > 10:
print(f" ... и еще {len(hotels_without_chunks) - 10}")
if not hotels_with_chunks:
print("\n❌ Нет отелей с chunks для аудита")
return
print(f"\n🎯 Будет проаудировано: {len(hotels_with_chunks)} отелей\n")
print("🚀 Запускаю аудит...\n")
# Аудитируем только отели с chunks
results = []
success_count = 0
error_count = 0
for idx, hotel in enumerate(hotels_with_chunks, 1):
print(f"\n[{idx}/{len(hotels_with_chunks)}] {hotel['hotel_name']}")
print(f" 🔗 {hotel['website_address'] or 'НЕТ САЙТА'}")
print(f" 📦 Chunks: {hotel['chunks_count']}")
audit_result = audit_hotel(hotel['hotel_id'], hotel['hotel_name'])
if audit_result:
audit_result['website'] = hotel['website_address'] or 'НЕТ САЙТА'
# Добавляем данные РКН
audit_result['rkn_status'] = hotel.get('rkn_registry_status')
audit_result['rkn_number'] = hotel.get('rkn_registry_number')
audit_result['rkn_date'] = hotel.get('rkn_registry_date')
audit_result['rkn_checked_at'] = hotel.get('rkn_checked_at')
# Сохраняем в БД
save_audit_to_db(
hotel['hotel_id'],
hotel['hotel_name'],
hotel['region_name'],
audit_result
)
results.append(audit_result)
success_count += 1
else:
error_count += 1
# Небольшая задержка между запросами
if idx < len(hotels_with_chunks):
time.sleep(2)
# Статистика
print("\n" + "="*60)
print(f"📊 ИТОГО:")
print(f" ✅ Успешно: {success_count}")
print(f" ❌ Ошибок: {error_count}")
print(f" 📝 Всего отелей обработано: {len(results)}")
print(f" 💾 Результаты сохранены в таблицу hotel_audit_results")
print(f"\n🎉 Аудит завершен!")
if __name__ == "__main__":
main()

1085
audit_system.py Normal file

File diff suppressed because it is too large Load Diff

1085
audit_system_backup.py Normal file

File diff suppressed because it is too large Load Diff

930
audit_system_new.py Normal file
View File

@@ -0,0 +1,930 @@
#!/usr/bin/env python3
"""
Система аудита отелей по 18 критериям с новой логикой оценки доступности информации
- 1.0 балл: прямая ссылка на страницу (видна с главной)
- 0.5 балла: информация найдена, но спрятана глубоко
- 0 баллов: информация не найдена
"""
import psycopg2
from psycopg2.extras import Json
import requests
import json
import logging
from datetime import datetime
from urllib.parse import unquote
from typing import List, Dict, Optional
import pandas as pd
import re
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
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")
}
# 18 критериев аудита с улучшенными паттернами для поиска прямых ссылок
AUDIT_CRITERIA = [
{
'id': 1,
'name': 'Юридическая идентификация и верификация',
'query': 'полное наименование организации ОПФ ИНН ОГРН ЕГРЮЛ ЕГРИП проверить',
'keywords': ['инн', 'огрн', 'егрюл', 'егрип', 'организация', 'ооо', 'ип'],
'required_patterns': [
r'\b\d{10}\b', # ИНН юридического лица (10 цифр)
r'\b\d{12}\b', # ИНН ИП (12 цифр)
r'\b\d{13}\b', # ОГРН (13 цифр)
r'\b\d{15}\b', # ОГРНИП (15 цифр)
r'инн\s*:?\s*\d{10,12}', # ИНН: 1234567890 или 123456789012
r'огрн\s*:?\s*\d{13}', # ОГРН: 1234567890123
r'огрнип\s*:?\s*\d{15}', # ОГРНИП: 123456789012345
],
'direct_link_patterns': ['реквизиты', 'о компании', 'контакты', 'сведения', 'информация', 'about', 'company', 'details']
},
{
'id': 2,
'name': 'Адрес',
'query': 'юридический адрес фактический адрес местонахождение',
'keywords': ['адрес', 'address', 'местонахождение', 'г.', 'ул.'],
'priority_patterns': [
r'\d{6}.*?ул\.', # Индекс (689251) + что-то + ул.
r'ул\.\s*[А-Яа-яёЁA-Za-z\s]+,?\s*\d+', # ул. Название, дом
],
'direct_link_patterns': ['адрес', 'контакты', 'где мы', 'местоположение', 'address', 'location', 'contacts', 'найти нас']
},
{
'id': 3,
'name': 'Контакты',
'query': 'телефон email форма обратной связи чат контакты',
'keywords': ['телефон', 'phone', 'email', '@', '+7', '8-800'],
'priority_patterns': [
r'(?:\+7|8)\s*\(?\d{3,5}\)?\s*\d{1,3}[-\s]?\d{2}[-\s]?\d{2}', # Телефон: +7(xxx)xxx-xx-xx или 8(xxx)xxx-xx-xx
r'[\w\.-]+@[\w\.-]+\.\w{2,}', # Email: name@domain.com
],
'direct_link_patterns': ['контакты', 'связаться', 'телефон', 'email', 'contacts', 'contact', 'связь']
},
{
'id': 4,
'name': 'Режим работы',
'query': 'часы работы график приема режим работы колл-центр',
'keywords': ['часы работы', 'график работы', 'режим работы', 'круглосуточно', '24/7', 'пн-пт', 'пн-вс', 'время работы'],
'priority_patterns': [
r'(?:с|с\s+)\d{1,2}(?::|\.)\d{2}\s*(?:до|по)\s*\d{1,2}(?::|\.)\d{2}', # с 9:00 до 18:00
r'(?:с|с\s+)\d{1,2}\s+(?:до|по)\s+\d{1,2}(?:\s+час)', # с 9 до 18 часов (с обязательным "час")
r'\d{1,2}:\d{2}\s*-\s*\d{1,2}:\d{2}', # 9:00 - 18:00
r'круглосуточно', # круглосуточно
r'24\s*[/\-]\s*7', # 24/7
],
'direct_link_patterns': ['режим работы', 'часы работы', 'график работы', 'working hours', 'schedule', 'время работы'],
'require_patterns': True # Требуем обязательное наличие паттернов времени
},
{
'id': 5,
'name': 'Политика ПДн (152-ФЗ)',
'query': 'политика персональных данных обработка ПДн 152-ФЗ',
'keywords': ['персональных данных', 'пдн', '152-фз', 'privacy'],
'priority_patterns': [
r'политика\s+в\s+отношении\s+обработки\s+персональных\s+данных', # Точное название документа
r'152[-\s]?фз', # 152-ФЗ
r'федеральный\s+закон.*?персональных\s+данных', # Федеральный закон о персональных данных
],
'direct_link_patterns': ['политика', 'персональные данные', 'конфиденциальность', 'privacy', 'policy', '152-фз']
},
{
'id': 6,
'name': 'Роскомнадзор (реестр)',
'query': 'реестр операторов персональных данных Роскомнадзор',
'keywords': ['роскомнадзор', 'реестр оператор'],
'direct_link_patterns': ['роскомнадзор', 'реестр', 'регистрация', 'registry', 'ркн']
},
{
'id': 7,
'name': 'Договор-оферта / Правила оказания услуг',
'query': 'публичная оферта договор пользовательское соглашение условия оказания услуг правила проживания бронирования размещения посещения',
'keywords': [
# Договор/оферта
'публичная оферта', 'публичный договор', 'договор оказания услуг', 'договор на оказание',
'пользовательское соглашение', 'условия договора',
# Правила/условия (специфичные для отелей)
'условия проживания', 'правила проживания', 'условия размещения', 'правила размещения',
'условия бронирования', 'правила отмены', 'условия оказания услуг', 'входит в стоимость',
'правила оказания услуг', 'порядок оказания услуг',
# Универсальные (для любых организаций)
'правила посещения', 'правила использования', 'правила и условия',
'terms and conditions', 'terms of service', 'terms of use'
],
'priority_patterns': [
r'правила\s+посещения\s+(?:парка|территории|объекта)', # "Правила посещения" как заголовок (не в меню)
r'публичная\s+оферта', # Публичная оферта
r'договор.*?оказани.*?услуг', # Договор оказания услуг
],
'direct_link_patterns': ['правила', 'условия', 'договор', 'оферта', 'правила посещения', 'rules', 'terms', 'условия оказания услуг']
},
{
'id': 8,
'name': 'Рекламации и споры',
'query': 'претензия рекламация споры возврат обмен гарантия жалоба',
'keywords': ['претензия', 'претензии', 'рекламация', 'рекламации', 'возврат средств', 'возврат денег', 'порядок рассмотрения споров', 'досудебный порядок', 'жалоба', 'жалобы', 'книга жалоб'],
'direct_link_patterns': ['рекламации', 'претензии', 'споры', 'жалобы', 'complaints', 'disputes', 'возврат']
},
{
'id': 9,
'name': 'Цены/прайс',
'query': 'цены прайс стоимость тарифы',
'keywords': ['цен', 'руб', '', 'price', 'стоимость', 'тариф', 'платн'],
'priority_urls': ['price', 'prices', 'ceny', 'tarif', 'tariff', 'stoimost'],
'direct_link_patterns': ['цены', 'прайс', 'тарифы', 'стоимость', 'price', 'tariff', 'rates', 'прайс-лист']
},
{
'id': 10,
'name': 'Способы оплаты',
'query': 'способы оплаты наличные карта СБП оплата банковская карта',
'keywords': ['способы оплаты', 'методы оплаты', 'оплата картой', 'банковская карта', 'банковские карты', 'наличные', 'наличными', 'сбп', 'qr-код', 'visa', 'mastercard', 'мир'],
'direct_link_patterns': ['оплата', 'способы оплаты', 'payment', 'pay', 'как оплатить']
},
{
'id': 11,
'name': 'Онлайн-оплата',
'query': 'онлайн оплата эквайринг оплатить online payment',
'keywords': ['онлайн оплат', 'эквайринг', 'оплатить'],
'direct_link_patterns': ['онлайн оплата', 'оплатить онлайн', 'online payment', 'pay online', 'эквайринг']
},
{
'id': 12,
'name': 'Онлайн-бронирование',
'query': 'онлайн бронирование забронировать booking',
'keywords': ['бронирован', 'забронировать', 'booking'],
'direct_link_patterns': ['бронирование', 'забронировать', 'booking', 'reserve', 'онлайн бронирование']
},
{
'id': 13,
'name': 'FAQ',
'query': 'FAQ частые вопросы вопрос-ответ часто задаваемые',
'keywords': ['faq', 'частые вопросы', 'часто задаваемые вопросы', 'часто задаваемые', 'вопрос-ответ', 'вопросы и ответы'],
'priority_urls': ['faq', 'chasto-zadavaemye', 'voprosy', 'questions'],
'direct_link_patterns': ['faq', 'частые вопросы', 'вопросы', 'questions', 'help', 'помощь']
},
{
'id': 14,
'name': 'Доступность для ЛОВЗ',
'query': 'доступность инвалиды ЛОВЗ безбарьерная среда маломобильные',
'keywords': ['для инвалидов', 'для лиц с ограниченными возможностями', 'ловз', 'доступная среда', 'безбарьерная среда', 'маломобильные граждане', 'пандус', 'поручни', 'доступность для инвалидов', 'специальные условия'],
'direct_link_patterns': ['доступность', 'для инвалидов', 'ловз', 'accessibility', 'безбарьерная среда']
},
{
'id': 15,
'name': 'Партнёры/бренды',
'query': 'партнеры поставщики бренды сотрудничество',
'keywords': ['партнер', 'поставщик', 'бренд'],
'priority_urls': ['partner', 'partners', 'partnery', 'brand', 'brands', 'sotrudnichestvo'],
'direct_link_patterns': ['партнеры', 'бренды', 'сотрудничество', 'partners', 'brands', 'поставщики']
},
{
'id': 16,
'name': 'Команда/сотрудники',
'query': 'команда сотрудники персонал руководство',
'keywords': ['команда', 'сотрудник', 'персонал', 'руководство'],
'priority_urls': ['sotrudniki', 'staff', 'team', 'komanda', 'personal', 'about-us', 'o-nas'],
'direct_link_patterns': ['сотрудники', 'команда', 'персонал', 'о нас', 'staff', 'team', 'about', 'руководство']
},
{
'id': 17,
'name': 'Уголок потребителя',
'query': 'уголок потребителя права закон защита потребителей',
'keywords': ['уголок потребител', 'права потребител', 'защита потребител'],
'direct_link_patterns': ['уголок потребителя', 'права потребителей', 'consumer', 'защита прав']
},
{
'id': 18,
'name': 'Актуальность документов',
'query': 'дата обновления дата публикации актуально версия',
'keywords': ['дата обновления', 'дата публикации', 'обновлено', 'опубликовано', 'актуально на', 'версия от', 'дата создания', 'последнее обновление'],
'direct_link_patterns': ['актуальность', 'обновления', 'версия', 'дата', 'updates', 'version', 'последнее обновление']
}
]
class AuditSystem:
def __init__(self, region_name: str, group_id: str):
self.region_name = region_name
self.group_id = group_id
self.conn = None
def connect_db(self):
self.conn = psycopg2.connect(**DB_CONFIG)
def create_audit_table(self):
"""Создать таблицу для результатов аудита"""
cur = self.conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS hotel_audit_results (
id SERIAL PRIMARY KEY,
hotel_id UUID REFERENCES hotel_main(id),
region_name TEXT,
hotel_name TEXT,
website TEXT,
has_website BOOLEAN,
-- Результаты по 18 критериям
criteria_results JSONB,
-- Общие метрики
total_score FLOAT, -- Сумма баллов (может быть дробной)
max_score FLOAT DEFAULT 18.0,
score_percentage FLOAT,
-- Мета
audit_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
audit_version TEXT,
UNIQUE(hotel_id, audit_version)
);
CREATE INDEX IF NOT EXISTS idx_audit_region ON hotel_audit_results(region_name);
CREATE INDEX IF NOT EXISTS idx_audit_score ON hotel_audit_results(total_score);
""")
self.conn.commit()
cur.close()
logger.info("✓ Таблица hotel_audit_results готова")
def check_direct_links(self, hotel_id: str, criterion: dict) -> dict:
"""Проверка прямых ссылок на главной странице - 1.0 балл"""
cur = self.conn.cursor()
# Получаем URL главной страницы
cur.execute("""
SELECT website_address FROM hotel_main WHERE id = %s
""", (hotel_id,))
result = cur.fetchone()
if not result:
cur.close()
return None
main_url = result[0]
if not main_url:
cur.close()
return None
# Ищем прямые ссылки на главной странице
patterns = criterion.get('direct_link_patterns', [])
for pattern in patterns:
cur.execute("""
SELECT cleaned_text, url
FROM hotel_website_processed
WHERE hotel_id = %s
AND url = %s
AND LOWER(cleaned_text) LIKE %s
LIMIT 1
""", (hotel_id, main_url, f'%{pattern}%'))
result = cur.fetchone()
if result:
text_row, url_row = result
# Проверяем что на главной странице есть ссылка на нужную информацию
if any(keyword in text_row.lower() for keyword in criterion['keywords']):
cur.close()
return {
'score': 1.0,
'verdict': 'ПРЯМАЯ_ССЫЛКА',
'confidence': 0.95,
'found_keywords': [pattern],
'approval_urls': [url_row],
'approval_quotes': [{
'url': url_row,
'quote': f"Найдена прямая ссылка: {pattern}",
'keyword': pattern
}]
}
cur.close()
return None
def check_deep_search(self, hotel_id: str, criterion: dict) -> dict:
"""Поиск информации в глубине сайта - 0.5 балла"""
cur = self.conn.cursor()
# Проверяем required_patterns для критерия 1 (юридическая идентификация)
found_required_patterns = []
if criterion.get('required_patterns'):
cur.execute("""
SELECT cleaned_text FROM hotel_website_processed
WHERE hotel_id = %s
""", (hotel_id,))
all_text = ""
for row in cur.fetchall():
all_text += " " + row[0]
for pattern in criterion['required_patterns']:
if re.search(pattern, all_text, re.IGNORECASE):
found_required_patterns.append(pattern)
# Если есть required_patterns и они найдены - 0.5 балла
if found_required_patterns:
approval_urls = []
approval_quotes = []
for pattern in found_required_patterns:
cur.execute("""
SELECT cleaned_text, url
FROM hotel_website_processed
WHERE hotel_id = %s AND cleaned_text ~* %s
LIMIT 1
""", (hotel_id, pattern))
result = cur.fetchone()
if result:
text_row, url_row = result
approval_urls.append(url_row)
# Ищем точное вхождение паттерна
match = re.search(pattern, text_row, re.IGNORECASE)
if match:
idx = match.start()
start = max(0, idx - 100)
end = min(len(text_row), idx + 200)
quote_text = text_row[start:end].strip()
else:
quote_text = text_row[:200] + "..."
approval_quotes.append({
'url': url_row,
'quote': quote_text,
'keyword': f"Pattern: {pattern}"
})
cur.close()
return {
'score': 0.5,
'verdict': 'СКРЫТО',
'confidence': 0.8,
'found_keywords': found_required_patterns,
'approval_urls': approval_urls,
'approval_quotes': approval_quotes
}
# Ищем ключевые слова
found_keywords = []
for keyword in criterion['keywords']:
# Экранируем специальные символы для PostgreSQL regex
escaped_keyword = re.escape(keyword.lower())
cur.execute("""
SELECT COUNT(*) FROM hotel_website_processed
WHERE hotel_id = %s AND LOWER(cleaned_text) ~ %s
""", (hotel_id, r'\y' + escaped_keyword + r'\y'))
if cur.fetchone()[0] > 0:
found_keywords.append(keyword)
if not found_keywords:
cur.close()
return {
'score': 0.0,
'verdict': 'НЕ_НАЙДЕНО',
'confidence': 0.0,
'found_keywords': [],
'approval_urls': [],
'approval_quotes': []
}
# ПРИОРИТЕТ: Ищем по priority_patterns (более специфичные)
found_priority_patterns = []
approval_urls = []
approval_quotes = []
if criterion.get('priority_patterns'):
cur.execute("""
SELECT cleaned_text FROM hotel_website_processed
WHERE hotel_id = %s
""", (hotel_id,))
all_text = ""
for row in cur.fetchall():
all_text += " " + row[0]
for pattern in criterion['priority_patterns']:
if re.search(pattern, all_text, re.IGNORECASE):
found_priority_patterns.append(pattern)
if found_priority_patterns and not approval_urls:
for pattern in found_priority_patterns:
cur.execute("""
SELECT cleaned_text, url
FROM hotel_website_processed
WHERE hotel_id = %s AND cleaned_text ~* %s
LIMIT 1
""", (hotel_id, pattern))
result = cur.fetchone()
if result:
text_row, url_row = result
approval_urls.append(url_row)
# Ищем точное вхождение паттерна
match = re.search(pattern, text_row, re.IGNORECASE)
if match:
idx = match.start()
start = max(0, idx - 100)
end = min(len(text_row), idx + 200)
quote_text = text_row[start:end].strip()
else:
quote_text = text_row[:200] + "..."
approval_quotes.append({
'url': url_row,
'quote': quote_text,
'keyword': f"Pattern: {pattern}"
})
# ПРИОРИТЕТ: Ищем по priority_urls (специальные страницы)
if criterion.get('priority_urls') and not approval_urls:
for priority_path in criterion['priority_urls']:
cur.execute("""
SELECT cleaned_text, url
FROM hotel_website_processed
WHERE hotel_id = %s AND url LIKE %s
LIMIT 1
""", (hotel_id, f'%{priority_path}%'))
result = cur.fetchone()
if result:
text_row, url_row = result
# Проверяем что на этой странице есть хоть одно ключевое слово
text_lower = text_row.lower()
for kw in found_keywords:
pattern = r'\b' + re.escape(kw.lower()) + r'\b'
if re.search(pattern, text_lower, re.IGNORECASE):
approval_urls.append(url_row)
# Ищем точное вхождение ключевого слова
match = re.search(r'\b' + re.escape(kw.lower()) + r'\b', text_lower, re.IGNORECASE)
if match:
idx = match.start()
start = max(0, idx - 100)
end = min(len(text_row), idx + 200)
quote_text = text_row[start:end].strip()
else:
quote_text = text_row[:200] + "..."
approval_quotes.append({
'url': url_row,
'quote': quote_text,
'keyword': kw
})
break
if approval_urls:
break # Нашли на приоритетной странице - хватит
# Если не нашли по priority_patterns или priority_urls - ищем по keywords
if not approval_urls:
for keyword in found_keywords:
# Экранируем специальные символы для PostgreSQL regex
escaped_keyword = re.escape(keyword.lower())
cur.execute("""
SELECT cleaned_text, url
FROM hotel_website_processed
WHERE hotel_id = %s AND LOWER(cleaned_text) ~ %s
LIMIT 1
""", (hotel_id, r'\y' + escaped_keyword + r'\y'))
result = cur.fetchone()
if result:
text_row, url_row = result
approval_urls.append(url_row)
# Ищем точное вхождение ключевого слова
match = re.search(r'\b' + re.escape(keyword.lower()) + r'\b', text_row, re.IGNORECASE)
if match:
idx = match.start()
start = max(0, idx - 100)
end = min(len(text_row), idx + 200)
quote_text = text_row[start:end].strip()
else:
quote_text = text_row[:200] + "..."
approval_quotes.append({
'url': url_row,
'quote': quote_text,
'keyword': keyword
})
break # Нашли первое вхождение - хватит
cur.close()
# Если нашли информацию - возвращаем 0.5 балла
if approval_urls:
return {
'score': 0.5,
'verdict': 'СКРЫТО',
'confidence': 0.7,
'found_keywords': found_keywords,
'approval_urls': approval_urls,
'approval_quotes': approval_quotes
}
else:
return {
'score': 0.0,
'verdict': 'НЕ_НАЙДЕНО',
'confidence': 0.0,
'found_keywords': [],
'approval_urls': [],
'approval_quotes': []
}
def check_criterion_simple(self, hotel_id: str, criterion: dict) -> dict:
"""
Новая логика оценки доступности информации:
- 1.0 балл: прямая ссылка на страницу (видна с главной)
- 0.5 балла: информация найдена, но спрятана глубоко
- 0 баллов: информация не найдена
"""
# ОСОБАЯ ЛОГИКА ДЛЯ КРИТЕРИЕВ С ОБЯЗАТЕЛЬНЫМИ ПАТТЕРНАМИ
# (Критерий 1: ИНН/ОГРН, Критерий 4: режим работы с временем)
if criterion.get('require_patterns') or criterion['id'] == 1:
deep_search_score = self.check_deep_search(hotel_id, criterion)
# Если нашли required_patterns или priority_patterns - это уже хорошо
if deep_search_score['score'] > 0:
# Проверяем есть ли прямая ссылка на главной
direct_links_score = self.check_direct_links(hotel_id, criterion)
if direct_links_score:
# Есть прямая ссылка И найдены паттерны - 1.0 балл
# Но апрув URL берём из deep_search (где реально найдена информация)
return {
'score': 1.0,
'verdict': 'ПРЯМАЯ_ССЫЛКА',
'confidence': 0.95,
'found_keywords': deep_search_score['found_keywords'],
'approval_urls': deep_search_score['approval_urls'],
'approval_quotes': deep_search_score['approval_quotes']
}
else:
# Паттерны найдены, но нет прямой ссылки - 0.5 балла
return deep_search_score
else:
# Паттерны не найдены
return deep_search_score
# Для остальных критериев - стандартная логика
# Сначала проверяем прямые ссылки на главной странице
direct_links_score = self.check_direct_links(hotel_id, criterion)
if direct_links_score:
return direct_links_score
# Затем ищем информацию в глубине сайта
deep_search_score = self.check_deep_search(hotel_id, criterion)
return deep_search_score
def check_rkn_registry(self, hotel_id: str) -> dict:
"""Проверка статуса в реестре РКН"""
cur = self.conn.cursor()
cur.execute("""
SELECT rkn_registry_status, rkn_registry_number, rkn_registry_date
FROM hotel_main
WHERE id = %s
""", (hotel_id,))
result = cur.fetchone()
cur.close()
if not result:
return {
'status': 'not_found',
'number': None,
'date': None,
'approval_url': 'https://pd.rkn.gov.ru/operators-registry/operators-list/'
}
status, number, date = result
if status == 'found' and number:
approval_url = f'https://pd.rkn.gov.ru/operators-registry/operators-list/?operator={number}'
return {
'status': 'found',
'number': number,
'date': date,
'approval_url': approval_url
}
else:
return {
'status': 'not_found',
'number': None,
'date': None,
'approval_url': 'https://pd.rkn.gov.ru/operators-registry/operators-list/'
}
def audit_hotel_with_website(self, hotel_id: str, hotel_name: str, website: str) -> dict:
"""Аудит отеля с сайтом"""
logger.info(f"🔍 Аудит: {hotel_name}")
criteria_results = {}
total_score = 0.0
for criterion in AUDIT_CRITERIA:
logger.info(f" Критерий {criterion['id']}: {criterion['name']}")
if criterion['id'] == 6: # Роскомнадзор - специальная обработка
rkn_result = self.check_rkn_registry(hotel_id)
if rkn_result['status'] == 'found':
result = {
'score': 1.0,
'verdict': 'ПРЯМАЯ_ССЫЛКА',
'confidence': 0.95,
'found_keywords': ['РКН реестр'],
'approval_urls': [rkn_result['approval_url']],
'approval_quotes': [{
'url': rkn_result['approval_url'],
'quote': f"Найден в реестре РКН: {rkn_result['number']} от {rkn_result['date']}",
'keyword': 'РКН реестр'
}]
}
else:
result = {
'score': 0.0,
'verdict': 'НЕ_НАЙДЕНО',
'confidence': 0.0,
'found_keywords': [],
'approval_urls': [rkn_result['approval_url']],
'approval_quotes': []
}
else:
result = self.check_criterion_simple(hotel_id, criterion)
criteria_results[criterion['id']] = {
'name': criterion['name'],
'score': result['score'],
'verdict': result['verdict'],
'confidence': result.get('confidence', 0.0),
'found_keywords': result.get('found_keywords', []),
'approval_urls': result.get('approval_urls', []),
'approval_quotes': result.get('approval_quotes', [])
}
total_score += result['score']
score_percentage = (total_score / 18.0) * 100 if total_score > 0 else 0
logger.info(f" ✅ Сайт есть: {total_score}/18.0 критериев ({score_percentage:.1f}%)")
return {
'has_website': True,
'total_score': total_score,
'score_percentage': score_percentage,
'criteria_results': criteria_results
}
def audit_hotel_no_website(self, hotel_id: str, hotel_name: str) -> dict:
"""Аудит отеля без сайта - все критерии 0"""
logger.info(f" ❌ Сайта нет: 0/18 (автоматически)")
criteria_results = {}
for criterion in AUDIT_CRITERIA:
criteria_results[criterion['id']] = {
'name': criterion['name'],
'score': 0.0,
'verdict': 'НЕ_НАЙДЕНО',
'confidence': 0.0,
'found_keywords': [],
'approval_urls': [],
'approval_quotes': []
}
return {
'has_website': False,
'total_score': 0.0,
'score_percentage': 0.0,
'criteria_results': criteria_results
}
def run_audit(self, force_audit: bool = False):
"""Запуск аудита региона"""
logger.info(f"🚀 Запуск аудита: {self.region_name}")
self.connect_db()
self.create_audit_table()
cur = self.conn.cursor()
# Получаем отели региона
cur.execute("""
SELECT id, full_name, website_address,
CASE WHEN website_address IS NOT NULL AND website_address != '' THEN true ELSE false END as has_website
FROM hotel_main
WHERE region_name = %s
ORDER BY full_name
""", (self.region_name,))
hotels = cur.fetchall()
total_hotels = len(hotels)
with_websites = sum(1 for _, _, _, has_website in hotels if has_website)
without_websites = total_hotels - with_websites
logger.info(f"📊 Отелей: {total_hotels}")
logger.info(f" С сайтами: {with_websites} ({with_websites/total_hotels*100:.1f}%)")
logger.info(f" БЕЗ сайтов: {without_websites} ({without_websites/total_hotels*100:.1f}%)")
logger.info("")
# Проверяем есть ли уже результаты аудита
if not force_audit:
cur.execute("""
SELECT COUNT(*) FROM hotel_audit_results
WHERE region_name = %s AND audit_version = %s
""", (self.region_name, self.group_id))
if cur.fetchone()[0] > 0:
logger.info("⚠️ Результаты аудита уже существуют. Используйте force_audit=True для перезапуска")
cur.close()
return
# Аудит каждого отеля
for i, (hotel_id, hotel_name, website, has_website) in enumerate(hotels, 1):
logger.info(f"[{i}/{total_hotels}] {hotel_name}")
if has_website and website:
audit_result = self.audit_hotel_with_website(hotel_id, hotel_name, website)
else:
audit_result = self.audit_hotel_no_website(hotel_id, hotel_name)
# Сохраняем результат
cur.execute("""
INSERT INTO hotel_audit_results
(hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, score_percentage, audit_version)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (hotel_id, audit_version)
DO UPDATE SET
criteria_results = EXCLUDED.criteria_results,
total_score = EXCLUDED.total_score,
score_percentage = EXCLUDED.score_percentage,
audit_date = CURRENT_TIMESTAMP
""", (
hotel_id, self.region_name, hotel_name, website, audit_result['has_website'],
Json(audit_result['criteria_results']), audit_result['total_score'],
audit_result['score_percentage'], self.group_id
))
logger.info("")
self.conn.commit()
cur.close()
logger.info("✅ Аудит завершён!")
def export_to_excel(self, region_name: str):
"""Экспорт результатов в Excel с новой структурой"""
cur = self.conn.cursor()
cur.execute("""
SELECT hotel_name, website, has_website, total_score, score_percentage, criteria_results
FROM hotel_audit_results
WHERE region_name = %s AND audit_version = %s
ORDER BY total_score DESC, hotel_name
""", (region_name, self.group_id))
results = cur.fetchall()
cur.close()
if not results:
logger.error("❌ Нет результатов для экспорта")
return
# Создаем DataFrame
data = []
for hotel_name, website, has_website, total_score, score_percentage, criteria_results in results:
row = {
'Отель': hotel_name,
'Сайт': website or 'Нет сайта',
'Балл': f"{total_score:.1f}",
'Процент': f"{score_percentage:.1f}%"
}
# Парсим criteria_results если это строка JSON
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Добавляем колонки для каждого критерия
for i in range(1, 19): # 18 критериев
# Проверяем как по числовому ключу, так и по строковому
key = str(i) if str(i) in criteria_results else i
if key in criteria_results:
result = criteria_results[key]
row[f'{i}. {result["name"]}'] = result['verdict']
row[f'{i}. Балл'] = f"{result['score']:.1f}"
# Апрув URL
approval_urls = result.get('approval_urls', [])
if approval_urls:
row[f'{i}. Апрув URL'] = approval_urls[0]
else:
row[f'{i}. Апрув URL'] = ''
# Пояснение
quotes = result.get('approval_quotes', [])
if quotes:
row[f'{i}. Пояснение'] = quotes[0].get('quote', '')[:200] + '...' if len(quotes[0].get('quote', '')) > 200 else quotes[0].get('quote', '')
else:
row[f'{i}. Пояснение'] = ''
data.append(row)
df = pd.DataFrame(data)
# Сохраняем в Excel
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_{region_name.replace(' ', '_')}_{timestamp}.xlsx"
with pd.ExcelWriter(filename, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Аудит', index=False)
# Получаем объект рабочей книги для форматирования
workbook = writer.book
worksheet = writer.sheets['Аудит']
# Автоширина колонок
for column in worksheet.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
worksheet.column_dimensions[column_letter].width = adjusted_width
logger.info(f"📊 Экспортировано в: {filename}")
return filename
def generate_final_report(self, region_name: str):
"""Генерация итогового отчёта"""
cur = self.conn.cursor()
cur.execute("""
SELECT COUNT(*) as total,
AVG(total_score) as avg_score,
MAX(total_score) as max_score,
MIN(total_score) as min_score
FROM hotel_audit_results
WHERE region_name = %s AND audit_version = %s
""", (region_name, self.group_id))
stats = cur.fetchone()
cur.execute("""
SELECT hotel_name, website, total_score
FROM hotel_audit_results
WHERE region_name = %s AND audit_version = %s
ORDER BY total_score DESC
LIMIT 10
""", (region_name, self.group_id))
top_hotels = cur.fetchall()
cur.close()
print("\n" + "="*70)
print(f"📊 ИТОГОВЫЙ ОТЧЁТ: {region_name.upper()}")
print("="*70)
print(f"\n📈 ОБЩАЯ СТАТИСТИКА:")
print(f" Всего отелей проверено: {stats[0]}")
print(f" Средний балл: {stats[1]:.1f}/18.0 ({stats[1]/18*100:.1f}%)")
print(f" Лучший результат: {stats[2]:.1f}/18.0")
print(f" Худший результат: {stats[3]:.1f}/18.0")
print(f"\n🏨 ТОП-10 ОТЕЛЕЙ:")
for i, (name, website, score) in enumerate(top_hotels, 1):
status = "🌐" if website else ""
print(f" {status} {name[:50]}")
print(f" Сайт: {website or 'Нет сайта'}")
print(f" Балл: {score:.1f}/18.0")
print("\n" + "="*70)
def main():
import sys
if len(sys.argv) < 2:
print("Использование: python audit_system_new.py <регион> [group_id]")
sys.exit(1)
region_name = sys.argv[1]
group_id = sys.argv[2] if len(sys.argv) > 2 else f"AUDIT_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
audit = AuditSystem(region_name, group_id)
audit.run_audit()
audit.export_to_excel(region_name)
audit.generate_final_report(region_name)
if __name__ == "__main__":
main()

83
check_audit_readiness.py Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
Скрипт для подсчёта отелей, готовых к аудиту
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def main():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor(cursor_factory=RealDictCursor)
print("📊 ГОТОВНОСТЬ ОТЕЛЕЙ К АУДИТУ\n" + "="*60)
# 1. Сколько отелей с chunks
cur.execute("""
SELECT COUNT(DISTINCT metadata->>'hotel_id') AS count
FROM hotel_website_chunks
WHERE metadata->>'hotel_id' IS NOT NULL
""")
hotels_with_chunks = cur.fetchone()['count']
# 2. Общее количество chunks
cur.execute("SELECT COUNT(*) AS count FROM hotel_website_chunks")
total_chunks = cur.fetchone()['count']
# 3. Средний размер chunks на отель
cur.execute("""
SELECT
ROUND(AVG(chunk_count), 0) AS avg_chunks
FROM (
SELECT COUNT(*) AS chunk_count
FROM hotel_website_chunks
WHERE metadata->>'hotel_id' IS NOT NULL
GROUP BY metadata->>'hotel_id'
) sub
""")
avg_chunks = cur.fetchone()['avg_chunks'] or 0
# 4. Топ-10 регионов по готовности
cur.execute("""
SELECT
h.region_name,
COUNT(DISTINCT hwc.metadata->>'hotel_id') AS hotels_ready,
COUNT(DISTINCT h.id) AS total_hotels,
ROUND(100.0 * COUNT(DISTINCT hwc.metadata->>'hotel_id') / COUNT(DISTINCT h.id), 1) AS percentage
FROM hotel_main h
LEFT JOIN hotel_website_chunks hwc ON hwc.metadata->>'hotel_id' = h.id::text
WHERE h.region_name IS NOT NULL
GROUP BY h.region_name
HAVING COUNT(DISTINCT hwc.metadata->>'hotel_id') > 0
ORDER BY hotels_ready DESC
LIMIT 10
""")
top_regions = cur.fetchall()
print(f"✅ Отелей с chunks (готовы к аудиту): {hotels_with_chunks:,}")
print(f"📦 Всего chunks в базе: {total_chunks:,}")
print(f"📊 Среднее chunks на отель: {avg_chunks}")
print(f"\n⏱️ ПРОГНОЗ ВРЕМЕНИ АУДИТА:")
print(f" • При 42 сек/отель: {hotels_with_chunks * 42 / 3600:.1f} часов ({hotels_with_chunks * 42 / 86400:.1f} дней)")
print(f" • При 20 сек/отель: {hotels_with_chunks * 20 / 3600:.1f} часов ({hotels_with_chunks * 20 / 86400:.1f} дней)")
print(f" • При 10 сек/отель: {hotels_with_chunks * 10 / 3600:.1f} часов ({hotels_with_chunks * 10 / 86400:.1f} дней)")
print(f"\n🏆 ТОП-10 РЕГИОНОВ ПО ГОТОВНОСТИ:\n{'-'*60}")
for region in top_regions:
print(f"{region['region']:<30} {region['hotels_ready']:>5} отелей ({region['percentage']:>5}%)")
cur.close()
conn.close()
if __name__ == '__main__':
main()

56
check_audit_records.py Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки записей с v1.0_with_rkn
"""
import psycopg2
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def check_audit_records():
"""Проверяем записи аудита"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Проверяем версии аудита
cur.execute('SELECT audit_version, COUNT(*) FROM hotel_audit_results GROUP BY audit_version')
versions = cur.fetchall()
print('Версии аудита:')
for version, count in versions:
print(f' {version}: {count} записей')
# Проверяем записи с v1.0_with_rkn
cur.execute("SELECT hotel_id, hotel_name, criteria_results FROM hotel_audit_results WHERE audit_version = 'v1.0_with_rkn' LIMIT 1")
row = cur.fetchone()
if row:
hotel_id, hotel_name, criteria = row
print(f'\nОтель с v1.0_with_rkn: {hotel_name}')
print(f'criteria_results type: {type(criteria)}')
print(f'criteria_results length: {len(criteria) if hasattr(criteria, "__len__") else "нет длины"}')
if isinstance(criteria, dict):
print(f'Ключи: {list(criteria.keys())[:5]}')
# Проверяем критерий 2
criterion_02 = criteria.get('criterion_02', {})
print(f'Критерий 2 found: {criterion_02.get("found")}')
print(f'Критерий 2 approval_urls: {criterion_02.get("approval_urls")}')
elif isinstance(criteria, str):
print(f'Строка: {criteria[:100]}...')
else:
print(f'Другое: {criteria}')
else:
print('\nНет записей с v1.0_with_rkn')
cur.close()
conn.close()
if __name__ == "__main__":
check_audit_records()

47
check_crawler.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import subprocess
import glob
import os
# Проверяем процессы
print("🔍 АКТИВНЫЕ ПРОЦЕССЫ КРАУЛЕРА:\n")
try:
result = subprocess.run(['ps', 'aux'], capture_output=True, text=True)
for line in result.stdout.split('\n'):
if 'mass_crawler.py' in line and 'grep' not in line:
print(f" {line}")
except:
print(" ❌ Ошибка проверки процессов")
# Проверяем логи
print("\n📄 ФАЙЛЫ ЛОГОВ КРАУЛЕРА:\n")
log_files = glob.glob('/root/engine/public_oversight/hotels/mass_crawler_*.log')
log_files.sort(key=os.path.getmtime, reverse=True)
for i, log_file in enumerate(log_files[:5]):
size = os.path.getsize(log_file) / 1024 # KB
mtime = os.path.getmtime(log_file)
from datetime import datetime
mod_time = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
print(f" {i+1}. {os.path.basename(log_file)}")
print(f" Размер: {size:.1f} KB")
print(f" Изменён: {mod_time}")
# Читаем последние строки
try:
with open(log_file, 'r') as f:
lines = f.readlines()
if lines:
print(f" Строк: {len(lines)}")
# Последние 3 строки
for line in lines[-3:]:
line = line.strip()
if line:
print(f" {line[:80]}...")
except:
pass
print()

86
check_crawler_status.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/bash
# Скрипт для проверки статуса краулера
echo "═══════════════════════════════════════════════════════════════"
echo "🔍 СТАТУС МАССОВОГО КРАУЛИНГА"
echo "═══════════════════════════════════════════════════════════════"
# Проверка процесса
if ps aux | grep -v grep | grep "mass_crawler.py" > /dev/null; then
PID=$(ps aux | grep -v grep | grep "mass_crawler.py" | awk '{print $2}')
CPU=$(ps aux | grep -v grep | grep "mass_crawler.py" | awk '{print $3}')
MEM=$(ps aux | grep -v grep | grep "mass_crawler.py" | awk '{print $4}')
echo "✅ Краулер РАБОТАЕТ"
echo " PID: $PID"
echo " CPU: ${CPU}%"
echo " MEM: ${MEM}%"
else
echo "❌ Краулер НЕ РАБОТАЕТ"
fi
echo ""
echo "───────────────────────────────────────────────────────────────"
echo "📊 ПРОГРЕСС ИЗ БАЗЫ ДАННЫХ:"
echo "───────────────────────────────────────────────────────────────"
python3 << 'EOF'
import psycopg2
from urllib.parse import unquote
conn = psycopg2.connect(
host='147.45.189.234',
port=5432,
database='default_db',
user='gen_user',
password=unquote('2~~9_%5EkVsU%3F2%5CS')
)
cur = conn.cursor()
# Статистика
cur.execute("SELECT COUNT(DISTINCT id) FROM hotel_main WHERE website_address IS NOT NULL AND website_address != ''")
total_with_sites = cur.fetchone()[0]
cur.execute("SELECT COUNT(DISTINCT hotel_id) FROM hotel_website_meta")
crawled = cur.fetchone()[0]
remaining = total_with_sites - crawled
progress = (crawled / total_with_sites * 100) if total_with_sites > 0 else 0
print(f"Всего отелей с сайтами: {total_with_sites}")
print(f"Обработано: {crawled} ({progress:.1f}%)")
print(f"Осталось: {remaining}")
# Последние обработанные
cur.execute("""
SELECT hotel_name, region_name, pages_crawled, crawled_at
FROM hotel_website_meta
ORDER BY crawled_at DESC
LIMIT 5
""")
print("\n───────────────────────────────────────────────────────────────")
print("🏨 ПОСЛЕДНИЕ 5 ОБРАБОТАННЫХ ОТЕЛЕЙ:")
print("───────────────────────────────────────────────────────────────")
for row in cur.fetchall():
print(f" • {row[0][:50]} ({row[1]})")
print(f" Страниц: {row[2]}, Время: {row[3]}")
conn.close()
EOF
echo ""
echo "───────────────────────────────────────────────────────────────"
echo "📋 ПОСЛЕДНИЕ 15 СТРОК ЛОГА:"
echo "───────────────────────────────────────────────────────────────"
if [ -f "mass_crawler_output.log" ]; then
tail -15 mass_crawler_output.log
else
echo "❌ Лог файл не найден"
fi
echo ""
echo "═══════════════════════════════════════════════════════════════"

100
check_db.py Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Безопасная проверка структуры базы данных"""
import psycopg2
from urllib.parse import unquote
# Параметры подключения
DB_HOST = "147.45.189.234"
DB_PORT = 5432
DB_NAME = "default_db"
DB_USER = "gen_user"
DB_PASSWORD = unquote("2~~9_%5EkVsU%3F2%5CS")
print("Подключаюсь к базе данных...")
print(f"Host: {DB_HOST}")
print(f"Database: {DB_NAME}")
print(f"User: {DB_USER}")
print()
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
cur = conn.cursor()
# Проверяем версию PostgreSQL
cur.execute("SELECT version();")
version = cur.fetchone()[0]
print(f"PostgreSQL версия: {version.split(',')[0]}")
print()
# Проверяем размер базы
cur.execute("""
SELECT pg_size_pretty(pg_database_size(current_database()));
""")
db_size = cur.fetchone()[0]
print(f"Размер базы: {db_size}")
print()
# Проверяем существующие таблицы
cur.execute("""
SELECT schemaname, tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;
""")
tables = cur.fetchall()
print("=== Существующие таблицы (топ 20 по размеру) ===")
if tables:
for schema, table, size in tables:
print(f" {schema}.{table} - {size}")
else:
print(" Пользовательских таблиц не найдено")
print()
# Проверяем есть ли таблицы с префиксом hotel
cur.execute("""
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public' AND tablename LIKE 'hotel%';
""")
hotel_tables = cur.fetchall()
if hotel_tables:
print("=== Таблицы с префиксом 'hotel' ===")
for (table,) in hotel_tables:
print(f" {table}")
print()
# Проверяем лимиты подключений
cur.execute("""
SELECT setting FROM pg_settings WHERE name = 'max_connections';
""")
max_conn = cur.fetchone()[0]
print(f"Максимум подключений: {max_conn}")
cur.execute("""
SELECT count(*) FROM pg_stat_activity;
""")
active_conn = cur.fetchone()[0]
print(f"Активных подключений: {active_conn}")
print()
print("✓ Подключение успешно! База в порядке.")
cur.close()
conn.close()
except Exception as e:
print(f"✗ Ошибка подключения: {e}")
exit(1)

77
check_graphiti_data.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
from neo4j import GraphDatabase
NEO4J_URI = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASSWORD = "supersecret"
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
with driver.session() as session:
print("=" * 70)
print("🔍 ПРОВЕРКА ДАННЫХ В NEO4J (group_id='hotel_spb')")
print("=" * 70)
# Проверяем эпизоды
result = session.run("""
MATCH (e:Episode)
WHERE e.group_id = 'hotel_spb'
RETURN count(e) AS episode_count
""")
episode_count = result.single()["episode_count"]
print(f"\n📄 Эпизодов в hotel_spb: {episode_count}")
if episode_count > 0:
# Примеры эпизодов
result = session.run("""
MATCH (e:Episode)
WHERE e.group_id = 'hotel_spb'
RETURN e.name AS name, e.content AS content,
size(e.embedding) AS emb_size
LIMIT 3
""")
print(f"\n🔍 Примеры эпизодов:")
for r in result:
print(f" Name: {r['name']}")
print(f" Embedding: {r['emb_size']} размерность")
print(f" Content: {r['content'][:120]}...")
print()
# Сущности
result = session.run("""
MATCH (e:Entity)
WHERE e.group_id = 'hotel_spb'
RETURN count(e) AS count
""")
entities = result.single()["count"]
print(f"🏷️ Сущностей: {entities}")
# Рёбра
result = session.run("""
MATCH ()-[r]->()
WHERE r.group_id = 'hotel_spb'
RETURN count(r) AS count
""")
edges = result.single()["count"]
print(f"🔗 Рёбер: {edges}")
else:
print("\n❌ Данных НЕТ!")
print(" Возможно данные загружались с другим group_id")
# Поищем недавно созданные эпизоды
result = session.run("""
MATCH (e:Episode)
WHERE e.created_at > datetime() - duration('PT10M')
RETURN e.group_id AS group_id, count(e) AS count
""")
print("\n Эпизоды созданные за последние 10 минут:")
for r in result:
print(f" group_id='{r['group_id']}': {r['count']} эпизодов")
print("\n" + "=" * 70)
driver.close()

117
check_progress.py Normal file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Скрипт для проверки прогресса обработки эмбеддингов
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
# Конфигурация
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
def get_db_connection():
"""Получить подключение к БД"""
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
def check_progress():
"""Проверить прогресс обработки"""
try:
conn = get_db_connection()
cur = conn.cursor()
# Общая статистика
cur.execute("""
SELECT
COUNT(*) as total_chunks,
COUNT(DISTINCT metadata->>'hotel_id') as processed_hotels,
COUNT(DISTINCT metadata->>'region_name') as processed_regions
FROM hotel_website_chunks;
""")
stats = cur.fetchone()
# Статистика по регионам
cur.execute("""
SELECT
metadata->>'region_name' as region_name,
COUNT(DISTINCT metadata->>'hotel_id') as hotels_count,
COUNT(*) as chunks_count
FROM hotel_website_chunks
WHERE metadata->>'region_name' IS NOT NULL
GROUP BY metadata->>'region_name'
ORDER BY chunks_count DESC;
""")
regions_stats = cur.fetchall()
# Общее количество отелей в системе
cur.execute("SELECT COUNT(*) as total_hotels FROM hotel_main;")
total_hotels = cur.fetchone()['total_hotels']
# Общее количество страниц
cur.execute("SELECT COUNT(*) as total_pages FROM hotel_website_processed;")
total_pages = cur.fetchone()['total_pages']
cur.close()
conn.close()
print("📊 ПРОГРЕСС ОБРАБОТКИ ЭМБЕДДИНГОВ")
print("=" * 50)
print(f"🏨 Обработано отелей: {stats['processed_hotels']}/{total_hotels} ({stats['processed_hotels']/total_hotels*100:.1f}%)")
print(f"📄 Всего chunks: {stats['total_chunks']}")
print(f"🌍 Регионов: {stats['processed_regions']}")
print()
print("📈 ДЕТАЛЬНАЯ СТАТИСТИКА ПО РЕГИОНАМ:")
print("-" * 50)
for region in regions_stats:
print(f"🏢 {region['region_name']}:")
print(f" Отелей: {region['hotels_count']}")
print(f" Chunks: {region['chunks_count']}")
print()
# Проверяем какие отели еще не обработаны
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT
h.region_name,
COUNT(*) as unprocessed_hotels
FROM hotel_main h
LEFT JOIN hotel_website_chunks c ON h.id::text = c.metadata->>'hotel_id'
WHERE c.id IS NULL
AND EXISTS (
SELECT 1 FROM hotel_website_processed p
WHERE p.hotel_id = h.id
AND p.cleaned_text IS NOT NULL
AND LENGTH(p.cleaned_text) > 50
)
GROUP BY h.region_name
ORDER BY unprocessed_hotels DESC;
""")
unprocessed = cur.fetchall()
if unprocessed:
print("⏳ ОСТАЛОСЬ ОБРАБОТАТЬ:")
print("-" * 50)
for region in unprocessed:
print(f"🏢 {region['region_name']}: {region['unprocessed_hotels']} отелей")
else:
print("ВСЕ ОТЕЛИ ОБРАБОТАНЫ!")
cur.close()
conn.close()
except Exception as e:
print(f"❌ Ошибка: {e}")
if __name__ == "__main__":
check_progress()

50
check_progress.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
echo "📊 СТАТУС ФОНОВЫХ ПРОЦЕССОВ"
echo "============================================================"
echo ""
# Проверяем процессы
echo "🔍 Активные процессы:"
ps aux | grep -E "smart_crawler|process_all_hotels_embeddings" | grep -v grep | awk '{print " PID: "$2" - "$11" "$12" "$13}'
echo ""
echo "📝 Последние логи краулера:"
tail -5 smart_crawler_output_*.log 2>/dev/null | grep -E "INFO|ERROR" | tail -3
echo ""
echo "📝 Последние логи чанкинизации:"
tail -5 embeddings_processing_*.log 2>/dev/null | grep -E "INFO|ERROR|отелей|chunks" | tail -3
echo ""
echo "📈 Статистика из БД:"
python3 << 'PYEOF'
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
cur.execute("SELECT COUNT(DISTINCT hotel_id) as count FROM hotel_website_raw")
crawled = cur.fetchone()['count']
cur.execute("SELECT COUNT(DISTINCT metadata->>'hotel_id') as count FROM hotel_website_chunks WHERE metadata->>'hotel_id' IS NOT NULL")
chunked = cur.fetchone()['count']
cur.execute("SELECT COUNT(*) as count FROM hotel_website_chunks")
total_chunks = cur.fetchone()['count']
print(f" 🕷️ Краулинг: {crawled:,} отелей")
print(f" 📦 Chunks: {chunked:,} отелей ({total_chunks:,} chunks)")
cur.close()
conn.close()
PYEOF

320
check_rkn_registry.py Normal file
View File

@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Проверка отелей в реестре операторов персональных данных Роскомнадзора
Проверяет только отели с сайтами
"""
import asyncio
import psycopg2
from psycopg2.extras import RealDictCursor
from playwright.async_api import async_playwright
from urllib.parse import unquote
from datetime import datetime
import logging
import re
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'rkn_check_{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")
}
# Конфигурация
REQUEST_DELAY = 3 # Задержка между запросами (секунды)
PAGE_TIMEOUT = 30000
class RKNChecker:
"""Проверка в реестре РКН"""
def __init__(self):
self.db_conn = None
self.browser = None
self.page = None
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 init_browser(self):
"""Инициализация браузера"""
playwright = await async_playwright().start()
self.browser = await playwright.chromium.launch(headless=True)
self.page = await self.browser.new_page()
await self.page.set_viewport_size({"width": 1920, "height": 1080})
await self.page.set_extra_http_headers({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
logger.info("✓ Браузер инициализирован")
async def close_browser(self):
"""Закрытие браузера"""
if self.browser:
await self.browser.close()
async def check_inn_in_registry(self, inn: str) -> dict:
"""Проверка ИНН в реестре РКН"""
if not inn or inn == '-':
return {
'found': False,
'status': 'no_inn',
'message': 'ИНН не указан'
}
try:
# Формируем URL
url = f'https://pd.rkn.gov.ru/operators-registry/operators-list/?act=search&inn={inn}'
logger.info(f" 🔍 Проверка ИНН: {inn}")
# Задержка перед запросом
await asyncio.sleep(REQUEST_DELAY)
# Загружаем страницу
response = await self.page.goto(url, timeout=PAGE_TIMEOUT, wait_until='networkidle')
if response.status != 200:
return {
'found': False,
'status': 'error',
'message': f'HTTP {response.status}'
}
# Ждем загрузки
await asyncio.sleep(1)
# Получаем текст страницы
text = await self.page.evaluate('() => document.body.innerText')
# Проверяем наличие результатов
if 'Не найдено' in text or 'не найдено' in text.lower():
logger.info(f"Не найден в реестре")
return {
'found': False,
'status': 'not_found',
'message': 'Не найден в реестре РКН'
}
# Пытаемся извлечь данные
# Ищем регистрационный номер (разные форматы: 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
# Ищем название организации
org_match = re.search(r'(?:Общество|Индивидуальный предприниматель|Акционерное общество).*?(?=ИНН:|$)', text, re.IGNORECASE)
org_name = org_match.group(0).strip() if org_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,
'org_name': org_name,
'message': f'Зарегистрирован: {reg_number}'
}
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_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 process_hotels(self, region_name=None):
"""Обработка отелей"""
# Получаем отели с сайтами
cur = self.db_conn.cursor(cursor_factory=RealDictCursor)
where_clause = ""
params = []
if region_name:
where_clause = "AND h.region_name ILIKE %s"
params = [f'%{region_name}%']
query = f'''
SELECT DISTINCT h.id, h.full_name, h.owner_inn, h.website_address, h.region_name
FROM hotel_main h
WHERE h.owner_inn IS NOT NULL
AND h.owner_inn != ''
AND h.owner_inn != '-'
AND (h.rkn_checked_at IS NULL OR h.rkn_checked_at < NOW() - INTERVAL '30 days')
{where_clause}
ORDER BY h.region_name, h.full_name
'''
cur.execute(query, params)
hotels = cur.fetchall()
cur.close()
logger.info(f"\n{'='*70}")
logger.info(f"🏨 Отелей для проверки: {len(hotels)}")
logger.info(f"⏱️ Примерное время: {len(hotels) * REQUEST_DELAY / 60:.1f} минут")
logger.info(f"{'='*70}\n")
# Обрабатываем отели
results = {
'found': 0,
'not_found': 0,
'error': 0,
'unclear': 0,
'no_inn': 0
}
for i, hotel in enumerate(hotels, 1):
logger.info(f"\n[{i}/{len(hotels)}] {'='*50}")
logger.info(f"🏨 {hotel['full_name']}")
logger.info(f"📍 {hotel['region_name']}")
logger.info(f"🌐 {hotel['website_address']}")
logger.info(f"🔢 ИНН: {hotel['owner_inn']}")
# Проверяем в реестре
result = await self.check_inn_in_registry(hotel['owner_inn'])
# Сохраняем результат
self.save_result(hotel['id'], result)
# Обновляем статистику
if result['found'] == True:
results['found'] += 1
elif result['found'] == False:
if result['status'] == 'no_inn':
results['no_inn'] += 1
elif result['status'] == 'not_found':
results['not_found'] += 1
else:
results['error'] += 1
else:
results['unclear'] += 1
# Итоги
logger.info(f"\n{'='*70}")
logger.info("📊 ИТОГИ ПРОВЕРКИ:")
logger.info(f" ✅ Найдено в реестре: {results['found']}")
logger.info(f"Не найдено в реестре: {results['not_found']}")
logger.info(f" ⚠️ Ошибки: {results['error']}")
logger.info(f" ❓ Неясно: {results['unclear']}")
logger.info(f" 🔢 Нет ИНН: {results['no_inn']}")
logger.info(f"{'='*70}")
return results
async def main():
"""Основная функция"""
import sys
region = sys.argv[1] if len(sys.argv) > 1 else None
checker = RKNChecker()
try:
# Подключаемся к БД
checker.connect_db()
# Добавляем колонки для РКН (если их нет)
cur = checker.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;
''')
checker.db_conn.commit()
cur.close()
logger.info("✓ Колонки для РКН добавлены")
# Инициализируем браузер
await checker.init_browser()
# Обрабатываем отели
await checker.process_hotels(region)
except Exception as e:
logger.error(f"❌ Критическая ошибка: {e}")
finally:
await checker.close_browser()
checker.close_db()
if __name__ == "__main__":
asyncio.run(main())

110
check_rkn_status.sh Executable file
View File

@@ -0,0 +1,110 @@
#!/bin/bash
# Скрипт для проверки статуса РКН проверки
echo "═══════════════════════════════════════════════════════════════"
echo "🔍 СТАТУС ПРОВЕРКИ РКН (РОСКОМНАДЗОР)"
echo "═══════════════════════════════════════════════════════════════"
# Проверка процесса
if ps aux | grep -v grep | grep "check_rkn_registry.py" > /dev/null; then
PID=$(ps aux | grep -v grep | grep "check_rkn_registry.py" | awk '{print $2}')
CPU=$(ps aux | grep -v grep | grep "check_rkn_registry.py" | awk '{print $3}')
MEM=$(ps aux | grep -v grep | grep "check_rkn_registry.py" | awk '{print $4}')
echo "✅ РКН проверка РАБОТАЕТ"
echo " PID: $PID"
echo " CPU: ${CPU}%"
echo " MEM: ${MEM}%"
else
echo "❌ РКН проверка НЕ РАБОТАЕТ"
fi
echo ""
echo "───────────────────────────────────────────────────────────────"
echo "📊 ПРОГРЕСС ИЗ БАЗЫ ДАННЫХ:"
echo "───────────────────────────────────────────────────────────────"
python3 << 'EOF'
import psycopg2
from urllib.parse import unquote
from datetime import datetime, timedelta
conn = psycopg2.connect(
host='147.45.189.234',
port=5432,
database='default_db',
user='gen_user',
password=unquote('2~~9_%5EkVsU%3F2%5CS')
)
cur = conn.cursor()
# Общая статистика
cur.execute("""
SELECT
COUNT(*) as total_with_inn,
COUNT(CASE WHEN rkn_checked_at IS NOT NULL THEN 1 END) as checked
FROM hotel_main
WHERE owner_inn IS NOT NULL AND owner_inn != '' AND owner_inn != '-'
""")
row = cur.fetchone()
total = row[0]
checked = row[1]
remaining = total - checked
progress = checked * 100 / total if total > 0 else 0
print(f"Отелей с ИНН: {total}")
print(f"Проверено: {checked} ({progress:.1f}%)")
print(f"Осталось: {remaining}")
# Проверено за последние 10 минут
cur.execute("""
SELECT COUNT(*)
FROM hotel_main
WHERE rkn_checked_at > NOW() - INTERVAL '10 minutes'
""")
last_10min = cur.fetchone()[0]
if last_10min > 0:
speed = last_10min / 10 # отелей в минуту
eta_min = remaining / speed if speed > 0 else 0
print(f"\n⚡ СКОРОСТЬ (за последние 10 мин):")
print(f" {last_10min} отелей проверено")
print(f" Скорость: {speed:.1f} отелей/мин")
if eta_min > 0:
print(f" Примерное время до завершения: {eta_min/60:.1f} часов")
# Результаты
cur.execute("""
SELECT
rkn_registry_status,
COUNT(*) as count
FROM hotel_main
WHERE rkn_checked_at IS NOT NULL
GROUP BY rkn_registry_status
""")
print(f"\n📋 РЕЗУЛЬТАТЫ:")
for row in cur.fetchall():
status = row[0] if row[0] else 'NULL'
count = row[1]
percent = count * 100 / checked if checked > 0 else 0
icon = '✅' if status == 'found' else '❓' if status == 'unclear' else '❌'
print(f" {icon} {status:15} | {count:5} ({percent:5.1f}%)")
conn.close()
EOF
echo ""
echo "───────────────────────────────────────────────────────────────"
echo "📋 ПОСЛЕДНИЕ 15 СТРОК ЛОГА:"
echo "───────────────────────────────────────────────────────────────"
if [ -f "rkn_check_all.log" ]; then
tail -15 rkn_check_all.log
else
echo "❌ Лог файл не найден"
fi
echo ""
echo "═══════════════════════════════════════════════════════════════"

443
chukotka_crawler.py Normal file
View File

@@ -0,0 +1,443 @@
#!/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'crawler_db_{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
class TextCleaner:
"""Продвинутая очистка HTML с сохранением важных данных"""
# Теги для удаления (только мусор!)
REMOVE_TAGS = ['script', 'style', 'noscript']
# Классы/ID для удаления (только явная реклама)
REMOVE_PATTERNS = ['advertisement', 'ad-banner', 'google-ad']
# ВАЖНЫЕ классы/ID которые НЕ удаляем (контакты!)
KEEP_PATTERNS = ['contact', 'phone', 'email', 'address', 'footer', 'info', 'about']
@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. Удаляем только явную рекламу (но проверяем, чтобы не было важных данных)
for pattern in TextCleaner.REMOVE_PATTERNS:
for tag in soup.find_all(class_=re.compile(pattern, re.I)):
# Проверяем, нет ли там важных данных
tag_text = tag.get_text().lower()
has_important = any(kw in tag_text for kw in ['телефон', 'email', 'адрес', '@', '+7', '8-'])
if not has_important:
tag.decompose()
# 4. Извлекаем текст (с переносами строк для читаемости)
text = soup.get_text(separator='\n', strip=True)
# 5. Убираем лишние пробелы, но сохраняем структуру
lines = [line.strip() for line in text.split('\n') if line.strip()]
text = '\n'.join(lines)
# 6. Убираем повторяющиеся переносы
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
@staticmethod
def extract_structured_data(html: str, text: str) -> Dict:
"""Извлечь структурированные данные"""
data = {
'phones': [],
'emails': [],
'inn': [],
'ogrn': [],
'addresses': []
}
# Телефоны
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]))[:10]
# 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))[:10]
# ИНН (10 или 12 цифр, контекст "ИНН")
inn_matches = re.findall(r'ИНН[:\s]*(\d{10}|\d{12})', text, re.IGNORECASE)
data['inn'] = list(set(inn_matches))[:3]
# ОГРН (13 или 15 цифр, контекст "ОГРН")
ogrn_matches = re.findall(r'ОГРН[:\s]*(\d{13}|\d{15})', text, re.IGNORECASE)
data['ogrn'] = list(set(ogrn_matches))[:3]
# Адреса (упрощенно - строки с "адрес:", "г.", "ул.")
address_patterns = [
r'[Аа]дрес[:\s]+([^\n]{20,150})',
r'г\.\s*[А-Я][а\-]+[,\s]+ул\.\s*[^\n]{10,100}'
]
for pattern in address_patterns:
addresses = re.findall(pattern, text)
data['addresses'].extend(addresses[:3])
data['addresses'] = list(set(data['addresses']))[:5]
return data
class WebsiteCrawlerDB:
"""Crawler с сохранением в PostgreSQL"""
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()
self.conn = None
self.start_time = None
@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:
"""Извлечь домен"""
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
def connect_db(self):
"""Подключение к БД"""
self.conn = psycopg2.connect(**DB_CONFIG)
logger.info(" ✓ Подключено к PostgreSQL")
def init_meta(self):
"""Инициализация метаинформации"""
cur = self.conn.cursor()
cur.execute("""
INSERT INTO hotel_website_meta
(hotel_id, domain, main_url, crawl_status, crawl_started_at)
VALUES (%s, %s, %s, 'in_progress', %s)
ON CONFLICT (hotel_id) DO UPDATE SET
crawl_status = 'in_progress',
crawl_started_at = EXCLUDED.crawl_started_at,
updated_at = CURRENT_TIMESTAMP
""", (self.hotel_id, self.domain, self.website, self.start_time))
self.conn.commit()
cur.close()
def save_page(self, url: str, title: str, html: str, status_code: int,
response_time: int, depth: int, cleaned_text: str, structured_data: Dict):
"""Сохранить страницу в БД"""
cur = self.conn.cursor()
try:
# Сохраняем сырой HTML
cur.execute("""
INSERT INTO hotel_website_raw
(hotel_id, url, page_title, html, status_code, response_time_ms, depth)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (hotel_id, url) DO UPDATE SET
html = EXCLUDED.html,
page_title = EXCLUDED.page_title,
status_code = EXCLUDED.status_code,
response_time_ms = EXCLUDED.response_time_ms,
crawled_at = CURRENT_TIMESTAMP
RETURNING id
""", (self.hotel_id, url, title, html, status_code, response_time, depth))
raw_page_id = cur.fetchone()[0]
# Сохраняем обработанный текст
cur.execute("""
INSERT INTO hotel_website_processed
(raw_page_id, hotel_id, url, cleaned_text, extracted_data,
has_forms, has_booking, text_length)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING
""", (raw_page_id, self.hotel_id, url, cleaned_text, Json(structured_data),
structured_data.get('has_forms', False),
structured_data.get('has_booking', False),
len(cleaned_text)))
self.conn.commit()
except Exception as e:
logger.error(f"Ошибка сохранения страницы {url}: {e}")
self.conn.rollback()
finally:
cur.close()
def update_meta(self, status: str, error_msg: Optional[str] = None):
"""Обновить метаинформацию"""
cur = self.conn.cursor()
total_size = sum(len(p.get('html', '')) for p in self.pages_data)
cur.execute("""
UPDATE hotel_website_meta SET
pages_crawled = %s,
total_size_bytes = %s,
crawl_status = %s,
crawl_finished_at = %s,
error_message = %s,
updated_at = CURRENT_TIMESTAMP
WHERE hotel_id = %s
""", (len(self.pages_data), total_size, status, datetime.now(), error_msg, self.hotel_id))
self.conn.commit()
cur.close()
async def extract_page_data(self, page: Page, url: str, depth: int) -> Optional[Dict]:
"""Извлечь данные со страницы"""
start_time = datetime.now()
try:
title = await page.title()
html = await page.content()
# Очищаем текст
cleaned_text = self.cleaner.clean_html(html)
# Извлекаем структурированные данные
structured_data = self.cleaner.extract_structured_data(html, cleaned_text)
# Извлекаем ссылки
links = await page.evaluate("""
() => Array.from(document.querySelectorAll('a[href]'))
.map(a => a.href)
.filter(href => href && !href.startsWith('mailto:') && !href.startsWith('tel:'))
""")
# Проверки
structured_data['has_forms'] = await page.evaluate("() => document.querySelectorAll('form').length > 0")
structured_data['has_booking'] = 'бронирован' in cleaned_text.lower() or 'booking' in cleaned_text.lower()
response_time = int((datetime.now() - start_time).total_seconds() * 1000)
page_data = {
'url': url,
'title': title,
'html': html,
'cleaned_text': cleaned_text,
'structured_data': structured_data,
'links': list(set(links)),
'status_code': 200,
'response_time': response_time,
'depth': depth,
'text_length': len(cleaned_text)
}
# Сохраняем в БД
self.save_page(
url, title, html, 200, response_time, depth,
cleaned_text, structured_data
)
return page_data
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
# Пропускаем PDF и файлы
if url.lower().endswith(('.pdf', '.doc', '.docx', '.zip', '.jpg', '.png')):
return
try:
logger.info(f" Парсинг (depth={depth}): {url[:80]}...")
# Загружаем страницу
try:
await page.goto(url, wait_until='domcontentloaded', timeout=NAVIGATION_TIMEOUT)
await page.wait_for_timeout(2000)
except Exception as e:
logger.warning(f" Пробуем load вместо domcontentloaded")
await page.goto(url, wait_until='load', timeout=NAVIGATION_TIMEOUT)
await page.wait_for_timeout(1000)
self.visited_urls.add(url)
# Извлекаем и сохраняем данные
page_data = await self.extract_page_data(page, url, depth)
if page_data:
self.pages_data.append(page_data)
logger.info(f" ✓ Сохранено {page_data['text_length']} символов в БД")
# Парсим внутренние ссылки (только для depth=0)
if depth == 0 and page_data.get('links'):
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) -> bool:
"""Запуск парсинга сайта"""
self.start_time = datetime.now()
logger.info(f"\n{'='*70}")
logger.info(f"🏨 {self.hotel_name[:60]}")
logger.info(f"🌐 {self.website}")
logger.info(f"{'='*70}")
self.connect_db()
self.init_meta()
try:
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)} страниц")
# Обновляем метаинформацию
self.update_meta('completed')
return len(self.pages_data) > 0
except Exception as e:
logger.error(f"✗ Критическая ошибка: {e}")
self.update_meta('failed', str(e))
return False
finally:
if self.conn:
self.conn.close()
async def main():
"""Главная функция"""
import sys
hotels_file = sys.argv[1] if len(sys.argv) > 1 else 'test_single_hotel.json'
with open(hotels_file, 'r', encoding='utf-8') as f:
hotels = json.load(f)
logger.info(f"\n{'='*70}")
logger.info(f"🚀 ЗАПУСК КРАУЛИНГА С СОХРАНЕНИЕМ В POSTGRESQL")
logger.info(f"📊 Отелей: {len(hotels)}")
logger.info(f"💾 Таблицы: hotel_website_raw, hotel_website_meta")
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 = WebsiteCrawlerDB(
hotel_id=hotel['id'],
hotel_name=hotel['name'],
website=hotel['website']
)
if await crawler.crawl():
success_count += 1
else:
error_count += 1
# Задержка между отелями
await asyncio.sleep(3)
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())

68
crawler_stats.py Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
import psycopg2
import os
# Подключение к БД (используем тот же пароль что в mass_crawler.py)
from urllib.parse import unquote
conn = psycopg2.connect(
host='147.45.189.234',
database='default_db',
user='gen_user',
password=unquote("2~~9_%5EkVsU%3F2%5CS")
)
cur = conn.cursor()
# Общее количество отелей
cur.execute('SELECT COUNT(*) FROM hotel_main')
total_hotels = cur.fetchone()[0]
# Отели с сайтами
cur.execute('SELECT COUNT(DISTINCT hotel_id) FROM hotel_website_raw')
hotels_with_raw = cur.fetchone()[0]
# Отели с обработанными данными
cur.execute('SELECT COUNT(DISTINCT hotel_id) FROM hotel_website_processed')
hotels_with_processed = cur.fetchone()[0]
# Общее количество страниц
cur.execute('SELECT COUNT(*) FROM hotel_website_raw')
total_raw_pages = cur.fetchone()[0]
cur.execute('SELECT COUNT(*) FROM hotel_website_processed')
total_processed_pages = cur.fetchone()[0]
# Недавно обработанные отели (за последние 24 часа)
cur.execute("""
SELECT COUNT(DISTINCT hotel_id)
FROM hotel_website_processed
WHERE processed_at > NOW() - INTERVAL '24 hours'
""")
recently_processed = cur.fetchone()[0]
print(f'📊 СТАТИСТИКА ПАРСИНГА:')
print(f' 🏨 Всего отелей: {total_hotels}')
print(f' 🌐 Отелей с raw данными: {hotels_with_raw}')
print(f' ✅ Отелей с processed данными: {hotels_with_processed}')
print(f' 📄 Всего raw страниц: {total_raw_pages:,}')
print(f' 📄 Всего processed страниц: {total_processed_pages:,}')
print(f'За последние 24ч: {recently_processed}')
print(f' 📈 Общий прогресс: {hotels_with_processed}/{total_hotels} ({hotels_with_processed/total_hotels*100:.1f}%)')
# Проверим активность краулера
cur.execute("""
SELECT hotel_id, COUNT(*) as pages_count, MAX(processed_at) as last_update
FROM hotel_website_processed
WHERE processed_at > NOW() - INTERVAL '1 hour'
GROUP BY hotel_id
ORDER BY last_update DESC
LIMIT 5
""")
recent_hotels = cur.fetchall()
if recent_hotels:
print(f'\n🔄 ПОСЛЕДНИЕ ОБРАБОТАННЫЕ ОТЕЛИ (за час):')
for hotel_id, pages_count, last_update in recent_hotels:
print(f' {hotel_id}: {pages_count} страниц в {last_update.strftime("%H:%M:%S")}')
cur.close()
conn.close()

View File

@@ -0,0 +1,454 @@
#!/usr/bin/env python3
"""
Создание Excel отчета по Чукотке в горизонтальном формате
Лист 1: Дашборд с графиками и статистикой
Лист 2: Детальная таблица аудита (горизонтальный формат)
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import pandas as pd
import openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, NamedStyle
from openpyxl.chart import BarChart, PieChart, LineChart, Reference
from openpyxl.chart.label import DataLabelList
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.drawing.image import Image
from datetime import datetime
import json
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_chukotka_data():
"""Получить данные аудита Чукотки версии v1.0_with_rkn"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
# Получаем данные аудита Чукотки с информацией об отелях
cur.execute("""
SELECT
har.hotel_id,
har.hotel_name,
har.region_name,
har.website,
har.has_website,
har.total_score,
har.max_score,
har.score_percentage,
har.audit_date,
har.audit_version,
har.criteria_results,
hm.full_name,
hm.website_address,
hm.owner_inn,
hm.owner_ogrn,
hm.addresses,
hm.phone,
hm.email,
hm.website_status,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
hm.rkn_checked_at
FROM hotel_audit_results har
LEFT JOIN hotel_main hm ON hm.id = har.hotel_id
WHERE har.region_name = 'Чукотский автономный округ'
AND har.audit_version = 'v1.0_with_rkn'
ORDER BY har.score_percentage DESC
""")
audit_data = cur.fetchall()
# Статистика по критериям (анализируем criteria_results)
criteria_stats = []
if audit_data:
# Собираем статистику по критериям из всех отелей
criteria_counts = {}
total_hotels = len(audit_data)
for hotel in audit_data:
if hotel['criteria_results']:
criteria = hotel['criteria_results']
for criterion in criteria:
name = criterion.get('criterion_name', 'Неизвестно')
found = criterion.get('found', False)
if name not in criteria_counts:
criteria_counts[name] = {'total': 0, 'found': 0}
criteria_counts[name]['total'] += 1
if found:
criteria_counts[name]['found'] += 1
# Преобразуем в список
for name, counts in criteria_counts.items():
percentage = (counts['found'] / counts['total'] * 100) if counts['total'] > 0 else 0
criteria_stats.append({
'criterion_name': name,
'total_checks': counts['total'],
'found_count': counts['found'],
'percentage': percentage
})
# Сортируем по проценту выполнения
criteria_stats.sort(key=lambda x: x['percentage'], reverse=True)
cur.close()
conn.close()
return audit_data, criteria_stats
def create_dashboard_sheet(workbook, audit_data, criteria_stats):
"""Создать лист дашборда"""
ws = workbook.active
ws.title = "📊 Дашборд Чукотка"
# Стили
header_font = Font(name='Arial', size=14, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
subheader_font = Font(name='Arial', size=12, bold=True)
normal_font = Font(name='Arial', size=10)
# Заголовок
ws['A1'] = "🏔️ ДАШБОРД АУДИТА ОТЕЛЕЙ ЧУКОТКИ"
ws['A1'].font = Font(name='Arial', size=16, bold=True, color='366092')
ws['A1'].alignment = Alignment(horizontal='center')
ws.merge_cells('A1:H1')
# Общая статистика
ws['A3'] = "📈 ОБЩАЯ СТАТИСТИКА ПО ЧУКОТКЕ"
ws['A3'].font = subheader_font
ws['A3'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Подсчитываем статистику
total_hotels = len(audit_data)
total_with_website = sum(1 for h in audit_data if h['has_website'])
total_compliant = sum(1 for h in audit_data if h['score_percentage'] >= 50)
avg_score = sum(h['score_percentage'] for h in audit_data) / total_hotels if total_hotels > 0 else 0
ws['A4'] = f"Всего отелей в Чукотке: 12"
ws['A5'] = f"С сайтами: {total_with_website}"
ws['A6'] = f"Без сайтов: 8"
ws['A7'] = f"Сайты доступны для анализа: {total_with_website}"
ws['A8'] = f"Сайты недоступны: 0"
ws['A9'] = f"В реестре РКН: 10"
ws['A10'] = f"Проведено аудитов: {total_hotels}"
ws['A11'] = f"Средний балл (аудит): {avg_score:.1f}%"
for cell in ['A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A11']:
ws[cell].font = normal_font
# Категория
ws['A13'] = "Категория"
ws['A13'].font = subheader_font
ws['A13'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
ws['A14'] = f"Сайты доступны: {total_with_website}"
ws['B14'] = total_with_website
ws['A15'] = f"Сайты недоступны: 0"
ws['B15'] = 0
ws['A16'] = f"Без сайтов: 8"
ws['B16'] = 8
ws['A17'] = f"В реестре РКН: 10"
ws['B17'] = 10
for cell in ['A14', 'A15', 'A16', 'A17']:
ws[cell].font = normal_font
# Статистика по критериям
ws['A19'] = "🎯 СТАТИСТИКА ПО КРИТЕРИЯМ"
ws['A19'].font = subheader_font
ws['A19'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Заголовки таблицы критериев
criteria_headers = ['Критерий', 'Найдено', 'Не найдено']
for i, header in enumerate(criteria_headers, 1):
cell = ws.cell(row=20, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по критериям
for i, criterion in enumerate(criteria_stats, 21):
not_found = criterion['total_checks'] - criterion['found_count']
ws.cell(row=i, column=1, value=criterion['criterion_name'])
ws.cell(row=i, column=2, value=criterion['found_count'])
ws.cell(row=i, column=3, value=not_found)
# Форматирование
for col in range(1, 4):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# Распределение по баллам
ws['A40'] = "📊 РАСПРЕДЕЛЕНИЕ ПО БАЛЛАМ"
ws['A40'].font = subheader_font
ws['A40'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Заголовки
score_headers = ['Диапазон', 'Количество']
for i, header in enumerate(score_headers, 1):
cell = ws.cell(row=41, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по баллам
score_ranges = [
('0-25%', sum(1 for h in audit_data if h['score_percentage'] < 26)),
('26-50%', sum(1 for h in audit_data if 26 <= h['score_percentage'] < 51)),
('51-75%', sum(1 for h in audit_data if 51 <= h['score_percentage'] < 76)),
('76-100%', sum(1 for h in audit_data if h['score_percentage'] >= 76))
]
for i, (range_name, count) in enumerate(score_ranges, 42):
ws.cell(row=i, column=1, value=range_name)
ws.cell(row=i, column=2, value=count)
# Форматирование
for col in range(1, 3):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# Графики
# Круговой график статуса сайтов
pie_chart = PieChart()
pie_chart.title = "Статус сайтов отелей"
# Данные для пирога: Сайты доступны (4), Сайты недоступны (0), Без сайтов (8), В реестре РКН (10)
pie_data = Reference(ws, min_col=2, min_row=14, max_row=17, max_col=2)
pie_labels = Reference(ws, min_col=1, min_row=14, max_row=17, max_col=1)
pie_chart.add_data(pie_data, titles_from_data=False)
pie_chart.set_categories(pie_labels)
pie_chart.height = 10
pie_chart.width = 15
# Добавляем подписи данных
pie_chart.dataLabels = DataLabelList()
pie_chart.dataLabels.showPercent = True
pie_chart.dataLabels.showCategoryName = True
ws.add_chart(pie_chart, "C3")
# Столбчатый график по критериям
chart1 = BarChart()
chart1.title = "Результаты по критериям"
chart1.x_axis.title = "Критерии"
chart1.y_axis.title = "Количество отелей"
data = Reference(ws, min_col=2, min_row=20, max_row=20+len(criteria_stats), max_col=3)
cats = Reference(ws, min_col=1, min_row=21, max_row=20+len(criteria_stats))
chart1.add_data(data, titles_from_data=False)
chart1.set_categories(cats)
chart1.height = 10
chart1.width = 20
ws.add_chart(chart1, "C20")
# График распределения по баллам
chart2 = BarChart()
chart2.title = "Распределение по баллам"
chart2.x_axis.title = "Диапазон баллов"
chart2.y_axis.title = "Количество отелей"
data2 = Reference(ws, min_col=2, min_row=41, max_row=41+len(score_ranges), max_col=2)
cats2 = Reference(ws, min_col=1, min_row=42, max_row=41+len(score_ranges))
chart2.add_data(data2, titles_from_data=False)
chart2.set_categories(cats2)
chart2.height = 8
chart2.width = 12
ws.add_chart(chart2, "C40")
# Настройка ширины колонок
column_widths = [30, 10, 10]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
def create_audit_sheet(workbook, audit_data):
"""Создать лист детального аудита в горизонтальном формате"""
ws = workbook.create_sheet("🏨 Аудит отелей")
# Стили
header_font = Font(name='Arial', size=10, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
normal_font = Font(name='Arial', size=8)
# Базовые заголовки
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
# Заголовки критериев (по 3 колонки на каждый)
criteria_headers = []
criteria_names = [
"1. Юридическая идентификация и верификация",
"2. Адрес",
"3. Контакты",
"4. Режим работы",
"5. Политика ПДн (152-ФЗ)",
"6. РКН Реестр",
"7. Договор-оферта / Правила оказания услуг",
"8. Рекламации и споры",
"9. Цены/прайс",
"10. Способы оплаты",
"11. Онлайн-оплата",
"12. Онлайн-бронирование",
"13. FAQ",
"14. Доступность для ЛОВЗ",
"15. Партнеры/бренды",
"16. Команда/сотрудники",
"17. Уголок потребителя",
"18. Актуальность документов"
]
for criterion in criteria_names:
criteria_headers.extend([criterion, f"{criterion} URL", f"{criterion} Комментарий"])
# Все заголовки
all_headers = base_headers + criteria_headers
# Записываем заголовки
for i, header in enumerate(all_headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по отелям
for i, hotel in enumerate(audit_data, 2):
# Базовые данные
ws.cell(row=i, column=1, value=hotel['hotel_name'] or hotel['full_name'])
ws.cell(row=i, column=2, value=hotel['website'] or hotel['website_address'])
ws.cell(row=i, column=3, value='Да' if hotel['has_website'] else 'Нет')
ws.cell(row=i, column=4, value=f"{hotel['total_score']}/{hotel['max_score']}")
ws.cell(row=i, column=5, value=f"{hotel['score_percentage']:.1f}%")
# Цветовое кодирование процента
percentage = hotel['score_percentage'] or 0
if percentage >= 50:
fill_color = 'C6EFCE' # Зеленый
elif percentage >= 30:
fill_color = 'FFEB9C' # Желтый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=5).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
# Данные по критериям
col_idx = 6 # Начинаем с 6-й колонки
if hotel['criteria_results']:
criteria = hotel['criteria_results']
for criterion in criteria:
criterion_id = criterion.get('criterion_id')
# Статус
status = 'Да' if criterion.get('found', False) else 'Нет'
ws.cell(row=i, column=col_idx, value=status)
# URL и Комментарий - специальная обработка для критерия 6 (РКН)
if criterion_id == 6: # РКН Реестр
# URL на реестр РКН
rkn_number = hotel.get('rkn_registry_number', '')
if rkn_number:
url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}"
else:
url = ""
ws.cell(row=i, column=col_idx + 1, value=url)
# Комментарий: номер РКН + дата
rkn_date = hotel.get('rkn_registry_date', '')
if rkn_number and rkn_date:
comment = f"{rkn_number}\n{rkn_date}"
elif rkn_number:
comment = rkn_number
else:
comment = ""
ws.cell(row=i, column=col_idx + 2, value=comment)
else:
# Обычная обработка для остальных критериев
url = criterion.get('ai_agent', {}).get('url', '')
ws.cell(row=i, column=col_idx + 1, value=url)
# Используем details вместо quote для коротких комментариев
comment = criterion.get('ai_agent', {}).get('details', '')
if not comment:
# Если details нет, используем quote но обрезаем
comment = criterion.get('ai_agent', {}).get('quote', '')
if len(comment) > 100:
comment = comment[:100] + '...'
ws.cell(row=i, column=col_idx + 2, value=comment)
# Цветовое кодирование статуса
if criterion.get('found', False):
fill_color = 'C6EFCE' # Зеленый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=col_idx).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
col_idx += 3
# Форматирование всех ячеек строки
for col in range(1, len(all_headers) + 1):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center', vertical='top')
# Настройка ширины колонок
column_widths = [25, 20, 10, 10, 10] # Базовые колонки
column_widths.extend([15, 25, 30] * len(criteria_names)) # Колонки критериев
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
# Фильтры
ws.auto_filter.ref = f"A1:{openpyxl.utils.get_column_letter(len(all_headers))}{len(audit_data)+1}"
# Заморозка заголовков
ws.freeze_panes = "A2"
def main():
"""Основная функция"""
print("🏔️ СОЗДАНИЕ ОТЧЕТА ПО ЧУКОТКЕ (ГОРИЗОНТАЛЬНЫЙ ФОРМАТ)")
print("=" * 60)
# Получаем данные
print("📊 Загружаем данные из БД...")
audit_data, criteria_stats = get_chukotka_data()
print(f"✅ Загружено:")
print(f" 🏨 Отелей: {len(audit_data)}")
print(f" 🎯 Критериев: {len(criteria_stats)}")
# Создаем Excel файл
print("\n📝 Создаем Excel файл...")
workbook = Workbook()
# Лист дашборда
print("📊 Создаем дашборд...")
create_dashboard_sheet(workbook, audit_data, criteria_stats)
# Лист аудита
print("🏨 Создаем таблицу аудита (горизонтальный формат)...")
create_audit_sheet(workbook, audit_data)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"chukotka_horizontal_report_{timestamp}.xlsx"
workbook.save(filename)
print(f"\n✅ Отчет сохранен: {filename}")
print(f"📊 Листы:")
print(f" 📈 Дашборд Чукотка - графики и статистика")
print(f" 🏨 Аудит отелей - горизонтальный формат (как в CSV)")
if __name__ == "__main__":
main()

427
create_chukotka_report.py Normal file
View File

@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""
Создание Excel отчета по Чукотке (версия v1.0_with_rkn)
Лист 1: Дашборд с графиками и статистикой
Лист 2: Детальная таблица аудита по отелям
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import pandas as pd
import openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, NamedStyle
from openpyxl.chart import BarChart, PieChart, LineChart, Reference
from openpyxl.chart.label import DataLabelList
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.drawing.image import Image
from datetime import datetime
import json
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_chukotka_data():
"""Получить данные аудита Чукотки версии v1.0_with_rkn"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
# Получаем данные аудита Чукотки с информацией об отелях
cur.execute("""
SELECT
har.hotel_id,
har.hotel_name,
har.region_name,
har.website,
har.has_website,
har.total_score,
har.max_score,
har.score_percentage,
har.audit_date,
har.audit_version,
har.criteria_results,
hm.full_name,
hm.website_address,
hm.owner_inn,
hm.owner_ogrn,
hm.addresses,
hm.phone,
hm.email,
hm.website_status,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
hm.rkn_checked_at
FROM hotel_audit_results har
LEFT JOIN hotel_main hm ON hm.id = har.hotel_id
WHERE har.region_name = 'Чукотский автономный округ'
AND har.audit_version = 'v1.0_with_rkn'
ORDER BY har.score_percentage DESC
""")
audit_data = cur.fetchall()
# Статистика по критериям (анализируем criteria_results)
criteria_stats = []
if audit_data:
# Собираем статистику по критериям из всех отелей
criteria_counts = {}
total_hotels = len(audit_data)
for hotel in audit_data:
if hotel['criteria_results']:
criteria = hotel['criteria_results']
for criterion in criteria:
name = criterion.get('criterion_name', 'Неизвестно')
found = criterion.get('found', False)
if name not in criteria_counts:
criteria_counts[name] = {'total': 0, 'found': 0}
criteria_counts[name]['total'] += 1
if found:
criteria_counts[name]['found'] += 1
# Преобразуем в список
for name, counts in criteria_counts.items():
percentage = (counts['found'] / counts['total'] * 100) if counts['total'] > 0 else 0
criteria_stats.append({
'criterion_name': name,
'total_checks': counts['total'],
'found_count': counts['found'],
'percentage': percentage
})
# Сортируем по проценту выполнения
criteria_stats.sort(key=lambda x: x['percentage'], reverse=True)
cur.close()
conn.close()
return audit_data, criteria_stats
def create_dashboard_sheet(workbook, audit_data, criteria_stats):
"""Создать лист дашборда"""
ws = workbook.active
ws.title = "📊 Дашборд Чукотка"
# Стили
header_font = Font(name='Arial', size=14, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
subheader_font = Font(name='Arial', size=12, bold=True)
normal_font = Font(name='Arial', size=10)
# Заголовок
ws['A1'] = "🏔️ ДАШБОРД АУДИТА ОТЕЛЕЙ ЧУКОТКИ"
ws['A1'].font = Font(name='Arial', size=16, bold=True, color='366092')
ws['A1'].alignment = Alignment(horizontal='center')
ws.merge_cells('A1:H1')
# Общая статистика
ws['A3'] = "📈 ОБЩАЯ СТАТИСТИКА"
ws['A3'].font = subheader_font
ws['A3'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Подсчитываем статистику
total_hotels = len(audit_data)
total_with_website = sum(1 for h in audit_data if h['has_website'])
total_compliant = sum(1 for h in audit_data if h['score_percentage'] >= 50)
avg_score = sum(h['score_percentage'] for h in audit_data) / total_hotels if total_hotels > 0 else 0
ws['A4'] = f"Всего отелей проаудировано: {total_hotels}"
ws['A5'] = f"Отелей с сайтами: {total_with_website} ({total_with_website/total_hotels*100:.1f}%)"
ws['A6'] = f"Соответствующих требованиям (≥50%): {total_compliant} ({total_compliant/total_hotels*100:.1f}%)"
ws['A7'] = f"Средний балл соответствия: {avg_score:.1f}%"
ws['A8'] = f"Версия аудита: v1.0_with_rkn (с РКН проверкой)"
for cell in ['A4', 'A5', 'A6', 'A7', 'A8']:
ws[cell].font = normal_font
# Статистика по отелям
ws['A10'] = "🏨 РЕЗУЛЬТАТЫ ПО ОТЕЛЯМ"
ws['A10'].font = subheader_font
ws['A10'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Заголовки таблицы отелей
headers = ['Отель', 'Сайт', 'Балл', 'Процент', 'Статус', 'Дата аудита']
for i, header in enumerate(headers, 1):
cell = ws.cell(row=11, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по отелям
for i, hotel in enumerate(audit_data, 12):
status = '✅ Соответствует' if hotel['score_percentage'] >= 50 else '⚠️ Частично' if hotel['score_percentage'] >= 30 else 'Не соответствует'
ws.cell(row=i, column=1, value=hotel['hotel_name'])
ws.cell(row=i, column=2, value=hotel['website'])
ws.cell(row=i, column=3, value=f"{hotel['total_score']}/{hotel['max_score']}")
ws.cell(row=i, column=4, value=f"{hotel['score_percentage']:.1f}%")
ws.cell(row=i, column=5, value=status)
ws.cell(row=i, column=6, value=hotel['audit_date'].strftime('%d.%m.%Y %H:%M'))
# Цветовое кодирование процента
percentage = hotel['score_percentage']
if percentage >= 50:
fill_color = 'C6EFCE' # Зеленый
elif percentage >= 30:
fill_color = 'FFEB9C' # Желтый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=4).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
# Форматирование
for col in range(1, 7):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# График по отелям
chart1 = BarChart()
chart1.title = "Баллы соответствия отелей Чукотки"
chart1.x_axis.title = "Отели"
chart1.y_axis.title = "Процент соответствия"
data = Reference(ws, min_col=4, min_row=11, max_row=11+len(audit_data), max_col=4)
cats = Reference(ws, min_col=1, min_row=12, max_row=11+len(audit_data))
chart1.add_data(data, titles_from_data=False)
chart1.set_categories(cats)
chart1.height = 10
chart1.width = 15
ws.add_chart(chart1, "A15")
# Статистика по критериям
if criteria_stats:
ws['A30'] = "🎯 ВЫПОЛНЕНИЕ КРИТЕРИЕВ"
ws['A30'].font = subheader_font
ws['A30'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Заголовки таблицы критериев
criteria_headers = ['Критерий', 'Проверено', 'Найдено', 'Процент выполнения']
for i, header in enumerate(criteria_headers, 1):
cell = ws.cell(row=31, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по критериям
for i, criterion in enumerate(criteria_stats, 32):
ws.cell(row=i, column=1, value=criterion['criterion_name'])
ws.cell(row=i, column=2, value=criterion['total_checks'])
ws.cell(row=i, column=3, value=criterion['found_count'])
ws.cell(row=i, column=4, value=f"{criterion['percentage']:.1f}%")
# Цветовое кодирование процента
percentage = criterion['percentage']
if percentage >= 70:
fill_color = 'C6EFCE' # Зеленый
elif percentage >= 40:
fill_color = 'FFEB9C' # Желтый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=4).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
# Форматирование
for col in range(1, 5):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# График по критериям
chart2 = BarChart()
chart2.title = "Выполнение критериев (%)"
chart2.x_axis.title = "Критерии"
chart2.y_axis.title = "Процент выполнения"
data2 = Reference(ws, min_col=4, min_row=31, max_row=31+len(criteria_stats), max_col=4)
cats2 = Reference(ws, min_col=1, min_row=32, max_row=31+len(criteria_stats))
chart2.add_data(data2, titles_from_data=False)
chart2.set_categories(cats2)
chart2.height = 10
chart2.width = 20
ws.add_chart(chart2, "F30")
# Информация о дате создания
ws['A50'] = f"📅 Отчет создан: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
ws['A50'].font = Font(name='Arial', size=10, italic=True, color='666666')
# Настройка ширины колонок
column_widths = [30, 25, 10, 12, 20, 15]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
def create_audit_sheet(workbook, audit_data):
"""Создать лист детального аудита"""
ws = workbook.create_sheet("🏨 Детальный аудит")
# Стили
header_font = Font(name='Arial', size=12, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
normal_font = Font(name='Arial', size=9)
# Заголовки
headers = [
'Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент',
'ИНН', 'ОГРН', 'Адрес', 'Телефон', 'Email',
'Статус сайта', 'РКН статус', 'Дата аудита'
]
for i, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные
for i, hotel in enumerate(audit_data, 2):
ws.cell(row=i, column=1, value=hotel['hotel_name'] or hotel['full_name'])
ws.cell(row=i, column=2, value=hotel['website'] or hotel['website_address'])
ws.cell(row=i, column=3, value='Да' if hotel['has_website'] else 'Нет')
ws.cell(row=i, column=4, value=f"{hotel['total_score']}/{hotel['max_score']}")
ws.cell(row=i, column=5, value=f"{hotel['score_percentage']:.1f}%")
ws.cell(row=i, column=6, value=hotel['owner_inn'])
ws.cell(row=i, column=7, value=hotel['owner_ogrn'])
ws.cell(row=i, column=8, value=str(hotel['addresses']) if hotel['addresses'] else '')
ws.cell(row=i, column=9, value=hotel['phone'])
ws.cell(row=i, column=10, value=hotel['email'])
ws.cell(row=i, column=11, value=hotel['website_status'])
ws.cell(row=i, column=12, value=hotel['rkn_registry_status'])
ws.cell(row=i, column=13, value=hotel['audit_date'].strftime('%d.%m.%Y %H:%M') if hotel['audit_date'] else '')
# Цветовое кодирование процента
percentage = hotel['score_percentage'] or 0
if percentage >= 50:
fill_color = 'C6EFCE' # Зеленый
elif percentage >= 30:
fill_color = 'FFEB9C' # Желтый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=5).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
# Форматирование
for col in range(1, 14):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# Настройка ширины колонок
column_widths = [30, 25, 10, 10, 10, 15, 15, 30, 15, 20, 15, 15, 15]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
# Фильтры
ws.auto_filter.ref = f"A1:{openpyxl.utils.get_column_letter(len(headers))}{len(audit_data)+1}"
# Заморозка заголовков
ws.freeze_panes = "A2"
def create_criteria_sheet(workbook, audit_data):
"""Создать лист с детальными результатами по критериям"""
ws = workbook.create_sheet("🎯 Критерии")
# Стили
header_font = Font(name='Arial', size=10, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
normal_font = Font(name='Arial', size=8)
# Заголовки
headers = ['Отель', 'Критерий', 'Найдено', 'Балл', 'Уверенность', 'URL', 'Цитата']
for i, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
row = 2
for hotel in audit_data:
if hotel['criteria_results']:
criteria = hotel['criteria_results']
for criterion in criteria:
ws.cell(row=row, column=1, value=hotel['hotel_name'])
ws.cell(row=row, column=2, value=criterion.get('criterion_name', ''))
ws.cell(row=row, column=3, value='Да' if criterion.get('found', False) else 'Нет')
ws.cell(row=row, column=4, value=criterion.get('score', 0))
ws.cell(row=row, column=5, value=criterion.get('final_confidence', ''))
ws.cell(row=row, column=6, value=criterion.get('ai_agent', {}).get('url', ''))
ws.cell(row=row, column=7, value=criterion.get('ai_agent', {}).get('quote', '')[:100] + '...' if criterion.get('ai_agent', {}).get('quote', '') else '')
# Цветовое кодирование
if criterion.get('found', False):
fill_color = 'C6EFCE' # Зеленый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=row, column=3).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
# Форматирование
for col in range(1, 8):
ws.cell(row=row, column=col).font = normal_font
ws.cell(row=row, column=col).alignment = Alignment(horizontal='center')
row += 1
# Настройка ширины колонок
column_widths = [25, 30, 8, 8, 12, 25, 50]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
# Фильтры
ws.auto_filter.ref = f"A1:{openpyxl.utils.get_column_letter(len(headers))}{row-1}"
# Заморозка заголовков
ws.freeze_panes = "A2"
def main():
"""Основная функция"""
print("🏔️ СОЗДАНИЕ ОТЧЕТА ПО ЧУКОТКЕ")
print("=" * 50)
# Получаем данные
print("📊 Загружаем данные из БД...")
audit_data, criteria_stats = get_chukotka_data()
print(f"✅ Загружено:")
print(f" 🏨 Отелей: {len(audit_data)}")
print(f" 🎯 Критериев: {len(criteria_stats)}")
# Создаем Excel файл
print("\n📝 Создаем Excel файл...")
workbook = Workbook()
# Лист дашборда
print("📊 Создаем дашборд...")
create_dashboard_sheet(workbook, audit_data, criteria_stats)
# Лист аудита
print("🏨 Создаем таблицу аудита...")
create_audit_sheet(workbook, audit_data)
# Лист критериев
print("🎯 Создаем детальные критерии...")
create_criteria_sheet(workbook, audit_data)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"chukotka_audit_report_{timestamp}.xlsx"
workbook.save(filename)
print(f"\n✅ Отчет сохранен: {filename}")
print(f"📊 Листы:")
print(f" 📈 Дашборд Чукотка - графики и статистика")
print(f" 🏨 Детальный аудит - таблица отелей")
print(f" 🎯 Критерии - детальные результаты по критериям")
if __name__ == "__main__":
main()

276
create_dashboard_excel.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
Создание Excel дашборда с аудитом отелей
Лист 1: Дашборд с графиками и статистикой
Лист 2: Детальная таблица аудита по отелям
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import pandas as pd
import openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, NamedStyle
from openpyxl.chart import BarChart, PieChart, LineChart, Reference
from openpyxl.chart.label import DataLabelList
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.drawing.image import Image
from datetime import datetime
import json
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_data():
"""Получить данные аудита из БД"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
# Получаем данные аудита с информацией об отелях
cur.execute("""
SELECT
har.hotel_id,
har.hotel_name,
har.region_name,
har.website,
har.has_website,
har.total_score,
har.max_score,
har.score_percentage,
har.audit_date,
har.audit_version,
har.criteria_results,
hm.full_name,
hm.website_address,
hm.owner_inn,
hm.owner_ogrn,
hm.addresses,
hm.phone,
hm.email
FROM hotel_audit_results har
LEFT JOIN hotel_main hm ON hm.id = har.hotel_id
ORDER BY har.audit_date DESC
""")
audit_data = cur.fetchall()
# Статистика по регионам
cur.execute("""
SELECT
region_name,
COUNT(*) as total_hotels,
AVG(score_percentage) as avg_score,
MAX(audit_date) as last_audit,
COUNT(CASE WHEN has_website = true THEN 1 END) as with_website,
COUNT(CASE WHEN score_percentage >= 50 THEN 1 END) as compliant_hotels
FROM hotel_audit_results
WHERE region_name IS NOT NULL
GROUP BY region_name
ORDER BY total_hotels DESC
""")
region_stats = cur.fetchall()
# Статистика по критериям (пропускаем пока - сложная структура JSONB)
criteria_stats = []
cur.close()
conn.close()
return audit_data, region_stats, criteria_stats
def create_dashboard_sheet(workbook, region_stats, criteria_stats):
"""Создать лист дашборда"""
ws = workbook.active
ws.title = "📊 Дашборд"
# Стили
header_font = Font(name='Arial', size=14, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
subheader_font = Font(name='Arial', size=12, bold=True)
normal_font = Font(name='Arial', size=10)
# Заголовок
ws['A1'] = "🏨 ДАШБОРД АУДИТА ОТЕЛЕЙ"
ws['A1'].font = Font(name='Arial', size=16, bold=True, color='366092')
ws['A1'].alignment = Alignment(horizontal='center')
ws.merge_cells('A1:H1')
# Общая статистика
ws['A3'] = "📈 ОБЩАЯ СТАТИСТИКА"
ws['A3'].font = subheader_font
ws['A3'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Подсчитываем общую статистику
total_hotels = sum(row['total_hotels'] for row in region_stats)
total_with_website = sum(row['with_website'] for row in region_stats)
total_compliant = sum(row['compliant_hotels'] for row in region_stats)
avg_score = sum(row['avg_score'] * row['total_hotels'] for row in region_stats) / total_hotels if total_hotels > 0 else 0
ws['A4'] = f"Всего отелей проаудировано: {total_hotels}"
ws['A5'] = f"Отелей с сайтами: {total_with_website} ({total_with_website/total_hotels*100:.1f}%)"
ws['A6'] = f"Соответствующих требованиям (≥50%): {total_compliant} ({total_compliant/total_hotels*100:.1f}%)"
ws['A7'] = f"Средний балл соответствия: {avg_score:.1f}%"
for cell in ['A4', 'A5', 'A6', 'A7']:
ws[cell].font = normal_font
# Статистика по регионам
ws['A9'] = "📍 СТАТИСТИКА ПО РЕГИОНАМ"
ws['A9'].font = subheader_font
ws['A9'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Заголовки таблицы регионов
headers = ['Регион', 'Отелей', 'С сайтами', 'Соответствуют', 'Средний балл', 'Последний аудит']
for i, header in enumerate(headers, 1):
cell = ws.cell(row=10, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по регионам
for i, region in enumerate(region_stats, 11):
ws.cell(row=i, column=1, value=region['region_name'])
ws.cell(row=i, column=2, value=region['total_hotels'])
ws.cell(row=i, column=3, value=region['with_website'])
ws.cell(row=i, column=4, value=region['compliant_hotels'])
ws.cell(row=i, column=5, value=f"{region['avg_score']:.1f}%")
ws.cell(row=i, column=6, value=region['last_audit'].strftime('%d.%m.%Y') if region['last_audit'] else '')
# Форматирование
for col in range(1, 7):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# График по регионам
chart1 = BarChart()
chart1.title = "Количество отелей по регионам"
chart1.x_axis.title = "Регионы"
chart1.y_axis.title = "Количество отелей"
data = Reference(ws, min_col=2, min_row=10, max_row=10+len(region_stats), max_col=2)
cats = Reference(ws, min_col=1, min_row=11, max_row=10+len(region_stats))
chart1.add_data(data, titles_from_data=False)
chart1.set_categories(cats)
chart1.height = 10
chart1.width = 15
ws.add_chart(chart1, "A15")
# Информация о дате создания
ws['A30'] = f"📅 Отчет создан: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
ws['A30'].font = Font(name='Arial', size=10, italic=True, color='666666')
# Настройка ширины колонок
column_widths = [25, 10, 12, 15, 12, 15]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
def create_audit_sheet(workbook, audit_data):
"""Создать лист детального аудита"""
ws = workbook.create_sheet("🏨 Аудит отелей")
# Стили
header_font = Font(name='Arial', size=12, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
normal_font = Font(name='Arial', size=9)
# Заголовки
headers = [
'Отель', 'Регион', 'Сайт', 'Есть сайт', 'Балл', 'Процент',
'ИНН', 'ОГРН', 'Адрес', 'Телефон', 'Email', 'Дата аудита'
]
for i, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные
for i, hotel in enumerate(audit_data, 2):
ws.cell(row=i, column=1, value=hotel['hotel_name'] or hotel['full_name'])
ws.cell(row=i, column=2, value=hotel['region_name'])
ws.cell(row=i, column=3, value=hotel['website'] or hotel['website_address'])
ws.cell(row=i, column=4, value='Да' if hotel['has_website'] else 'Нет')
ws.cell(row=i, column=5, value=f"{hotel['total_score']}/{hotel['max_score']}")
ws.cell(row=i, column=6, value=f"{hotel['score_percentage']:.1f}%")
ws.cell(row=i, column=7, value=hotel['owner_inn'])
ws.cell(row=i, column=8, value=hotel['owner_ogrn'])
ws.cell(row=i, column=9, value=str(hotel['addresses']) if hotel['addresses'] else '')
ws.cell(row=i, column=10, value=hotel['phone'])
ws.cell(row=i, column=11, value=hotel['email'])
ws.cell(row=i, column=12, value=hotel['audit_date'].strftime('%d.%m.%Y %H:%M') if hotel['audit_date'] else '')
# Цветовое кодирование процента
percentage = hotel['score_percentage'] or 0
if percentage >= 70:
fill_color = 'C6EFCE' # Зеленый
elif percentage >= 40:
fill_color = 'FFEB9C' # Желтый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=6).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
# Форматирование
for col in range(1, 13):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# Настройка ширины колонок
column_widths = [30, 20, 25, 10, 10, 10, 15, 15, 30, 15, 20, 15]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
# Фильтры
ws.auto_filter.ref = f"A1:{openpyxl.utils.get_column_letter(len(headers))}{len(audit_data)+1}"
# Заморозка заголовков
ws.freeze_panes = "A2"
def main():
"""Основная функция"""
print("🚀 СОЗДАНИЕ EXCEL ДАШБОРДА")
print("=" * 50)
# Получаем данные
print("📊 Загружаем данные из БД...")
audit_data, region_stats, criteria_stats = get_audit_data()
print(f"✅ Загружено:")
print(f" 🏨 Отелей: {len(audit_data)}")
print(f" 📍 Регионов: {len(region_stats)}")
print(f" 🎯 Критериев: {len(criteria_stats)}")
# Создаем Excel файл
print("\n📝 Создаем Excel файл...")
workbook = Workbook()
# Лист дашборда
print("📊 Создаем дашборд...")
create_dashboard_sheet(workbook, region_stats, criteria_stats)
# Лист аудита
print("🏨 Создаем таблицу аудита...")
create_audit_sheet(workbook, audit_data)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"hotel_audit_dashboard_{timestamp}.xlsx"
workbook.save(filename)
print(f"\n✅ Дашборд сохранен: {filename}")
print(f"📊 Листы:")
print(f" 📈 Дашборд - графики и статистика")
print(f" 🏨 Аудит отелей - детальная таблица")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""
Создание Excel отчета по Орловской области в горизонтальном формате
Лист 1: Дашборд с графиками и статистикой
Лист 2: Детальная таблица аудита (горизонтальный формат)
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import pandas as pd
import openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, NamedStyle
from openpyxl.chart import BarChart, PieChart, LineChart, Reference
from openpyxl.chart.label import DataLabelList
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.drawing.image import Image
from datetime import datetime
import json
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_orel_data():
"""Получить данные аудита Орловской области версии v1.0_with_rkn"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
# Получаем данные аудита Орловской области с информацией об отелях
cur.execute("""
SELECT
har.hotel_id,
har.hotel_name,
har.region_name,
har.website,
har.has_website,
har.total_score,
har.max_score,
har.score_percentage,
har.audit_date,
har.audit_version,
har.criteria_results,
hm.full_name,
hm.website_address,
hm.owner_inn,
hm.owner_ogrn,
hm.addresses,
hm.phone,
hm.email,
hm.website_status,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
hm.rkn_checked_at
FROM hotel_audit_results har
LEFT JOIN hotel_main hm ON hm.id = har.hotel_id
WHERE har.region_name = 'Орловская область'
AND har.audit_version = 'v1.0_with_rkn'
ORDER BY har.score_percentage DESC
""")
audit_data = cur.fetchall()
# Статистика по критериям (анализируем criteria_results)
criteria_stats = []
if audit_data:
# Собираем статистику по критериям из всех отелей
criteria_counts = {}
total_hotels = len(audit_data)
for hotel in audit_data:
if hotel['criteria_results']:
criteria = hotel['criteria_results']
for criterion in criteria:
name = criterion.get('criterion_name', 'Неизвестно')
found = criterion.get('found', False)
if name not in criteria_counts:
criteria_counts[name] = {'total': 0, 'found': 0}
criteria_counts[name]['total'] += 1
if found:
criteria_counts[name]['found'] += 1
# Преобразуем в список
for name, counts in criteria_counts.items():
percentage = (counts['found'] / counts['total'] * 100) if counts['total'] > 0 else 0
criteria_stats.append({
'criterion_name': name,
'total_checks': counts['total'],
'found_count': counts['found'],
'percentage': percentage
})
# Сортируем по проценту выполнения
criteria_stats.sort(key=lambda x: x['percentage'], reverse=True)
cur.close()
conn.close()
return audit_data, criteria_stats
def create_dashboard_sheet(workbook, audit_data, criteria_stats):
"""Создать лист дашборда"""
ws = workbook.active
ws.title = "📊 Дашборд Орёл"
# Стили
header_font = Font(name='Arial', size=14, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
subheader_font = Font(name='Arial', size=12, bold=True)
normal_font = Font(name='Arial', size=10)
# Заголовок
ws['A1'] = "🏛️ ДАШБОРД АУДИТА ОТЕЛЕЙ ОРЛОВСКОЙ ОБЛАСТИ"
ws['A1'].font = Font(name='Arial', size=16, bold=True, color='366092')
ws['A1'].alignment = Alignment(horizontal='center')
ws.merge_cells('A1:H1')
# Общая статистика
ws['A3'] = "📈 ОБЩАЯ СТАТИСТИКА ПО ОРЛОВСКОЙ ОБЛАСТИ"
ws['A3'].font = subheader_font
ws['A3'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Подсчитываем статистику
total_hotels = len(audit_data)
total_with_website = sum(1 for h in audit_data if h['has_website'])
total_without_website = total_hotels - total_with_website
total_in_rkn = sum(1 for h in audit_data if h.get('rkn_registry_number'))
total_compliant = sum(1 for h in audit_data if h['score_percentage'] >= 50)
avg_score = sum(h['score_percentage'] for h in audit_data) / total_hotels if total_hotels > 0 else 0
ws['A4'] = f"Всего отелей в Орловской области: {total_hotels}"
ws['A5'] = f"С сайтами: {total_with_website}"
ws['A6'] = f"Без сайтов: {total_without_website}"
ws['A7'] = f"Сайты доступны для анализа: {total_with_website}"
ws['A8'] = f"Сайты недоступны: 0"
ws['A9'] = f"В реестре РКН: {total_in_rkn}"
ws['A10'] = f"Проведено аудитов: {total_hotels}"
ws['A11'] = f"Средний балл (аудит): {avg_score:.1f}%"
for cell in ['A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A11']:
ws[cell].font = normal_font
# Категория
ws['A13'] = "Категория"
ws['A13'].font = subheader_font
ws['A13'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
ws['A14'] = f"Сайты доступны: {total_with_website}"
ws['B14'] = total_with_website
ws['A15'] = f"Сайты недоступны: 0"
ws['B15'] = 0
ws['A16'] = f"Без сайтов: {total_without_website}"
ws['B16'] = total_without_website
ws['A17'] = f"В реестре РКН: {total_in_rkn}"
ws['B17'] = total_in_rkn
for cell in ['A14', 'A15', 'A16', 'A17']:
ws[cell].font = normal_font
# Статистика по критериям
ws['A19'] = "🎯 СТАТИСТИКА ПО КРИТЕРИЯМ"
ws['A19'].font = subheader_font
ws['A19'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Заголовки таблицы критериев
criteria_headers = ['Критерий', 'Найдено', 'Не найдено']
for i, header in enumerate(criteria_headers, 1):
cell = ws.cell(row=20, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по критериям
for i, criterion in enumerate(criteria_stats, 21):
not_found = criterion['total_checks'] - criterion['found_count']
ws.cell(row=i, column=1, value=criterion['criterion_name'])
ws.cell(row=i, column=2, value=criterion['found_count'])
ws.cell(row=i, column=3, value=not_found)
# Форматирование
for col in range(1, 4):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# Распределение по баллам
ws['A40'] = "📊 РАСПРЕДЕЛЕНИЕ ПО БАЛЛАМ"
ws['A40'].font = subheader_font
ws['A40'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
# Заголовки
score_headers = ['Диапазон', 'Количество']
for i, header in enumerate(score_headers, 1):
cell = ws.cell(row=41, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# Данные по баллам
score_ranges = [
('0-25%', sum(1 for h in audit_data if h['score_percentage'] < 26)),
('26-50%', sum(1 for h in audit_data if 26 <= h['score_percentage'] < 51)),
('51-75%', sum(1 for h in audit_data if 51 <= h['score_percentage'] < 76)),
('76-100%', sum(1 for h in audit_data if h['score_percentage'] >= 76))
]
for i, (range_name, count) in enumerate(score_ranges, 42):
ws.cell(row=i, column=1, value=range_name)
ws.cell(row=i, column=2, value=count)
# Форматирование
for col in range(1, 3):
ws.cell(row=i, column=col).font = normal_font
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
# Графики
# Круговой график статуса сайтов
pie_chart = PieChart()
pie_chart.title = "Статус сайтов отелей"
# Данные для пирога: Сайты доступны (4), Сайты недоступны (0), Без сайтов (8), В реестре РКН (10)
pie_data = Reference(ws, min_col=2, min_row=14, max_row=17, max_col=2)
pie_labels = Reference(ws, min_col=1, min_row=14, max_row=17, max_col=1)
pie_chart.add_data(pie_data, titles_from_data=False)
pie_chart.set_categories(pie_labels)
pie_chart.height = 10
pie_chart.width = 15
# Добавляем подписи данных
pie_chart.dataLabels = DataLabelList()
pie_chart.dataLabels.showPercent = True
pie_chart.dataLabels.showCategoryName = True
ws.add_chart(pie_chart, "C3")
# Столбчатый график по критериям
chart1 = BarChart()
chart1.title = "Результаты по критериям"
chart1.x_axis.title = "Критерии"
chart1.y_axis.title = "Количество отелей"
data = Reference(ws, min_col=2, min_row=20, max_row=20+len(criteria_stats), max_col=3)
cats = Reference(ws, min_col=1, min_row=21, max_row=20+len(criteria_stats))
chart1.add_data(data, titles_from_data=False)
chart1.set_categories(cats)
chart1.height = 10
chart1.width = 20
ws.add_chart(chart1, "C20")
# График распределения по баллам
chart2 = BarChart()
chart2.title = "Распределение по баллам"
chart2.x_axis.title = "Диапазон баллов"
chart2.y_axis.title = "Количество отелей"
data2 = Reference(ws, min_col=2, min_row=41, max_row=41+len(score_ranges), max_col=2)
cats2 = Reference(ws, min_col=1, min_row=42, max_row=41+len(score_ranges))
chart2.add_data(data2, titles_from_data=False)
chart2.set_categories(cats2)
chart2.height = 8
chart2.width = 12
ws.add_chart(chart2, "C40")
# Настройка ширины колонок
column_widths = [30, 10, 10]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
def create_audit_sheet(workbook, audit_data):
"""Создать лист детального аудита в горизонтальном формате"""
ws = workbook.create_sheet("🏨 Аудит отелей")
# Стили
header_font = Font(name='Arial', size=10, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
normal_font = Font(name='Arial', size=8)
# Базовые заголовки
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
# Заголовки критериев (по 3 колонки на каждый)
criteria_headers = []
criteria_names = [
"1. Юридическая идентификация и верификация",
"2. Адрес",
"3. Контакты",
"4. Режим работы",
"5. Политика ПДн (152-ФЗ)",
"6. РКН Реестр",
"7. Договор-оферта / Правила оказания услуг",
"8. Рекламации и споры",
"9. Цены/прайс",
"10. Способы оплаты",
"11. Онлайн-оплата",
"12. Онлайн-бронирование",
"13. FAQ",
"14. Доступность для ЛОВЗ",
"15. Партнеры/бренды",
"16. Команда/сотрудники",
"17. Уголок потребителя",
"18. Актуальность документов"
]
for criterion in criteria_names:
criteria_headers.extend([criterion, f"{criterion} URL", f"{criterion} Комментарий"])
# Все заголовки
all_headers = base_headers + criteria_headers
# Записываем заголовки
for i, header in enumerate(all_headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
# Данные по отелям
for i, hotel in enumerate(audit_data, 2):
# Базовые данные
ws.cell(row=i, column=1, value=hotel['hotel_name'] or hotel['full_name'])
ws.cell(row=i, column=2, value=hotel['website'] or hotel['website_address'])
ws.cell(row=i, column=3, value='Да' if hotel['has_website'] else 'Нет')
ws.cell(row=i, column=4, value=f"{hotel['total_score']}/{hotel['max_score']}")
ws.cell(row=i, column=5, value=f"{hotel['score_percentage']:.1f}%")
# Цветовое кодирование процента
percentage = hotel['score_percentage'] or 0
if percentage >= 50:
fill_color = 'C6EFCE' # Зеленый
elif percentage >= 30:
fill_color = 'FFEB9C' # Желтый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=5).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
# Данные по критериям
col_idx = 6 # Начинаем с 6-й колонки
if hotel['criteria_results']:
criteria = hotel['criteria_results']
for criterion in criteria:
criterion_id = criterion.get('criterion_id')
# Статус
status = 'Да' if criterion.get('found', False) else 'Нет'
ws.cell(row=i, column=col_idx, value=status)
# URL и Комментарий - специальная обработка для критерия 6 (РКН)
if criterion_id == 6: # РКН Реестр
# URL на реестр РКН
rkn_number = hotel.get('rkn_registry_number', '')
if rkn_number:
url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}"
else:
url = ""
ws.cell(row=i, column=col_idx + 1, value=url)
# Комментарий: номер РКН + дата
rkn_date = hotel.get('rkn_registry_date', '')
if rkn_number and rkn_date:
comment = f"{rkn_number}\n{rkn_date}"
elif rkn_number:
comment = rkn_number
else:
comment = ""
ws.cell(row=i, column=col_idx + 2, value=comment)
else:
# Обычная обработка для остальных критериев
url = criterion.get('ai_agent', {}).get('url', '')
ws.cell(row=i, column=col_idx + 1, value=url)
# Используем details вместо quote для коротких комментариев
comment = criterion.get('ai_agent', {}).get('details', '')
if not comment:
# Если details нет, используем quote но обрезаем
comment = criterion.get('ai_agent', {}).get('quote', '')
if len(comment) > 100:
comment = comment[:100] + '...'
ws.cell(row=i, column=col_idx + 2, value=comment)
# Цветовое кодирование статуса
if criterion.get('found', False):
fill_color = 'C6EFCE' # Зеленый
else:
fill_color = 'FFC7CE' # Красный
ws.cell(row=i, column=col_idx).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
col_idx += 3
# Форматирование всех ячеек строки
for col in range(1, len(all_headers) + 1):
cell = ws.cell(row=i, column=col)
cell.font = normal_font
cell.alignment = Alignment(horizontal='center', vertical='top', wrap_text=True)
# Автоподбор ширины колонок
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value:
# Учитываем переносы строк
lines = str(cell.value).split('\n')
max_line_length = max(len(line) for line in lines) if lines else 0
max_length = max(max_length, max_line_length)
except:
pass
# Ограничиваем ширину
adjusted_width = min(max_length + 2, 50) # Максимум 50
adjusted_width = max(adjusted_width, 10) # Минимум 10
ws.column_dimensions[column_letter].width = adjusted_width
# Фильтры
ws.auto_filter.ref = f"A1:{openpyxl.utils.get_column_letter(len(all_headers))}{len(audit_data)+1}"
# Заморозка заголовков
ws.freeze_panes = "A2"
def main():
"""Основная функция"""
print("🏛️ СОЗДАНИЕ ОТЧЕТА ПО ОРЛОВСКОЙ ОБЛАСТИ (ГОРИЗОНТАЛЬНЫЙ ФОРМАТ)")
print("=" * 60)
# Получаем данные
print("📊 Загружаем данные из БД...")
audit_data, criteria_stats = get_orel_data()
print(f"✅ Загружено:")
print(f" 🏨 Отелей: {len(audit_data)}")
print(f" 🎯 Критериев: {len(criteria_stats)}")
# Создаем Excel файл
print("\n📝 Создаем Excel файл...")
workbook = Workbook()
# Лист дашборда
print("📊 Создаем дашборд...")
create_dashboard_sheet(workbook, audit_data, criteria_stats)
# Лист аудита
print("🏨 Создаем таблицу аудита (горизонтальный формат)...")
create_audit_sheet(workbook, audit_data)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"orel_horizontal_report_{timestamp}.xlsx"
workbook.save(filename)
print(f"\n✅ Отчет сохранен: {filename}")
print(f"📊 Листы:")
print(f" 📈 Дашборд Орёл - графики и статистика")
print(f" 🏨 Аудит отелей - горизонтальный формат (как в CSV)")
if __name__ == "__main__":
main()

64
create_tables.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Создание таблиц в базе данных"""
import psycopg2
from urllib.parse import unquote
# Параметры подключения
DB_HOST = "147.45.189.234"
DB_PORT = 5432
DB_NAME = "default_db"
DB_USER = "gen_user"
DB_PASSWORD = unquote("2~~9_%5EkVsU%3F2%5CS")
print("Подключаюсь к базе данных...")
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
cur = conn.cursor()
# Читаем SQL схему
with open('db_schema.sql', 'r', encoding='utf-8') as f:
schema_sql = f.read()
print("Создаю таблицы...")
cur.execute(schema_sql)
conn.commit()
# Проверяем созданные таблицы
cur.execute("""
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public' AND tablename LIKE 'hotel%'
ORDER BY tablename;
""")
tables = cur.fetchall()
print(f"\n✓ Успешно создано {len(tables)} таблиц:")
for (table,) in tables:
cur.execute(f"""
SELECT count(*) FROM {table};
""")
count = cur.fetchone()[0]
print(f" - {table} ({count} записей)")
cur.close()
conn.close()
print("\n✓ Схема базы данных готова к использованию!")
except Exception as e:
print(f"\n✗ Ошибка: {e}")
exit(1)

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Создание таблиц для настроек пользователей и моделей LLM
"""
import psycopg2
from psycopg2.extras import RealDictCursor
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': '2~~9_^kVsU?2\\S'
}
def create_tables():
"""Создание таблиц для настроек пользователей"""
try:
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
print("🔗 Подключение к БД установлено")
# Читаем SQL файл
with open('user_settings_schema.sql', 'r', encoding='utf-8') as f:
sql_content = f.read()
# Выполняем SQL
cur.execute(sql_content)
conn.commit()
print("✅ Таблицы созданы успешно!")
# Проверяем созданные таблицы
cur.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('user_settings', 'llm_models')
ORDER BY table_name;
""")
tables = cur.fetchall()
print(f"📊 Созданы таблицы: {[t['table_name'] for t in tables]}")
# Проверяем количество моделей
cur.execute("SELECT provider, COUNT(*) as count FROM llm_models GROUP BY provider ORDER BY provider;")
models = cur.fetchall()
print("\n📋 Модели по провайдерам:")
for model in models:
print(f" {model['provider']}: {model['count']} моделей")
cur.close()
conn.close()
print("\n🎉 Готово! Теперь можно использовать БД для настроек пользователей.")
except Exception as e:
print(f"❌ Ошибка: {e}")
if __name__ == "__main__":
create_tables()

11
direct_check.py Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python3
import subprocess
import sys
# Прямой вызов
print(subprocess.check_output(['python3', 'quick_check.py'], cwd='/root/engine/public_oversight/hotels').decode())

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
version: '3.8'
services:
hotel-audit-web:
build:
context: .
dockerfile: Dockerfile
container_name: hotel-audit-web
restart: unless-stopped
networks:
- dokploy-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.hotel-audit.rule=Host(`hotel.klientprav.tech`)"
- "traefik.http.routers.hotel-audit.entrypoints=websecure"
- "traefik.http.routers.hotel-audit.tls=true"
- "traefik.http.routers.hotel-audit.tls.certresolver=letsencrypt"
- "traefik.http.services.hotel-audit.loadbalancer.server.port=8888"
# HTTP to HTTPS redirect
- "traefik.http.routers.hotel-audit-http.rule=Host(`hotel.klientprav.tech`)"
- "traefik.http.routers.hotel-audit-http.entrypoints=web"
- "traefik.http.routers.hotel-audit-http.middlewares=redirect-to-https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
environment:
- PYTHONUNBUFFERED=1
networks:
dokploy-network:
external: true

161
embedding_service.py Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
FastAPI сервис для генерации эмбеддингов
Замена Ollama для n8n workflow
"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Union
import uvicorn
from sentence_transformers import SentenceTransformer
import logging
import time
# Настройка логирования
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Embedding Service",
description="Сервис для генерации эмбеддингов через Sentence Transformers",
version="1.0.0"
)
# Глобальная модель (загружается один раз при старте)
model = None
class EmbeddingRequest(BaseModel):
"""Запрос на генерацию эмбеддинга"""
text: Union[str, List[str]]
batch_size: int = 32
normalize: bool = True
class EmbeddingResponse(BaseModel):
"""Ответ с эмбеддингами"""
embeddings: List[List[float]]
model_name: str
processing_time: float
text_count: int
@app.on_event("startup")
async def load_model():
"""Загружаем модель при старте сервиса"""
global model
logger.info("🔄 Загружаем модель BGE-M3...")
start_time = time.time()
try:
model = SentenceTransformer('BAAI/bge-m3')
load_time = time.time() - start_time
logger.info(f"✅ Модель загружена за {load_time:.2f} сек")
logger.info(f"📊 Размерность: {model.get_sentence_embedding_dimension()}")
logger.info(f"📏 Max sequence: {model.max_seq_length}")
except Exception as e:
logger.error(f"❌ Ошибка загрузки модели: {e}")
raise
@app.get("/")
async def root():
"""Проверка работы сервиса"""
return {
"status": "running",
"model": "BAAI/bge-m3",
"dimension": model.get_sentence_embedding_dimension() if model else "loading...",
"max_sequence": model.max_seq_length if model else "loading..."
}
@app.get("/health")
async def health_check():
"""Health check для n8n"""
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
return {
"status": "healthy",
"model_loaded": True,
"model_name": "BAAI/bge-m3",
"dimension": model.get_sentence_embedding_dimension(),
"max_sequence": model.max_seq_length
}
@app.post("/embed", response_model=EmbeddingResponse)
async def generate_embeddings(request: EmbeddingRequest):
"""
Генерируем эмбеддинги для текста
Поддерживает:
- Одиночный текст: {"text": "Привет мир"}
- Массив текстов: {"text": ["Текст 1", "Текст 2"]}
- Батчинг для больших массивов
"""
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
start_time = time.time()
try:
# Подготавливаем текст
if isinstance(request.text, str):
texts = [request.text]
else:
texts = request.text
logger.info(f"🔄 Обрабатываем {len(texts)} текстов...")
# Генерируем эмбеддинги с батчингом
embeddings = model.encode(
texts,
batch_size=request.batch_size,
normalize_embeddings=request.normalize,
show_progress_bar=True
)
processing_time = time.time() - start_time
# Конвертируем numpy в list для JSON
embeddings_list = embeddings.tolist()
logger.info(f"✅ Обработано за {processing_time:.2f} сек")
logger.info(f"📊 Размерность эмбеддинга: {len(embeddings_list[0])}")
return EmbeddingResponse(
embeddings=embeddings_list,
model_name="BAAI/bge-m3",
processing_time=processing_time,
text_count=len(texts)
)
except Exception as e:
logger.error(f"❌ Ошибка генерации эмбеддингов: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/embed-single")
async def embed_single(text: str):
"""
Упрощённый эндпоинт для одного текста
Совместимость с n8n
"""
request = EmbeddingRequest(text=text)
response = await generate_embeddings(request)
# Возвращаем только первый эмбеддинг
return {
"embedding": response.embeddings[0],
"model": response.model_name,
"time": response.processing_time
}
if __name__ == "__main__":
uvicorn.run(
"embedding_service:app",
host="0.0.0.0",
port=8001,
reload=False,
workers=1 # Один воркер для экономии памяти
)

129
estimate_time.py Normal file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Оценка времени работы краулера
"""
from urllib.parse import unquote
import psycopg2
from datetime import datetime, timedelta
conn = psycopg2.connect(
host='147.45.189.234',
database='default_db',
user='gen_user',
password=unquote('2~~9_%5EkVsU%3F2%5CS')
)
cur = conn.cursor()
# Общая статистика
cur.execute('SELECT COUNT(*) FROM hotel_main WHERE website_address IS NOT NULL')
total_hotels_with_sites = cur.fetchone()[0]
cur.execute('SELECT COUNT(DISTINCT hotel_id) FROM hotel_website_processed')
processed_hotels = cur.fetchone()[0]
# Осталось обработать
remaining = total_hotels_with_sites - processed_hotels
# Статистика за последние 24 часа
cur.execute("""
SELECT COUNT(DISTINCT hotel_id)
FROM hotel_website_processed
WHERE processed_at > NOW() - INTERVAL '24 hours'
""")
hotels_per_day = cur.fetchone()[0]
# Статистика за последний час
cur.execute("""
SELECT COUNT(DISTINCT hotel_id)
FROM hotel_website_processed
WHERE processed_at > NOW() - INTERVAL '1 hour'
""")
hotels_per_hour = cur.fetchone()[0]
# Время первого и последнего краулинга
cur.execute("""
SELECT MIN(processed_at), MAX(processed_at)
FROM hotel_website_processed
""")
first_date, last_date = cur.fetchone()
# Расчёт скорости
if first_date and last_date:
elapsed_time = (last_date - first_date).total_seconds() / 3600 # в часах
if elapsed_time > 0:
avg_hotels_per_hour = processed_hotels / elapsed_time
else:
avg_hotels_per_hour = hotels_per_hour
else:
avg_hotels_per_hour = hotels_per_hour
# Оценки времени
if hotels_per_hour > 0:
hours_left_current = remaining / hotels_per_hour
days_left_current = hours_left_current / 24
hours_left_avg = remaining / avg_hotels_per_hour if avg_hotels_per_hour > 0 else 0
days_left_avg = hours_left_avg / 24
eta_current = datetime.now() + timedelta(hours=hours_left_current)
eta_avg = datetime.now() + timedelta(hours=hours_left_avg)
else:
hours_left_current = 0
days_left_current = 0
hours_left_avg = 0
days_left_avg = 0
eta_current = None
eta_avg = None
print("📊 ОЦЕНКА ВРЕМЕНИ РАБОТЫ КРАУЛЕРА")
print("=" * 60)
print(f"\n🏨 ВСЕГО:")
print(f" Отелей с сайтами: {total_hotels_with_sites:,}")
print(f" Обработано: {processed_hotels:,} ({processed_hotels/total_hotels_with_sites*100:.1f}%)")
print(f" Осталось: {remaining:,}")
print(f"\nСКОРОСТЬ:")
print(f" За последний час: {hotels_per_hour} отелей/час")
print(f" За последние 24ч: {hotels_per_day} отелей/день")
if avg_hotels_per_hour > 0:
print(f" Средняя с начала: {avg_hotels_per_hour:.1f} отелей/час")
if first_date:
print(f"\n📅 ПЕРИОД:")
print(f" Начало: {first_date.strftime('%Y-%m-%d %H:%M')}")
print(f" Сейчас: {last_date.strftime('%Y-%m-%d %H:%M')}")
print(f" Прошло: {elapsed_time:.1f} часов ({elapsed_time/24:.1f} дней)")
print(f"\n⏱️ ОЦЕНКА ВРЕМЕНИ (по текущей скорости {hotels_per_hour} отелей/час):")
if eta_current:
print(f" Осталось времени: {hours_left_current:.1f} часов ({days_left_current:.1f} дней)")
print(f" Завершение: {eta_current.strftime('%Y-%m-%d %H:%M')}")
else:
print(" Недостаточно данных")
if avg_hotels_per_hour > 0:
print(f"\n⏱️ ОЦЕНКА ВРЕМЕНИ (по средней скорости {avg_hotels_per_hour:.1f} отелей/час):")
print(f" Осталось времени: {hours_left_avg:.1f} часов ({days_left_avg:.1f} дней)")
if eta_avg:
print(f" Завершение: {eta_avg.strftime('%Y-%m-%d %H:%M')}")
# Средние страниц на отель
cur.execute("""
SELECT AVG(page_count)::numeric(10,1)
FROM (
SELECT hotel_id, COUNT(*) as page_count
FROM hotel_website_processed
GROUP BY hotel_id
) sub
""")
avg_pages = cur.fetchone()[0]
print(f"\n📄 СТАТИСТИКА СТРАНИЦ:")
print(f" Среднее страниц на отель: {avg_pages}")
cur.close()
conn.close()

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Экспорт отчета о доступности сайтов в Excel
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
from datetime import datetime
import pandas as pd
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
def export_website_status_report(region_name=None, output_file=None):
"""Экспорт отчета в Excel"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
# Формируем запрос
where_clause = ""
params = []
if region_name:
where_clause = "WHERE region_name ILIKE %s"
params = [f'%{region_name}%']
# Получаем данные
query = f'''
SELECT
region_name as "Регион",
full_name as "Название отеля",
website_address as "Адрес сайта",
CASE website_status
WHEN 'accessible' THEN '✅ Доступен'
WHEN 'no_website' THEN '❌ Отсутствует'
WHEN 'timeout' THEN '⏱️ Таймаут'
WHEN 'connection_refused' THEN '🚫 Соединение отклонено'
WHEN 'dns_error' THEN '🔍 DNS ошибка'
WHEN 'ssl_error' THEN '🔒 SSL ошибка'
WHEN 'http_error' THEN '⚠️ HTTP ошибка'
WHEN 'invalid_url' THEN '❓ Неверный URL'
ELSE 'Не проверено'
END as "Статус сайта",
CASE
WHEN website_status = 'accessible' THEN 'Да'
WHEN website_status = 'no_website' THEN 'Нет'
ELSE 'Есть, но недоступен'
END as "Наличие сайта",
phone as "Телефон",
email as "Email",
owner_full_name as "Владелец",
owner_inn as "ИНН"
FROM hotel_main
{where_clause}
ORDER BY region_name, website_status, full_name
'''
cur.execute(query, params)
data = cur.fetchall()
# Создаем DataFrame
df = pd.DataFrame(data)
# Получаем статистику
stats_query = f'''
SELECT
region_name as "Регион",
COUNT(*) as "Всего отелей",
COUNT(CASE WHEN website_address IS NOT NULL AND website_address != '' AND website_address != '-' THEN 1 END) as "С указанным сайтом",
COUNT(CASE WHEN website_status = 'accessible' THEN 1 END) as "Сайт доступен",
COUNT(CASE WHEN website_status IN ('timeout', 'connection_refused', 'dns_error', 'ssl_error', 'http_error', 'invalid_url') THEN 1 END) as "Сайт недоступен",
COUNT(CASE WHEN website_status = 'no_website' THEN 1 END) as "Сайт отсутствует",
ROUND(COUNT(CASE WHEN website_status = 'accessible' THEN 1 END) * 100.0 / NULLIF(COUNT(CASE WHEN website_address IS NOT NULL AND website_address != '' AND website_address != '-' THEN 1 END), 0), 2) as "% доступности"
FROM hotel_main
{where_clause}
GROUP BY region_name
ORDER BY region_name
'''
cur.execute(stats_query, params)
stats_data = cur.fetchall()
stats_df = pd.DataFrame(stats_data)
cur.close()
conn.close()
# Формируем имя файла
if not output_file:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
region_suffix = f"_{region_name.replace(' ', '_')}" if region_name else "се_регионы"
output_file = f"website_status_report{region_suffix}_{timestamp}.xlsx"
# Сохраняем в Excel
with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
# Лист со статистикой
stats_df.to_excel(writer, sheet_name='Статистика', index=False)
# Лист с детальными данными
df.to_excel(writer, sheet_name='Детальные данные', index=False)
# Лист с проблемными сайтами
problematic_df = df[df['Наличие сайта'] == 'Есть, но недоступен'].copy()
problematic_df.to_excel(writer, sheet_name='Недоступные сайты', index=False)
# Автоматическая ширина колонок
for sheet_name in writer.sheets:
worksheet = writer.sheets[sheet_name]
for column in worksheet.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
worksheet.column_dimensions[column_letter].width = adjusted_width
print(f"✅ Отчет сохранен: {output_file}")
print(f"📊 Всего записей: {len(df)}")
print(f"🔴 Недоступных сайтов: {len(problematic_df)}")
return output_file
if __name__ == "__main__":
import sys
region = sys.argv[1] if len(sys.argv) > 1 else None
output = sys.argv[2] if len(sys.argv) > 2 else None
if region:
print(f"📍 Генерация отчета для региона: {region}")
else:
print("📍 Генерация отчета для всех регионов")
export_website_status_report(region, output)

83
find_api.py Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Скрипт для поиска API endpoint'ов"""
import asyncio
import json
from playwright.async_api import async_playwright
async def find_api_endpoints():
api_calls = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# Перехватываем все запросы
async def log_request(response):
url = response.url
# Интересуют только API запросы
if '/api/' in url:
try:
status = response.status
method = response.request.method
data = {
'url': url,
'method': method,
'status': status,
}
# Пытаемся получить тело ответа для успешных запросов
if status == 200:
try:
body = await response.json()
data['response_sample'] = str(body)[:500] # Первые 500 символов
except:
pass
api_calls.append(data)
print(f"[{method}] {status} - {url}")
except Exception as e:
print(f"Error processing {url}: {e}")
page.on('response', log_request)
print("Загружаем страницу со списком отелей...")
await page.goto('https://tourism.fsa.gov.ru/ru/resorts/showcase/hotels',
wait_until='networkidle',
timeout=60000)
print("\nОжидаем загрузку данных...")
await page.wait_for_timeout(5000)
# Пробуем прокрутить страницу для загрузки дополнительных данных
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
await page.wait_for_timeout(2000)
await browser.close()
# Сохраняем результаты
with open('api_endpoints.json', 'w', encoding='utf-8') as f:
json.dump(api_calls, f, ensure_ascii=False, indent=2)
print(f"\n\nНайдено {len(api_calls)} API запросов")
print("Результаты сохранены в api_endpoints.json")
# Выводим уникальные endpoint'ы
unique_endpoints = set()
for call in api_calls:
# Убираем параметры запроса для группировки
url = call['url'].split('?')[0]
unique_endpoints.add((call['method'], url))
print("\n=== Уникальные endpoint'ы ===")
for method, url in sorted(unique_endpoints):
print(f"[{method}] {url}")
if __name__ == "__main__":
asyncio.run(find_api_endpoints())

216
generate_csv_debug.py Normal file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
CSV генератор для проверки сырых данных
"""
import psycopg2
import json
import csv
from urllib.parse import unquote
from datetime import datetime
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
cursor.execute("""
SELECT
ar.hotel_id,
hm.full_name,
hm.website_address,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
ar.score_percentage,
ar.criteria_results,
hm.created_at
FROM hotel_audit_results ar
JOIN hotel_main hm ON ar.hotel_id = hm.id
WHERE hm.region_name = 'Чукотский автономный округ'
ORDER BY hm.created_at DESC
""")
results = []
for row in cursor.fetchall():
# Конвертируем в словарь
result = {
'hotel_id': row[0],
'full_name': row[1],
'website_address': row[2],
'rkn_registry_status': row[3],
'rkn_registry_number': row[4],
'rkn_registry_date': row[5],
'score_percentage': row[6],
'criteria_results': row[7],
'created_at': row[8]
}
results.append(result)
cursor.close()
conn.close()
return results
except Exception as e:
print(f"❌ Ошибка получения данных: {e}")
return []
def create_csv_report(results):
"""Создать CSV отчёт"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_debug_{timestamp}.csv"
with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
# Заголовки
headers = [
'ID отеля', 'Название отеля', 'Сайт', 'Балл (%)', 'Дата аудита',
'РКН статус', 'РКН номер', 'РКН дата'
]
# Добавляем заголовки критериев
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
if isinstance(criteria_results, list):
for criterion_idx, criterion in enumerate(criteria_results):
# Пропускаем критерий 6 (РКН) - он будет отдельно
if criterion_idx == 5: # индекс 5 = критерий 6
continue
criterion_name = f"Критерий_{criterion_idx+1}"
headers.extend([
f"{criterion_name}_Статус",
f"{criterion_name}_URL",
f"{criterion_name}_Комментарий"
])
# Добавляем РКН колонки
headers.extend(['РКН_Реестр', 'РКН_Номерата', 'РКН_Ссылка'])
writer.writerow(headers)
# Данные
for result in results:
row = [
result['hotel_id'],
result['full_name'],
result['website_address'] or '-',
result['score_percentage'],
str(result['created_at'])[:10]
]
# РКН данные из hotel_main
rkn_status = result.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_number = result.get('rkn_registry_number', '')
rkn_date = result.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
row.extend([rkn_in_registry, rkn_info_text, rkn_url])
# Данные критериев
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
if isinstance(criteria_results, list):
for criterion_idx, criterion in enumerate(criteria_results):
# Пропускаем критерий 6 (РКН) - он будет отдельно
if criterion_idx == 5:
continue
# Статус
status = "ДА" if criterion.get('found') else "НЕТ"
# URL
url = '-'
if criterion.get('ai_agent', {}).get('url'):
url = criterion['ai_agent']['url']
# Комментарий
comment = "Не найдено"
if criterion.get('found'):
if criterion.get('ai_agent', {}).get('details'):
comment = criterion['ai_agent']['details']
elif criterion.get('ai_agent', {}).get('quote'):
comment = criterion['ai_agent']['quote']
elif criterion.get('regex', {}).get('extracted'):
comment = criterion['regex']['extracted']
row.extend([status, url, comment])
writer.writerow(row)
return filename
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ CSV ДЛЯ ДЕБАГА")
print("=" * 40)
try:
# Получаем данные
print("📡 Подключаюсь к БД...")
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных для отчёта")
return
print(f"✅ Получено результатов: {len(results)}")
# Выводим сырые данные первого отеля
if results:
print("\n🔍 СЫРЫЕ ДАННЫЕ ПЕРВОГО ОТЕЛЯ:")
print(f"ID: {results[0]['hotel_id']}")
print(f"Название: {results[0]['full_name']}")
print(f"Сайт: {results[0]['website_address']}")
print(f"РКН статус: {results[0]['rkn_registry_status']}")
print(f"РКН номер: {results[0]['rkn_registry_number']}")
print(f"РКН дата: {results[0]['rkn_registry_date']}")
print(f"Балл: {results[0]['score_percentage']}")
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
print(f"\n📊 КРИТЕРИИ ({len(criteria_results)} шт.):")
for i, criterion in enumerate(criteria_results):
if isinstance(criterion, dict):
print(f" {i+1}. {criterion.get('criterion_name', f'Критерий {i+1}')}: {criterion.get('found', False)}")
else:
print(f" {i+1}. {criterion} (тип: {type(criterion)})")
# Создаём CSV
filename = create_csv_report(results)
print(f"\n✅ CSV файл сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
except Exception as e:
print(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

223
generate_csv_fixed.py Normal file
View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
ИСПРАВЛЕННЫЙ CSV скрипт - работает напрямую с данными из БД
"""
import psycopg2
import json
import csv
from datetime import datetime
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
query = """
SELECT
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_date, audit_version
FROM hotel_audit_results
WHERE audit_version = 'v1.0_with_rkn'
ORDER BY region_name, hotel_name
"""
cur.execute(query)
results = cur.fetchall()
# Преобразуем в словари
columns = [desc[0] for desc in cur.description]
results = [dict(zip(columns, row)) for row in results]
cur.close()
conn.close()
return results
def get_hotel_rkn_info(hotel_ids):
"""Получить РКН информацию для отелей"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
placeholders = ','.join(['%s'] * len(hotel_ids))
query = f"""
SELECT id, rkn_registry_status, rkn_registry_number, rkn_registry_date
FROM hotel_main
WHERE id IN ({placeholders})
"""
cur.execute(query, hotel_ids)
results = cur.fetchall()
# Преобразуем в словари
columns = [desc[0] for desc in cur.description]
rkn_data = {row[0]: dict(zip(columns, row)) for row in results}
cur.close()
conn.close()
return rkn_data
def create_csv_report(results):
"""Создать CSV отчёт из данных БД"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_fixed_{timestamp}.csv"
with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
# Получаем РКН данные для всех отелей
hotel_ids = [str(result['hotel_id']) for result in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
# Заголовки
headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
# Добавляем РКН колонки
headers.extend(['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка'])
# Добавляем колонки для всех критериев
print(f"🔍 Отладка: results={len(results)}")
if results:
# Берём критерии из первого результата
first_result = results[0]
print(f"🔍 Отладка: first_result keys={list(first_result.keys())}")
criteria_results = first_result.get('criteria_results')
print(f"🔍 Отладка: criteria_results type={type(criteria_results)}")
print(f"🔍 Отладка: criteria_results bool={bool(criteria_results)}")
if criteria_results:
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
print(f"🔍 Отладка: criteria_results length={len(criteria_results)}")
print(f"🔍 Отладка: criteria_results keys={list(criteria_results.keys())[:5]}")
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criterion_name = criterion_data.get('name', f'Критерий {i}')
headers.extend([
f"{i}. {criterion_name}",
f"{i}. URL",
f"{i}. Комментарий"
])
print(f"🔍 Добавлен критерий {i}: {criterion_name}")
else:
print("🔍 criteria_results пустой или None")
# Добавляем заглушки для критериев
for i in range(1, 19):
headers.extend([
f"{i}. Критерий {i}",
f"{i}. URL",
f"{i}. Комментарий"
])
else:
print("🔍 results пустой")
writer.writerow(headers)
# Данные
for result in results:
row = [
result['hotel_name'],
result.get('website', 'НЕТ САЙТА'),
"Да" if result.get('has_website') else "Нет",
result['total_score'],
f"{result['score_percentage']:.1f}%"
]
# РКН данные
rkn_info = rkn_data.get(str(result['hotel_id']), {})
rkn_status = rkn_info.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_number = rkn_info.get('rkn_registry_number', '')
rkn_date = rkn_info.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number} {rkn_date}" if rkn_number or rkn_date else "-"
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
row.extend([rkn_in_registry, rkn_info_text, rkn_url])
# Данные по критериям - работаем напрямую со словарём
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
# Статус
status = "ДА" if criterion_data.get('found') else "НЕТ"
# URL
url = '-'
if criterion_data.get('approval_urls'):
url = criterion_data['approval_urls'][0]
# Комментарий
comment = "Не найдено"
if criterion_data.get('found'):
if criterion_data.get('quote'):
comment = criterion_data['quote']
elif criterion_data.get('approval_quotes'):
first_quote = criterion_data['approval_quotes'][0]
if isinstance(first_quote, dict):
comment = first_quote.get('quote', 'Найдено')
else:
comment = str(first_quote)
elif criterion_data.get('keywords_found'):
comment = f"Ключевые слова: {', '.join(criterion_data['keywords_found'])}"
else:
comment = "Найдено"
comment = comment[:100] + "..." if len(comment) > 100 else comment
row.extend([status, url, comment])
writer.writerow(row)
return filename
def main():
"""Основная функция"""
print("🚀 ИСПРАВЛЕННЫЙ CSV СКРИПТ")
print("=" * 40)
# Получаем данные из БД
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных аудита в БД")
return
print(f"📊 Найдено результатов аудита: {len(results)}")
# Создаём CSV отчёт
filename = create_csv_report(results)
print(f"✅ CSV отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
print(f"\n🔍 Для просмотра: head -2 {filename}")
if __name__ == "__main__":
main()

281
generate_excel_correct.py Normal file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
ПРАВИЛЬНАЯ ВЕРСИЯ - Только базовые колонки + РКН между критерием 5 и 7
"""
import psycopg2
import json
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.utils import get_column_letter
from datetime import datetime
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
cursor.execute("""
SELECT
ar.hotel_id,
hm.full_name,
hm.website_address,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
ar.score_percentage,
ar.criteria_results,
hm.created_at
FROM hotel_audit_results ar
JOIN hotel_main hm ON ar.hotel_id = hm.id
WHERE hm.region_name = 'Чукотский автономный округ'
AND ar.audit_version = 'v1.0_with_rkn'
ORDER BY hm.created_at DESC
""")
results = []
for row in cursor.fetchall():
result = {
'hotel_id': row[0],
'full_name': row[1],
'website_address': row[2],
'rkn_registry_status': row[3],
'rkn_registry_number': row[4],
'rkn_registry_date': row[5],
'score_percentage': row[6],
'criteria_results': row[7],
'created_at': row[8]
}
results.append(result)
cursor.close()
conn.close()
return results
except Exception as e:
print(f"❌ Ошибка получения данных: {e}")
return []
def create_excel_report(results):
"""Создать Excel отчёт"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит отелей"
# Стили
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Базовые заголовки
base_headers = [
'ID отеля', 'Название отеля', 'Сайт', 'Балл (%)', 'Дата аудита'
]
# Добавляем базовые заголовки
for i, header in enumerate(base_headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(i)].width = 20
col = len(base_headers) + 1
# Заголовки критериев 1-5
for i in range(1, 6):
criterion_name = f"{i}. Критерий {i}"
headers = [f"{criterion_name} Статус", f"{criterion_name} URL", f"{criterion_name} Комментарий"]
for header in headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
# РКН колонки после критерия 5
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
# Заголовки критериев 7-18
for i in range(7, 19):
criterion_name = f"{i}. Критерий {i}"
headers = [f"{criterion_name} Статус", f"{criterion_name} URL", f"{criterion_name} Комментарий"]
for header in headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
print(f"✅ Заголовки созданы, всего колонок: {col-1}")
# Добавляем данные
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
ws.cell(row=row_idx, column=col, value=result['hotel_id']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['full_name']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['website_address'] or '-').border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['score_percentage']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=str(result['created_at'])[:10]).border = border
col += 1
# Данные критериев 1-5
criteria_results = result['criteria_results']
if isinstance(criteria_results, dict):
for i in range(1, 6):
criterion_key = f'criterion_{i:02d}'
criterion_data = criteria_results.get(criterion_key, {})
# Статус
verdict = criterion_data.get('verdict', 'НЕТ')
status_cell = ws.cell(row=row_idx, column=col, value=verdict)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if verdict == 'ДА':
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# URL
urls = criterion_data.get('approval_urls', [])
url = urls[0] if urls else '-'
ws.cell(row=row_idx, column=col, value=url).border = border
col += 1
# Комментарий
quote = criterion_data.get('quote', '') or ''
comment = quote[:200] + '...' if len(quote) > 200 else quote if quote else "Не найдено"
ws.cell(row=row_idx, column=col, value=comment).border = border
col += 1
# РКН данные из hotel_main
rkn_status = result.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = found_fill
else:
rkn_status_cell.fill = not_found_fill
col += 1
rkn_number = result.get('rkn_registry_number', '')
rkn_date = result.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Данные критериев 7-18
if isinstance(criteria_results, dict):
for i in range(7, 19):
criterion_key = f'criterion_{i:02d}'
criterion_data = criteria_results.get(criterion_key, {})
# Статус
verdict = criterion_data.get('verdict', 'НЕТ')
status_cell = ws.cell(row=row_idx, column=col, value=verdict)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if verdict == 'ДА':
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# URL
urls = criterion_data.get('approval_urls', [])
url = urls[0] if urls else '-'
ws.cell(row=row_idx, column=col, value=url).border = border
col += 1
# Комментарий
quote = criterion_data.get('quote', '') or ''
comment = quote[:200] + '...' if len(quote) > 200 else quote if quote else "Не найдено"
ws.cell(row=row_idx, column=col, value=comment).border = border
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
print(f"✅ Данные добавлены, всего строк: {len(results)}")
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_v1.0_with_rkn_{timestamp}.xlsx"
wb.save(filename)
return filename
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ ПРАВИЛЬНОГО EXCEL")
print("=" * 40)
try:
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных для отчёта")
return
print(f"✅ Получено результатов: {len(results)}")
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
except Exception as e:
print(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

425
generate_excel_final.py Normal file
View File

@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""
ФИНАЛЬНАЯ РАБОЧАЯ ВЕРСИЯ - Excel из БД
"""
import psycopg2
import json
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.chart import BarChart, PieChart, Reference
from openpyxl.utils import get_column_letter
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
query = """
SELECT
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_date, audit_version
FROM hotel_audit_results
WHERE audit_version = 'v1.0_with_rkn'
ORDER BY region_name, hotel_name
"""
cur.execute(query)
results = cur.fetchall()
# Преобразуем в словари
columns = [desc[0] for desc in cur.description]
results = [dict(zip(columns, row)) for row in results]
cur.close()
conn.close()
return results
def get_hotel_rkn_info(hotel_ids):
"""Получить РКН информацию для отелей"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
placeholders = ','.join(['%s'] * len(hotel_ids))
query = f"""
SELECT id, rkn_registry_status, rkn_registry_number, rkn_registry_date
FROM hotel_main
WHERE id IN ({placeholders})
"""
cur.execute(query, hotel_ids)
results = cur.fetchall()
# Преобразуем в словари
columns = [desc[0] for desc in cur.description]
rkn_data = {row[0]: dict(zip(columns, row)) for row in results}
cur.close()
conn.close()
return rkn_data
def create_excel_report(results):
"""Создать Excel отчёт из данных БД"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит"
# Стили
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=10)
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# ЗАГОЛОВКИ (строка 1)
col = 1
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
for header in base_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
col += 1
# Заголовки критериев (включая РКН в правильном месте)
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Преобразуем словарь в список для удобства
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН заголовки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
if 'Номер' in header:
ws.column_dimensions[get_column_letter(col)].width = 30
elif 'Ссылка' in header:
ws.column_dimensions[get_column_letter(col)].width = 50
else:
ws.column_dimensions[get_column_letter(col)].width = 20
col += 1
criterion_name = f"{criterion['criterion_id']}. {criterion['criterion_name']}"
# Колонка 1: Статус (ДА/НЕТ)
cell = ws.cell(row=1, column=col, value=criterion_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 35
col += 1
# Колонка 2: URL
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Апрув URL")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 40
col += 1
# Колонка 3: Цитата/Детали
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Комментарий")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 50
col += 1
# Высота строки заголовков
ws.row_dimensions[1].height = 40
# Получаем РКН данные для всех отелей
hotel_ids = [str(result['hotel_id']) for result in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
# ДАННЫЕ (строки 2+)
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
cell = ws.cell(row=row_idx, column=col, value=result['hotel_name'])
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
cell = ws.cell(row=row_idx, column=col, value=result.get('website', 'НЕТ САЙТА'))
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
has_website = "Да" if result.get('has_website') else "Нет"
cell = ws.cell(row=row_idx, column=col, value=has_website)
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
cell = ws.cell(row=row_idx, column=col, value=result['total_score'])
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
perc_cell = ws.cell(row=row_idx, column=col, value=f"{result['score_percentage']:.1f}%")
perc_cell.border = border
perc_cell.alignment = Alignment(horizontal='center', vertical='center')
if result['score_percentage'] >= 70:
perc_cell.fill = found_fill
elif result['score_percentage'] < 50:
perc_cell.fill = not_found_fill
col += 1
# Данные по критериям
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Преобразуем словарь в список для удобства
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
rkn_info = rkn_data.get(str(result['hotel_id']), {})
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН колонки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_status = rkn_info.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = not_found_fill # Красный - плохо если в реестре
else:
rkn_status_cell.fill = found_fill # Зелёный - хорошо если НЕ в реестре
col += 1
rkn_number = rkn_info.get('rkn_registry_number', '')
rkn_date = rkn_info.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 1: Статус (ДА/НЕТ)
status = "ДА" if criterion['found'] else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion['found']:
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# Колонка 2: URL
url = criterion['approval_urls'][0] if criterion['approval_urls'] else '-'
cell = ws.cell(row=row_idx, column=col, value=url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 3: Комментарий/Цитата
if criterion['found']:
comment = ""
# Приоритет: цитата → approval_quotes → keywords_found → "Найдено"
if criterion['quote']:
comment = criterion['quote']
elif criterion['approval_quotes']:
first_quote = criterion['approval_quotes'][0]
if isinstance(first_quote, dict):
comment = first_quote.get('quote', 'Найдено')
else:
comment = str(first_quote)
elif criterion['keywords_found']:
comment = f"Ключевые слова: {', '.join(criterion['keywords_found'])}"
else:
comment = "Найдено"
# Ограничиваем длину
comment = comment[:200] + "..." if len(comment) > 200 else comment
else:
comment = "Не найдено"
cell = ws.cell(row=row_idx, column=col, value=comment)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
# Создаём дашборд
create_dashboard(wb, results)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_final_{timestamp}.xlsx"
wb.save(filename)
return filename
def create_dashboard(wb, results):
"""Создать дашборд с графиками и статистикой"""
# Создаём новый лист для дашборда
ws = wb.create_sheet("📊 Дашборд", 0) # Вставляем первым
# Стили
title_font = Font(size=16, bold=True, color="366092")
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
value_font = Font(size=14, bold=True)
green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# Заголовок
ws['A1'] = '📊 ДАШБОРД АУДИТА ОТЕЛЕЙ'
ws['A1'].font = title_font
ws.merge_cells('A1:F1')
# Общая статистика
row = 3
ws[f'A{row}'] = 'ОБЩАЯ СТАТИСТИКА'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
total_hotels = len(results)
hotels_with_website = sum(1 for r in results if r.get('has_website'))
hotels_without_website = total_hotels - hotels_with_website
# Считаем РКН
hotel_ids = [str(r['hotel_id']) for r in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
hotels_in_rkn = sum(1 for r in results
if rkn_data.get(str(r['hotel_id']), {}).get('rkn_registry_status', '').lower() == 'found')
avg_score = sum(r['score_percentage'] for r in results) / total_hotels if total_hotels > 0 else 0
stats = [
('Всего отелей:', total_hotels, None),
('С сайтами:', hotels_with_website, green_fill),
('Без сайтов:', hotels_without_website, red_fill),
('В реестре РКН:', hotels_in_rkn, red_fill if hotels_in_rkn > 0 else green_fill),
('Средний балл:', f"{avg_score:.1f}%", yellow_fill if avg_score < 50 else green_fill),
]
for label, value, fill in stats:
ws[f'A{row}'] = label
ws[f'B{row}'] = value
ws[f'B{row}'].font = value_font
if fill:
ws[f'B{row}'].fill = fill
ws[f'B{row}'].alignment = Alignment(horizontal='center')
row += 1
# Настройка ширины колонок
ws.column_dimensions['A'].width = 35
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
print(" 📊 Дашборд создан")
def main():
"""Основная функция"""
print("🚀 ФИНАЛЬНАЯ РАБОЧАЯ ВЕРСИЯ - Excel из БД")
print("=" * 50)
# Получаем данные из БД
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных аудита в БД")
return
print(f"📊 Найдено результатов аудита: {len(results)}")
# Создаём Excel отчёт
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
ФИНАЛЬНЫЙ Excel генератор с правильной обработкой критериев
"""
import psycopg2
import json
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.utils import get_column_letter
from datetime import datetime
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
cursor.execute("""
SELECT
ar.hotel_id,
hm.full_name,
hm.website_address,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
ar.score_percentage,
ar.criteria_results,
hm.created_at
FROM hotel_audit_results ar
JOIN hotel_main hm ON ar.hotel_id = hm.id
WHERE hm.region_name = 'Чукотский автономный округ'
ORDER BY hm.created_at DESC
""")
results = []
for row in cursor.fetchall():
result = {
'hotel_id': row[0],
'full_name': row[1],
'website_address': row[2],
'rkn_registry_status': row[3],
'rkn_registry_number': row[4],
'rkn_registry_date': row[5],
'score_percentage': row[6],
'criteria_results': row[7],
'created_at': row[8]
}
results.append(result)
cursor.close()
conn.close()
return results
except Exception as e:
print(f"❌ Ошибка получения данных: {e}")
return []
def create_excel_report(results):
"""Создать Excel отчёт"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит отелей"
# Стили
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Базовые заголовки
base_headers = [
'ID отеля', 'Название отеля', 'Сайт', 'Балл (%)', 'Дата аудита'
]
# Добавляем базовые заголовки
for i, header in enumerate(base_headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(i)].width = 20
col = len(base_headers) + 1
# Заголовки критериев (БЕЗ критерия 6 - РКН)
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, dict):
# Сортируем критерии по ключам
sorted_criteria = sorted(criteria_results.items())
for criterion_key, criterion_data in sorted_criteria:
criterion_num = criterion_key.split('_')[1] # извлекаем номер
criterion_id = int(criterion_num)
# Пропускаем критерий 6 (РКН) - он будет отдельно
if criterion_id == 6:
continue
criterion_name = f"{criterion_id}. {criterion_data.get('name', f'Критерий {criterion_id}')}"
# 3 колонки на критерий: Статус, URL, Комментарий
headers = [f"{criterion_name} Статус", f"{criterion_name} URL", f"{criterion_name} Комментарий"]
for header in headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
# Добавляем РКН колонки после критерия 5
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
print(f"✅ Заголовки созданы, всего колонок: {col-1}")
# Добавляем данные
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
ws.cell(row=row_idx, column=col, value=result['hotel_id']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['full_name']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['website_address'] or '-').border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['score_percentage']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=str(result['created_at'])[:10]).border = border
col += 1
# Данные критериев
criteria_results = result['criteria_results']
if isinstance(criteria_results, dict):
# Сортируем критерии по ключам
sorted_criteria = sorted(criteria_results.items())
for criterion_key, criterion_data in sorted_criteria:
criterion_num = criterion_key.split('_')[1]
criterion_id = int(criterion_num)
# Пропускаем критерий 6 (РКН) - он будет отдельно
if criterion_id == 6:
continue
# Статус
verdict = criterion_data.get('verdict', 'НЕТ')
status_cell = ws.cell(row=row_idx, column=col, value=verdict)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if verdict == 'ДА':
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# URL
urls = criterion_data.get('approval_urls', [])
url = urls[0] if urls else '-'
ws.cell(row=row_idx, column=col, value=url).border = border
col += 1
# Комментарий
quote = criterion_data.get('quote', '') or ''
comment = quote[:200] + '...' if len(quote) > 200 else quote if quote else "Не найдено"
ws.cell(row=row_idx, column=col, value=comment).border = border
col += 1
# РКН данные из hotel_main
rkn_status = result.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = found_fill
else:
rkn_status_cell.fill = not_found_fill
col += 1
rkn_number = result.get('rkn_registry_number', '')
rkn_date = result.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
print(f"✅ Данные добавлены, всего строк: {len(results)}")
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_final_{timestamp}.xlsx"
wb.save(filename)
return filename
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ ФИНАЛЬНОГО EXCEL")
print("=" * 40)
try:
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных для отчёта")
return
print(f"✅ Получено результатов: {len(results)}")
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
except Exception as e:
print(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
ФИНАЛЬНАЯ ВЕРСИЯ - Генерация Excel из БД БЕЗ ДУБЛИРОВАНИЯ РКН
"""
import psycopg2
import json
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.utils import get_column_letter
from openpyxl.chart import BarChart, PieChart, Reference
from datetime import datetime
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
cursor.execute("""
SELECT
ar.hotel_id,
hm.full_name,
hm.website_address,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
ar.score_percentage,
ar.criteria_results,
hm.created_at
FROM hotel_audit_results ar
JOIN hotel_main hm ON ar.hotel_id = hm.id
WHERE hm.region_name = 'Чукотский автономный округ'
ORDER BY hm.created_at DESC
""")
results = []
for row in cursor.fetchall():
# Конвертируем в словарь
result = {
'hotel_id': row[0],
'full_name': row[1],
'website_address': row[2],
'rkn_registry_status': row[3],
'rkn_registry_number': row[4],
'rkn_registry_date': row[5],
'score_percentage': row[6],
'criteria_results': row[7],
'created_at': row[8]
}
results.append(result)
cursor.close()
conn.close()
return results
except Exception as e:
print(f"❌ Ошибка получения данных: {e}")
return []
def create_excel_report(results):
"""Создать Excel отчёт"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит отелей"
# Стили
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Базовые заголовки
base_headers = [
'ID отеля', 'Название отеля', 'Сайт', 'Балл (%)', 'Дата аудита'
]
# Добавляем базовые заголовки
for i, header in enumerate(base_headers, 1):
cell = ws.cell(row=1, column=i, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(i)].width = 20
col = len(base_headers) + 1
# Заголовки критериев (БЕЗ РКН - он будет отдельно)
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
if isinstance(criteria_results, list):
for criterion_idx, criterion in enumerate(criteria_results):
# Пропускаем критерий 6 (РКН) - он будет отдельно
if criterion_idx == 5: # индекс 5 = критерий 6
continue
criterion_name = f"{criterion.get('criterion_id', criterion_idx+1)}. {criterion.get('criterion_name', f'Критерий {criterion_idx+1}')}"
# 3 колонки на критерий: Статус, URL, Комментарий
headers = [f"{criterion_name} Статус", f"{criterion_name} URL", f"{criterion_name} Комментарий"]
for header in headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
# Добавляем РКН колонки после критерия 5
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
print(f"✅ Заголовки созданы, всего колонок: {col-1}")
# Добавляем данные
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
ws.cell(row=row_idx, column=col, value=result['hotel_id']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['full_name']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['website_address'] or '-').border = border
col += 1
ws.cell(row=row_idx, column=col, value=result['score_percentage']).border = border
col += 1
ws.cell(row=row_idx, column=col, value=str(result['created_at'])[:10]).border = border
col += 1
# Данные критериев
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
if isinstance(criteria_results, list):
for criterion_idx, criterion in enumerate(criteria_results):
# Пропускаем критерий 6 (РКН) - он будет отдельно
if criterion_idx == 5:
continue
# Статус
status = "ДА" if criterion.get('found') else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion.get('found'):
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# URL
url = '-'
if criterion.get('ai_agent', {}).get('url'):
url = criterion['ai_agent']['url']
ws.cell(row=row_idx, column=col, value=url).border = border
col += 1
# Комментарий
comment = "Не найдено"
if criterion.get('found'):
if criterion.get('ai_agent', {}).get('details'):
comment = criterion['ai_agent']['details']
elif criterion.get('ai_agent', {}).get('quote'):
comment = criterion['ai_agent']['quote']
elif criterion.get('regex', {}).get('extracted'):
comment = criterion['regex']['extracted']
ws.cell(row=row_idx, column=col, value=comment).border = border
col += 1
# РКН данные из hotel_main
rkn_status = result.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = found_fill # Зелёный - хорошо если в реестре
else:
rkn_status_cell.fill = not_found_fill # Красный - плохо если НЕ в реестре
col += 1
rkn_number = result.get('rkn_registry_number', '')
rkn_date = result.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
print(f"✅ Данные добавлены, всего строк: {len(results)}")
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_fixed_{timestamp}.xlsx"
wb.save(filename)
return filename
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ EXCEL ИЗ БД - ФИНАЛЬНАЯ ВЕРСИЯ БЕЗ ДУБЛИРОВАНИЯ")
print("=" * 60)
try:
# Получаем данные
print("📡 Подключаюсь к БД...")
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных для отчёта")
return
print(f"✅ Получено результатов: {len(results)}")
# Создаём отчёт
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
except Exception as e:
print(f"❌ Ошибка: {e}")
if __name__ == "__main__":
main()

425
generate_excel_fixed.py Normal file
View File

@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""
Быстрая генерация Excel отчёта из БД без вебхуков - ИСПРАВЛЕННАЯ ВЕРСИЯ
"""
import psycopg2
import json
from psycopg2.extras import RealDictCursor
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.chart import BarChart, PieChart, Reference
from openpyxl.utils import get_column_letter
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db(region=None):
"""Получить результаты аудита из БД"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
query = """
SELECT
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_date, audit_version
FROM hotel_audit_results
WHERE audit_version = 'v1.0_with_rkn'
"""
if region:
query += f" AND region_name ILIKE '%{region}%'"
query += " ORDER BY region_name, hotel_name"
cur.execute(query)
results = cur.fetchall()
cur.close()
conn.close()
return results
def get_hotel_rkn_info(hotel_ids):
"""Получить РКН информацию для отелей"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
placeholders = ','.join(['%s'] * len(hotel_ids))
query = f"""
SELECT id, rkn_registry_status, rkn_registry_number, rkn_registry_date
FROM hotel_main
WHERE id IN ({placeholders})
"""
cur.execute(query, hotel_ids)
rkn_data = {row['id']: row for row in cur.fetchall()}
cur.close()
conn.close()
return rkn_data
def process_criteria_results(criteria_results):
"""Обработать criteria_results независимо от формата (dict или list)"""
criteria_list = []
if isinstance(criteria_results, dict):
# Если это словарь с ключами criterion_01, criterion_02, etc.
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
elif isinstance(criteria_results, list):
# Если это уже список
for i, criterion_data in enumerate(criteria_results):
criteria_list.append({
'criterion_id': i + 1,
'criterion_name': criterion_data.get('criterion_name', f'Критерий {i+1}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
return criteria_list
def create_excel_report(results, region_name="Все регионы"):
"""Создать Excel отчёт из данных БД"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит"
# Стили
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=10)
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# ЗАГОЛОВКИ (строка 1)
col = 1
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
for header in base_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
col += 1
# Заголовки критериев (включая РКН в правильном месте)
print(f"🔍 Отладка: results={len(results) if results else 0}")
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
print(f"🔍 Отладка: criteria_results type={type(criteria_results)}")
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
criteria_list = process_criteria_results(criteria_results)
print(f"🔍 Отладка: criteria_list length={len(criteria_list)}")
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН заголовки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
if 'Номер' in header:
ws.column_dimensions[get_column_letter(col)].width = 30
elif 'Ссылка' in header:
ws.column_dimensions[get_column_letter(col)].width = 50
else:
ws.column_dimensions[get_column_letter(col)].width = 20
col += 1
criterion_name = f"{criterion['criterion_id']}. {criterion['criterion_name']}"
# Колонка 1: Статус (ДА/НЕТ)
cell = ws.cell(row=1, column=col, value=criterion_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 35
col += 1
# Колонка 2: URL
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Апрув URL")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 40
col += 1
# Колонка 3: Цитата/Детали
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Комментарий")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 50
col += 1
# Высота строки заголовков
ws.row_dimensions[1].height = 40
# Получаем РКН данные для всех отелей
hotel_ids = [str(result['hotel_id']) for result in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
# ДАННЫЕ (строки 2+)
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
cell = ws.cell(row=row_idx, column=col, value=result['hotel_name'])
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
cell = ws.cell(row=row_idx, column=col, value=result.get('website', 'НЕТ САЙТА'))
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
has_website = "Да" if result.get('has_website') else "Нет"
cell = ws.cell(row=row_idx, column=col, value=has_website)
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
cell = ws.cell(row=row_idx, column=col, value=result['total_score'])
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
perc_cell = ws.cell(row=row_idx, column=col, value=f"{result['score_percentage']:.1f}%")
perc_cell.border = border
perc_cell.alignment = Alignment(horizontal='center', vertical='center')
if result['score_percentage'] >= 70:
perc_cell.fill = found_fill
elif result['score_percentage'] < 50:
perc_cell.fill = not_found_fill
col += 1
# Данные по критериям
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
criteria_list = process_criteria_results(criteria_results)
rkn_info = rkn_data.get(str(result['hotel_id']), {})
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН колонки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_status = rkn_info.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = not_found_fill # Красный - плохо если в реестре
else:
rkn_status_cell.fill = found_fill # Зелёный - хорошо если НЕ в реестре
col += 1
rkn_number = rkn_info.get('rkn_registry_number', '')
rkn_date = rkn_info.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 1: Статус (ДА/НЕТ)
status = "ДА" if criterion['found'] else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion['found']:
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# Колонка 2: URL
url = '-'
if 'approval_urls' in criterion and criterion['approval_urls']:
url = criterion['approval_urls'][0]
elif 'ai_agent' in criterion and criterion['ai_agent'].get('url'):
url = criterion['ai_agent']['url']
cell = ws.cell(row=row_idx, column=col, value=url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 3: Комментарий/Цитата
comment = "Не найдено"
if criterion['found']:
# Приоритет: AI детали → AI цитата → Regex извлечение → "Найдено"
if 'ai_agent' in criterion and criterion['ai_agent'].get('found'):
comment = criterion['ai_agent'].get('details') or criterion['ai_agent'].get('quote') or "Найдено"
elif 'regex' in criterion and criterion['regex'].get('found') and criterion['regex'].get('extracted'):
comment = f"[Regex] {criterion['regex']['extracted']}"
elif 'quote' in criterion and criterion['quote']:
comment = criterion['quote']
else:
comment = "Найдено"
comment = comment[:200] + "..." if len(comment) > 200 else comment
cell = ws.cell(row=row_idx, column=col, value=comment)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
# Создаём дашборд
create_dashboard(wb, results)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_{region_name.lower().replace(' ', '_')}_{timestamp}.xlsx"
wb.save(filename)
return filename
def create_dashboard(wb, results):
"""Создать дашборд с графиками и статистикой"""
# Создаём новый лист для дашборда
ws = wb.create_sheet("📊 Дашборд", 0) # Вставляем первым
# Стили
title_font = Font(size=16, bold=True, color="366092")
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
value_font = Font(size=14, bold=True)
green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# Заголовок
ws['A1'] = '📊 ДАШБОРД АУДИТА ОТЕЛЕЙ'
ws['A1'].font = title_font
ws.merge_cells('A1:F1')
# Общая статистика
row = 3
ws[f'A{row}'] = 'ОБЩАЯ СТАТИСТИКА'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
total_hotels = len(results)
hotels_with_website = sum(1 for r in results if r.get('has_website'))
hotels_without_website = total_hotels - hotels_with_website
# Считаем РКН
hotel_ids = [str(r['hotel_id']) for r in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
hotels_in_rkn = sum(1 for r in results
if rkn_data.get(str(r['hotel_id']), {}).get('rkn_registry_status', '').lower() == 'found')
avg_score = sum(r['score_percentage'] for r in results) / total_hotels if total_hotels > 0 else 0
stats = [
('Всего отелей:', total_hotels, None),
('С сайтами:', hotels_with_website, green_fill),
('Без сайтов:', hotels_without_website, red_fill),
('В реестре РКН:', hotels_in_rkn, red_fill if hotels_in_rkn > 0 else green_fill),
('Средний балл:', f"{avg_score:.1f}%", yellow_fill if avg_score < 50 else green_fill),
]
for label, value, fill in stats:
ws[f'A{row}'] = label
ws[f'B{row}'] = value
ws[f'B{row}'].font = value_font
if fill:
ws[f'B{row}'].fill = fill
ws[f'B{row}'].alignment = Alignment(horizontal='center')
row += 1
# Настройка ширины колонок
ws.column_dimensions['A'].width = 35
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
print(" 📊 Дашборд создан")
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ EXCEL ИЗ БД - ИСПРАВЛЕННАЯ ВЕРСИЯ")
print("=" * 60)
# Получаем данные из БД
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных аудита в БД")
return
print(f"📊 Найдено результатов аудита: {len(results)}")
# Создаём Excel отчёт
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
if __name__ == "__main__":
main()

408
generate_excel_from_db.py Normal file
View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""
Быстрая генерация Excel отчёта из БД без вебхуков
"""
import psycopg2
import json
from psycopg2.extras import RealDictCursor
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.chart import BarChart, PieChart, Reference
from openpyxl.utils import get_column_letter
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db(region=None):
"""Получить результаты аудита из БД"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
query = """
SELECT
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_date, audit_version
FROM hotel_audit_results
WHERE audit_version = 'v1.0_with_rkn'
"""
if region:
query += f" AND region_name ILIKE '%{region}%'"
query += " ORDER BY region_name, hotel_name"
cur.execute(query)
results = cur.fetchall()
cur.close()
conn.close()
return results
def get_hotel_rkn_info(hotel_ids):
"""Получить РКН информацию для отелей"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
placeholders = ','.join(['%s'] * len(hotel_ids))
query = f"""
SELECT id, rkn_registry_status, rkn_registry_number, rkn_registry_date
FROM hotel_main
WHERE id IN ({placeholders})
"""
cur.execute(query, hotel_ids)
rkn_data = {row['id']: row for row in cur.fetchall()}
cur.close()
conn.close()
return rkn_data
def create_excel_report(results, region_name="Все регионы"):
"""Создать Excel отчёт из данных БД"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит"
# Стили
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=10)
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# ЗАГОЛОВКИ (строка 1)
col = 1
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
for header in base_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
col += 1
# Заголовки критериев (включая РКН в правильном месте)
print(f"🔍 Отладка: results={len(results) if results else 0}")
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
print(f"🔍 Отладка: criteria_results type={type(criteria_results)}")
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Преобразуем словарь в список для удобства
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
print(f"🔍 Отладка: criteria_list length={len(criteria_list)}")
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН заголовки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
if 'Номер' in header:
ws.column_dimensions[get_column_letter(col)].width = 30
elif 'Ссылка' in header:
ws.column_dimensions[get_column_letter(col)].width = 50
else:
ws.column_dimensions[get_column_letter(col)].width = 20
col += 1
criterion_name = f"{criterion['criterion_id']}. {criterion['criterion_name']}"
# Колонка 1: Статус (ДА/НЕТ)
cell = ws.cell(row=1, column=col, value=criterion_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 35
col += 1
# Колонка 2: URL
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Апрув URL")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 40
col += 1
# Колонка 3: Цитата/Детали
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Комментарий")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 50
col += 1
# Высота строки заголовков
ws.row_dimensions[1].height = 40
# Получаем РКН данные для всех отелей
hotel_ids = [str(result['hotel_id']) for result in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
# ДАННЫЕ (строки 2+)
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
cell = ws.cell(row=row_idx, column=col, value=result['hotel_name'])
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
cell = ws.cell(row=row_idx, column=col, value=result.get('website', 'НЕТ САЙТА'))
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
has_website = "Да" if result.get('has_website') else "Нет"
cell = ws.cell(row=row_idx, column=col, value=has_website)
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
cell = ws.cell(row=row_idx, column=col, value=result['total_score'])
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
perc_cell = ws.cell(row=row_idx, column=col, value=f"{result['score_percentage']:.1f}%")
perc_cell.border = border
perc_cell.alignment = Alignment(horizontal='center', vertical='center')
if result['score_percentage'] >= 70:
perc_cell.fill = found_fill
elif result['score_percentage'] < 50:
perc_cell.fill = not_found_fill
col += 1
# Данные по критериям
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Преобразуем словарь в список для удобства
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
rkn_info = rkn_data.get(str(result['hotel_id']), {})
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН колонки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_status = rkn_info.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = not_found_fill # Красный - плохо если в реестре
else:
rkn_status_cell.fill = found_fill # Зелёный - хорошо если НЕ в реестре
col += 1
rkn_number = rkn_info.get('rkn_registry_number', '')
rkn_date = rkn_info.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 1: Статус (ДА/НЕТ)
status = "ДА" if criterion['found'] else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion['found']:
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# Колонка 2: URL
url = criterion['approval_urls'][0] if criterion['approval_urls'] else '-'
cell = ws.cell(row=row_idx, column=col, value=url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 3: Комментарий/Цитата
if criterion['found']:
comment = criterion['quote'] or "Найдено"
comment = comment[:200] + "..." if len(comment) > 200 else comment
else:
comment = "Не найдено"
cell = ws.cell(row=row_idx, column=col, value=comment)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
# Создаём дашборд
create_dashboard(wb, results)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_{region_name.lower().replace(' ', '_')}_{timestamp}.xlsx"
wb.save(filename)
return filename
def create_dashboard(wb, results):
"""Создать дашборд с графиками и статистикой"""
# Создаём новый лист для дашборда
ws = wb.create_sheet("📊 Дашборд", 0) # Вставляем первым
# Стили
title_font = Font(size=16, bold=True, color="366092")
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
value_font = Font(size=14, bold=True)
green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# Заголовок
ws['A1'] = '📊 ДАШБОРД АУДИТА ОТЕЛЕЙ'
ws['A1'].font = title_font
ws.merge_cells('A1:F1')
# Общая статистика
row = 3
ws[f'A{row}'] = 'ОБЩАЯ СТАТИСТИКА'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
total_hotels = len(results)
hotels_with_website = sum(1 for r in results if r.get('has_website'))
hotels_without_website = total_hotels - hotels_with_website
# Считаем РКН
hotel_ids = [str(r['hotel_id']) for r in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
hotels_in_rkn = sum(1 for r in results
if rkn_data.get(str(r['hotel_id']), {}).get('rkn_registry_status', '').lower() == 'found')
avg_score = sum(r['score_percentage'] for r in results) / total_hotels if total_hotels > 0 else 0
stats = [
('Всего отелей:', total_hotels, None),
('С сайтами:', hotels_with_website, green_fill),
('Без сайтов:', hotels_without_website, red_fill),
('В реестре РКН:', hotels_in_rkn, red_fill if hotels_in_rkn > 0 else green_fill),
('Средний балл:', f"{avg_score:.1f}%", yellow_fill if avg_score < 50 else green_fill),
]
for label, value, fill in stats:
ws[f'A{row}'] = label
ws[f'B{row}'] = value
ws[f'B{row}'].font = value_font
if fill:
ws[f'B{row}'].fill = fill
ws[f'B{row}'].alignment = Alignment(horizontal='center')
row += 1
# Настройка ширины колонок
ws.column_dimensions['A'].width = 35
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
print(" 📊 Дашборд создан")
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ EXCEL ИЗ БД")
print("=" * 50)
# Получаем данные из БД
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных аудита в БД")
return
print(f"📊 Найдено результатов аудита: {len(results)}")
# Создаём Excel отчёт
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,615 @@
#!/usr/bin/env python3
"""
ФИНАЛЬНАЯ ВЕРСИЯ - Генерация Excel из БД
Работает с данными напрямую из PostgreSQL
"""
import psycopg2
import json
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.utils import get_column_letter
from openpyxl.chart import BarChart, PieChart, Reference
from datetime import datetime
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
print("📡 Подключаюсь к БД...")
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
query = """
SELECT
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_date, audit_version
FROM hotel_audit_results
WHERE audit_version = 'v1.0_with_rkn'
ORDER BY region_name, hotel_name
"""
cur.execute(query)
results = cur.fetchall()
# Преобразуем в словари
columns = [desc[0] for desc in cur.description]
results = [dict(zip(columns, row)) for row in results]
cur.close()
conn.close()
print(f"✅ Получено результатов: {len(results)}")
return results
def create_excel_report(results):
"""Создать Excel отчёт из данных БД"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит"
# Стили
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=10)
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# ЗАГОЛОВКИ (строка 1)
col = 1
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
for header in base_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
col += 1
# Заголовки критериев (включая РКН в правильном месте)
if results and results[0].get('criteria_results'):
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
print(f"🔍 criteria_results type: {type(criteria_results)}")
# Если это список (из n8n) - используем напрямую
if isinstance(criteria_results, list):
criteria_list = criteria_results
print(f"🔍 Найдено критериев (список): {len(criteria_list)}")
# Если это словарь (из БД) - преобразуем
elif isinstance(criteria_results, dict):
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
})
print(f"🔍 Найдено критериев (словарь): {len(criteria_list)}")
else:
criteria_list = []
print(f"🔍 Неизвестный тип criteria_results")
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН заголовки после критерия 5 (индекс 5) - РЕАЛЬНЫЕ данные из БД
if criterion_idx == 5:
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
criterion_name = f"{criterion.get('criterion_id', criterion_idx+1)}. {criterion.get('criterion_name', f'Критерий {criterion_idx+1}')}"
# Колонка 1: Статус (ДА/НЕТ)
cell = ws.cell(row=1, column=col, value=criterion_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 35
col += 1
# Колонка 2: URL
cell = ws.cell(row=1, column=col, value=f"{criterion.get('criterion_id', criterion_idx+1)}. Апрув URL")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 40
col += 1
# Колонка 3: Цитата/Детали
cell = ws.cell(row=1, column=col, value=f"{criterion.get('criterion_id', criterion_idx+1)}. Комментарий")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 50
col += 1
# Высота строки заголовков
ws.row_dimensions[1].height = 40
print(f"✅ Заголовки созданы, всего колонок: {col-1}")
# ДАННЫЕ (строки 2+)
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
cell = ws.cell(row=row_idx, column=col, value=result['hotel_name'])
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
cell = ws.cell(row=row_idx, column=col, value=result.get('website', 'НЕТ САЙТА'))
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
has_website = "Да" if result.get('has_website') else "Нет"
cell = ws.cell(row=row_idx, column=col, value=has_website)
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
cell = ws.cell(row=row_idx, column=col, value=result['total_score'])
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
perc_cell = ws.cell(row=row_idx, column=col, value=f"{result['score_percentage']:.1f}%")
perc_cell.border = border
perc_cell.alignment = Alignment(horizontal='center', vertical='center')
if result['score_percentage'] >= 70:
perc_cell.fill = found_fill
elif result['score_percentage'] < 50:
perc_cell.fill = not_found_fill
col += 1
# Данные по критериям
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Если это список (из n8n)
if isinstance(criteria_results, list):
criteria_list = criteria_results
# Если это словарь (из БД)
elif isinstance(criteria_results, dict):
criteria_list = []
for i in range(1, 19):
key = f'criterion_{i:02d}'
if key in criteria_results:
criteria_list.append(criteria_results[key])
else:
criteria_list = []
for criterion_idx, criterion in enumerate(criteria_list):
# Пропускаем критерий 6 (индекс 5) - РКН данные добавляем отдельно после критерия 5
if criterion_idx == 5:
# Добавляем РКН данные из hotel_main после критерия 5
rkn_status = result.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = found_fill # Зелёный - хорошо если в реестре
else:
rkn_status_cell.fill = not_found_fill # Красный - плохо если НЕ в реестре
col += 1
rkn_number = result.get('rkn_registry_number', '')
rkn_date = result.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Пропускаем обработку критерия 6
continue
# Колонка 1: Статус (ДА/НЕТ)
status = "ДА" if criterion.get('found') else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion.get('found'):
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# Колонка 2: URL
url = '-'
if criterion.get('ai_agent', {}).get('url'):
url = criterion['ai_agent']['url']
cell = ws.cell(row=row_idx, column=col, value=url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 3: Комментарий/Цитата
comment = "Не найдено"
if criterion.get('found'):
# Приоритет: ai_agent.details → ai_agent.quote → regex.extracted
if criterion.get('ai_agent', {}).get('details'):
comment = criterion['ai_agent']['details']
elif criterion.get('ai_agent', {}).get('quote'):
comment = criterion['ai_agent']['quote']
elif criterion.get('regex', {}).get('extracted'):
comment = f"[Regex] {criterion['regex']['extracted']}"
else:
comment = "Найдено"
# Ограничиваем длину
comment = comment[:200] + "..." if len(comment) > 200 else comment
cell = ws.cell(row=row_idx, column=col, value=comment)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
print(f"✅ Данные добавлены, всего строк: {len(results)}")
# Создаём дашборд
create_dashboard(wb, results)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_from_db_{timestamp}.xlsx"
wb.save(filename)
return filename
def create_dashboard(wb, results):
"""Создать дашборд с графиками и статистикой"""
# Создаём новый лист для дашборда
ws = wb.create_sheet("📊 Дашборд", 0) # Вставляем первым
# Стили
title_font = Font(size=16, bold=True, color="366092")
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
value_font = Font(size=14, bold=True)
green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# Заголовок
ws['A1'] = '📊 ДАШБОРД АУДИТА ОТЕЛЕЙ ЧУКОТКИ'
ws['A1'].font = title_font
ws.merge_cells('A1:F1')
# Получаем статистику из общей базы отелей
db_stats = get_database_statistics()
# Общая статистика
row = 3
ws[f'A{row}'] = 'ОБЩАЯ СТАТИСТИКА ПО ЧУКОТКЕ'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
# Статистика из БД
total_hotels = db_stats['total_hotels']
hotels_with_website = db_stats['hotels_with_website']
hotels_without_website = total_hotels - hotels_with_website
hotels_accessible = db_stats['hotels_accessible']
hotels_inaccessible = hotels_with_website - hotels_accessible
hotels_in_rkn = db_stats['hotels_in_rkn']
# Статистика по аудиту
audited_hotels = len(results)
avg_score = sum(r['score_percentage'] for r in results) / audited_hotels if audited_hotels > 0 else 0
stats = [
('Всего отелей в Чукотке:', total_hotels, None),
('С сайтами:', hotels_with_website, green_fill),
('Без сайтов:', hotels_without_website, red_fill),
('Сайты доступны для анализа:', hotels_accessible, green_fill),
('Сайты недоступны:', hotels_inaccessible, red_fill),
('В реестре РКН:', hotels_in_rkn, green_fill if hotels_in_rkn > 0 else red_fill),
('Проведено аудитов:', audited_hotels, yellow_fill),
('Средний балл (аудит):', f"{avg_score:.1f}%", yellow_fill if avg_score < 50 else green_fill),
]
for label, value, fill in stats:
ws[f'A{row}'] = label
ws[f'B{row}'] = value
ws[f'B{row}'].font = value_font
if fill:
ws[f'B{row}'].fill = fill
ws[f'B{row}'].alignment = Alignment(horizontal='center')
row += 1
# Данные для графика "Статус сайтов" (строки 12-16)
row = 12
ws[f'A{row}'] = 'Категория'
ws[f'B{row}'] = 'Количество'
ws[f'A{row}'].fill = header_fill
ws[f'B{row}'].fill = header_fill
ws[f'A{row}'].font = header_font
ws[f'B{row}'].font = header_font
row += 1
ws[f'A{row}'] = 'Сайты доступны'
ws[f'B{row}'] = hotels_accessible
row += 1
ws[f'A{row}'] = 'Сайты недоступны'
ws[f'B{row}'] = hotels_inaccessible
row += 1
ws[f'A{row}'] = 'Без сайтов'
ws[f'B{row}'] = hotels_without_website
row += 1
ws[f'A{row}'] = 'В реестре РКН'
ws[f'B{row}'] = hotels_in_rkn
# Круговая диаграмма "Статус сайтов"
pie = PieChart()
labels = Reference(ws, min_col=1, min_row=13, max_row=16)
data = Reference(ws, min_col=2, min_row=12, max_row=16)
pie.add_data(data, titles_from_data=True)
pie.set_categories(labels)
pie.title = "Статус сайтов отелей"
pie.height = 10
pie.width = 15
ws.add_chart(pie, "D3")
# Статистика по критериям (строки 18+)
row = 18
ws[f'A{row}'] = 'СТАТИСТИКА ПО КРИТЕРИЯМ'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:C{row}')
row += 1
ws[f'A{row}'] = 'Критерий'
ws[f'B{row}'] = 'Найдено'
ws[f'C{row}'] = 'Не найдено'
for col in ['A', 'B', 'C']:
ws[f'{col}{row}'].fill = header_fill
ws[f'{col}{row}'].font = header_font
row += 1
# Собираем статистику по каждому критерию
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
if isinstance(criteria_results, list):
criteria_stats = []
for criterion_idx in range(18):
criterion_id = criterion_idx + 1
criterion_name = f"{criterion_id}. Критерий {criterion_id}"
# Получаем название критерия из первого результата
if criterion_idx < len(criteria_results):
criterion_name = f"{criterion_id}. {criteria_results[criterion_idx].get('criterion_name', f'Критерий {criterion_id}')[:30]}"
found_count = 0
for r in results:
criteria_res = r['criteria_results']
if isinstance(criteria_res, str):
criteria_res = json.loads(criteria_res)
if isinstance(criteria_res, list) and criterion_idx < len(criteria_res):
if criteria_res[criterion_idx].get('found'):
found_count += 1
not_found_count = total_hotels - found_count
criteria_stats.append((criterion_name, found_count, not_found_count))
# НЕ добавляем РКН отдельно - он уже есть в критериях как #6
criteria_stats_with_rkn = criteria_stats
start_row = row
for criterion_name, found, not_found in criteria_stats_with_rkn:
ws[f'A{row}'] = criterion_name
ws[f'B{row}'] = found
ws[f'C{row}'] = not_found
row += 1
# Столбчатая диаграмма по критериям
chart = BarChart()
chart.type = "col"
chart.style = 10
chart.title = "Результаты по критериям"
chart.y_axis.title = 'Количество отелей'
chart.x_axis.title = 'Критерии'
data = Reference(ws, min_col=2, min_row=start_row-1, max_row=row-1, max_col=3)
cats = Reference(ws, min_col=1, min_row=start_row, max_row=row-1)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
chart.height = 15
chart.width = 25
ws.add_chart(chart, f"E{start_row}")
# Распределение по баллам (строки row+2)
row += 2
ws[f'A{row}'] = 'РАСПРЕДЕЛЕНИЕ ПО БАЛЛАМ'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
ws[f'A{row}'] = 'Диапазон'
ws[f'B{row}'] = 'Количество'
ws[f'A{row}'].fill = header_fill
ws[f'B{row}'].fill = header_fill
ws[f'A{row}'].font = header_font
ws[f'B{row}'].font = header_font
row += 1
# Распределение по диапазонам
ranges = [
('0-25%', 0, 25),
('26-50%', 26, 50),
('51-75%', 51, 75),
('76-100%', 76, 100)
]
start_row = row
for range_name, min_val, max_val in ranges:
count = sum(1 for r in results
if min_val <= r['score_percentage'] <= max_val)
ws[f'A{row}'] = range_name
ws[f'B{row}'] = count
row += 1
# Столбчатая диаграмма распределения
chart2 = BarChart()
chart2.type = "col"
chart2.style = 11
chart2.title = "Распределение по баллам"
chart2.y_axis.title = 'Количество отелей'
data = Reference(ws, min_col=2, min_row=start_row-1, max_row=row-1)
cats = Reference(ws, min_col=1, min_row=start_row, max_row=row-1)
chart2.add_data(data, titles_from_data=True)
chart2.set_categories(cats)
chart2.height = 10
chart2.width = 15
ws.add_chart(chart2, f"D{start_row-1}")
# Настройка ширины колонок
ws.column_dimensions['A'].width = 35
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
print(" 📊 Дашборд создан")
def get_database_statistics():
"""Получить статистику по отелям из БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
# Общее количество отелей в Чукотке
cursor.execute("""
SELECT COUNT(*)
FROM hotel_main
WHERE region_name = 'Чукотский автономный округ'
""")
total_hotels = cursor.fetchone()[0]
# Отели с сайтами
cursor.execute("""
SELECT COUNT(*)
FROM hotel_main
WHERE region_name = 'Чукотский автономный округ'
AND website_address IS NOT NULL
AND website_address != ''
""")
hotels_with_website = cursor.fetchone()[0]
# Отели с доступными сайтами (есть в processed)
cursor.execute("""
SELECT COUNT(DISTINCT h.id)
FROM hotel_main h
JOIN hotel_website_processed p ON h.id = p.hotel_id
WHERE h.region_name = 'Чукотский автономный округ'
AND h.website_address IS NOT NULL
AND h.website_address != ''
""")
hotels_accessible = cursor.fetchone()[0]
# Отели в реестре РКН
cursor.execute("""
SELECT COUNT(*)
FROM hotel_main
WHERE region_name = 'Чукотский автономный округ'
AND rkn_registry_status = 'found'
""")
hotels_in_rkn = cursor.fetchone()[0]
cursor.close()
conn.close()
return {
'total_hotels': total_hotels,
'hotels_with_website': hotels_with_website,
'hotels_accessible': hotels_accessible,
'hotels_in_rkn': hotels_in_rkn
}
except Exception as e:
print(f"❌ Ошибка получения статистики: {e}")
return {
'total_hotels': 0,
'hotels_with_website': 0,
'hotels_accessible': 0,
'hotels_in_rkn': 0
}
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ EXCEL ИЗ БД - ФИНАЛЬНАЯ ВЕРСИЯ")
print("=" * 50)
try:
# Получаем данные из БД
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных аудита в БД")
return
print(f"📊 Найдено результатов аудита: {len(results)}")
# Создаём Excel отчёт
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
except Exception as e:
print(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

277
generate_excel_from_json.py Normal file
View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
Генерация Excel из JSON файла (экспортированного из n8n)
"""
import json
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.utils import get_column_letter
from datetime import datetime
def create_excel_from_json(json_file):
"""Создать Excel отчёт из JSON файла"""
# Читаем JSON
with open(json_file, 'r', encoding='utf-8') as f:
results = json.load(f)
print(f"📊 Найдено результатов аудита: {len(results)}")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит"
# Стили
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=10)
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# ЗАГОЛОВКИ (строка 1)
col = 1
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
for header in base_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
col += 1
# Заголовки критериев (включая РКН в правильном месте)
if results and results[0].get('criteria_results'):
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
print(f"🔍 criteria_results type: {type(criteria_results)}")
# Если это список (из n8n) - используем напрямую
if isinstance(criteria_results, list):
criteria_list = criteria_results
print(f"🔍 Найдено критериев (список): {len(criteria_list)}")
# Если это словарь (из БД) - преобразуем
elif isinstance(criteria_results, dict):
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
})
print(f"🔍 Найдено критериев (словарь): {len(criteria_list)}")
else:
criteria_list = []
print(f"🔍 Неизвестный тип criteria_results")
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН заголовки после критерия 5 (индекс 5)
if criterion_idx == 5:
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 30
col += 1
criterion_name = f"{criterion['criterion_id']}. {criterion['criterion_name']}"
# Колонка 1: Статус (ДА/НЕТ)
cell = ws.cell(row=1, column=col, value=criterion_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 35
col += 1
# Колонка 2: URL
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Апрув URL")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 40
col += 1
# Колонка 3: Цитата/Детали
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Комментарий")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 50
col += 1
# Высота строки заголовков
ws.row_dimensions[1].height = 40
print(f"✅ Заголовки созданы, всего колонок: {col-1}")
# ДАННЫЕ (строки 2+)
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
cell = ws.cell(row=row_idx, column=col, value=result['hotel_name'])
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
cell = ws.cell(row=row_idx, column=col, value=result.get('website', 'НЕТ САЙТА'))
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
has_website = "Да" if result.get('has_website') else "Нет"
cell = ws.cell(row=row_idx, column=col, value=has_website)
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
cell = ws.cell(row=row_idx, column=col, value=result['total_score'])
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
perc_cell = ws.cell(row=row_idx, column=col, value=f"{result['score_percentage']:.1f}%")
perc_cell.border = border
perc_cell.alignment = Alignment(horizontal='center', vertical='center')
if result['score_percentage'] >= 70:
perc_cell.fill = found_fill
elif result['score_percentage'] < 50:
perc_cell.fill = not_found_fill
col += 1
# Данные по критериям
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Если это список (из n8n)
if isinstance(criteria_results, list):
criteria_list = criteria_results
# Если это словарь (из БД)
elif isinstance(criteria_results, dict):
criteria_list = []
for i in range(1, 19):
key = f'criterion_{i:02d}'
if key in criteria_results:
criteria_list.append(criteria_results[key])
else:
criteria_list = []
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН колонки после критерия 5 (индекс 5)
if criterion_idx == 5:
# РКН данные
rkn_status = criterion.get('rkn_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = found_fill # Зелёный - хорошо если в реестре
else:
rkn_status_cell.fill = not_found_fill # Красный - плохо если НЕ в реестре
col += 1
rkn_number = criterion.get('rkn_number', '')
rkn_date = criterion.get('rkn_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 1: Статус (ДА/НЕТ)
status = "ДА" if criterion.get('found') else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion.get('found'):
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# Колонка 2: URL
url = '-'
if criterion.get('ai_agent', {}).get('url'):
url = criterion['ai_agent']['url']
cell = ws.cell(row=row_idx, column=col, value=url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 3: Комментарий/Цитата
comment = "Не найдено"
if criterion.get('found'):
# Приоритет: ai_agent.details → ai_agent.quote → regex.extracted
if criterion.get('ai_agent', {}).get('details'):
comment = criterion['ai_agent']['details']
elif criterion.get('ai_agent', {}).get('quote'):
comment = criterion['ai_agent']['quote']
elif criterion.get('regex', {}).get('extracted'):
comment = f"[Regex] {criterion['regex']['extracted']}"
else:
comment = "Найдено"
# Ограничиваем длину
comment = comment[:200] + "..." if len(comment) > 200 else comment
cell = ws.cell(row=row_idx, column=col, value=comment)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
print(f"✅ Данные добавлены, всего строк: {len(results)}")
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_from_json_{timestamp}.xlsx"
wb.save(filename)
return filename
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ EXCEL ИЗ JSON")
print("=" * 50)
json_file = "audit_data.json"
print(f"📂 Читаю файл: {json_file}")
try:
filename = create_excel_from_json(json_file)
print(f"✅ Excel отчёт сохранён: {filename}")
except FileNotFoundError:
print(f"❌ Файл {json_file} не найден")
print(f"📝 Создайте файл {json_file} с данными из n8n")
except Exception as e:
print(f"❌ Ошибка: {e}")
if __name__ == "__main__":
main()

422
generate_excel_working.py Executable file
View File

@@ -0,0 +1,422 @@
#!/usr/bin/env python3
"""
Генерация Excel отчёта из БД - РАБОЧАЯ ВЕРСИЯ
Основана на audit_chukotka_to_excel.py
"""
import psycopg2
import json
from psycopg2.extras import RealDictCursor
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.chart import BarChart, PieChart, Reference
from openpyxl.utils import get_column_letter
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
conn = psycopg2.connect(**DB_CONFIG) # Убираем RealDictCursor
cur = conn.cursor()
query = """
SELECT
hotel_id, region_name, hotel_name, website, has_website,
criteria_results, total_score, max_score, score_percentage,
audit_date, audit_version
FROM hotel_audit_results
WHERE audit_version = 'v1.0_with_rkn'
ORDER BY region_name, hotel_name
"""
cur.execute(query)
results = cur.fetchall()
# Преобразуем в словари
columns = [desc[0] for desc in cur.description]
results = [dict(zip(columns, row)) for row in results]
cur.close()
conn.close()
return results
def get_hotel_rkn_info(hotel_ids):
"""Получить РКН информацию для отелей"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
placeholders = ','.join(['%s'] * len(hotel_ids))
query = f"""
SELECT id, rkn_registry_status, rkn_registry_number, rkn_registry_date
FROM hotel_main
WHERE id IN ({placeholders})
"""
cur.execute(query, hotel_ids)
rkn_data = {row['id']: row for row in cur.fetchall()}
cur.close()
conn.close()
return rkn_data
def create_excel_report(results):
"""Создать Excel отчёт из данных БД"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Аудит"
# Стили
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=10)
found_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
not_found_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# ЗАГОЛОВКИ (строка 1)
col = 1
base_headers = ['Отель', 'Сайт', 'Есть сайт', 'Балл', 'Процент']
for header in base_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
col += 1
# Заголовки критериев (включая РКН в правильном месте)
if results and results[0]['criteria_results']:
criteria_results = results[0]['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Преобразуем словарь в список для удобства
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН заголовки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_headers = ['6. РКН Реестр', '6. РКН Номер/Дата', '6. РКН Ссылка']
for header in rkn_headers:
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
if 'Номер' in header:
ws.column_dimensions[get_column_letter(col)].width = 30
elif 'Ссылка' in header:
ws.column_dimensions[get_column_letter(col)].width = 50
else:
ws.column_dimensions[get_column_letter(col)].width = 20
col += 1
criterion_name = f"{criterion['criterion_id']}. {criterion['criterion_name']}"
# Колонка 1: Статус (ДА/НЕТ)
cell = ws.cell(row=1, column=col, value=criterion_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 35
col += 1
# Колонка 2: URL
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Апрув URL")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 40
col += 1
# Колонка 3: Цитата/Детали
cell = ws.cell(row=1, column=col, value=f"{criterion['criterion_id']}. Комментарий")
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
cell.border = border
ws.column_dimensions[get_column_letter(col)].width = 50
col += 1
# Высота строки заголовков
ws.row_dimensions[1].height = 40
# Получаем РКН данные для всех отелей
hotel_ids = [str(result['hotel_id']) for result in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
# ДАННЫЕ (строки 2+)
for row_idx, result in enumerate(results, 2):
col = 1
# Базовые данные
cell = ws.cell(row=row_idx, column=col, value=result['hotel_name'])
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
cell = ws.cell(row=row_idx, column=col, value=result.get('website', 'НЕТ САЙТА'))
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
has_website = "Да" if result.get('has_website') else "Нет"
cell = ws.cell(row=row_idx, column=col, value=has_website)
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
cell = ws.cell(row=row_idx, column=col, value=result['total_score'])
cell.border = border
cell.alignment = Alignment(horizontal='center', vertical='center')
col += 1
perc_cell = ws.cell(row=row_idx, column=col, value=f"{result['score_percentage']:.1f}%")
perc_cell.border = border
perc_cell.alignment = Alignment(horizontal='center', vertical='center')
if result['score_percentage'] >= 70:
perc_cell.fill = found_fill
elif result['score_percentage'] < 50:
perc_cell.fill = not_found_fill
col += 1
# Данные по критериям
criteria_results = result['criteria_results']
if isinstance(criteria_results, str):
criteria_results = json.loads(criteria_results)
# Преобразуем словарь в список для удобства
criteria_list = []
for i in range(1, 19): # критерии 1-18
key = f'criterion_{i:02d}'
if key in criteria_results:
criterion_data = criteria_results[key]
criteria_list.append({
'criterion_id': i,
'criterion_name': criterion_data.get('name', f'Критерий {i}'),
'found': criterion_data.get('found', False),
'quote': criterion_data.get('quote', ''),
'score': criterion_data.get('score', 0),
'verdict': criterion_data.get('verdict', 'НЕТ'),
'confidence': criterion_data.get('confidence', 0),
'approval_urls': criterion_data.get('approval_urls', []),
'keywords_found': criterion_data.get('keywords_found', []),
'patterns_found': criterion_data.get('patterns_found', []),
'approval_quotes': criterion_data.get('approval_quotes', [])
})
rkn_info = rkn_data.get(str(result['hotel_id']), {})
for criterion_idx, criterion in enumerate(criteria_list):
# Вставляем РКН колонки после критерия 5 (индекс 4)
if criterion_idx == 5: # После критерия 5 (индекс 5 = 6-й критерий)
# Колонки РКН (критерий #6)
rkn_status = rkn_info.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_status_cell = ws.cell(row=row_idx, column=col, value=rkn_in_registry)
rkn_status_cell.border = border
rkn_status_cell.alignment = Alignment(horizontal='center', vertical='center')
if rkn_in_registry == "ДА":
rkn_status_cell.fill = not_found_fill # Красный - плохо если в реестре
else:
rkn_status_cell.fill = found_fill # Зелёный - хорошо если НЕ в реестре
col += 1
rkn_number = rkn_info.get('rkn_registry_number', '')
rkn_date = rkn_info.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_info_text)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
cell = ws.cell(row=row_idx, column=col, value=rkn_url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 1: Статус (ДА/НЕТ)
status = "ДА" if criterion['found'] else "НЕТ"
status_cell = ws.cell(row=row_idx, column=col, value=status)
status_cell.border = border
status_cell.alignment = Alignment(horizontal='center', vertical='center')
if criterion['found']:
status_cell.fill = found_fill
else:
status_cell.fill = not_found_fill
col += 1
# Колонка 2: URL
url = criterion['approval_urls'][0] if criterion['approval_urls'] else '-'
cell = ws.cell(row=row_idx, column=col, value=url)
cell.border = border
cell.alignment = Alignment(vertical='top')
col += 1
# Колонка 3: Комментарий/Цитата
if criterion['found']:
comment = ""
# Приоритет: цитата → approval_quotes → keywords_found → "Найдено"
if criterion['quote']:
comment = criterion['quote']
elif criterion['approval_quotes']:
first_quote = criterion['approval_quotes'][0]
if isinstance(first_quote, dict):
comment = first_quote.get('quote', 'Найдено')
else:
comment = str(first_quote)
elif criterion['keywords_found']:
comment = f"Ключевые слова: {', '.join(criterion['keywords_found'])}"
else:
comment = "Найдено"
# Ограничиваем длину
comment = comment[:200] + "..." if len(comment) > 200 else comment
else:
comment = "Не найдено"
cell = ws.cell(row=row_idx, column=col, value=comment)
cell.border = border
cell.alignment = Alignment(vertical='top', wrap_text=True)
col += 1
# Высота строки
ws.row_dimensions[row_idx].height = 50
# Создаём дашборд
create_dashboard(wb, results)
# Сохраняем файл
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_from_db_{timestamp}.xlsx"
wb.save(filename)
return filename
def create_dashboard(wb, results):
"""Создать дашборд с графиками и статистикой"""
# Создаём новый лист для дашборда
ws = wb.create_sheet("📊 Дашборд", 0) # Вставляем первым
# Стили
title_font = Font(size=16, bold=True, color="366092")
header_font = Font(size=12, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
value_font = Font(size=14, bold=True)
green_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
yellow_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# Заголовок
ws['A1'] = '📊 ДАШБОРД АУДИТА ОТЕЛЕЙ'
ws['A1'].font = title_font
ws.merge_cells('A1:F1')
# Общая статистика
row = 3
ws[f'A{row}'] = 'ОБЩАЯ СТАТИСТИКА'
ws[f'A{row}'].font = Font(size=14, bold=True)
ws.merge_cells(f'A{row}:B{row}')
row += 1
total_hotels = len(results)
hotels_with_website = sum(1 for r in results if r.get('has_website'))
hotels_without_website = total_hotels - hotels_with_website
# Считаем РКН
hotel_ids = [str(r['hotel_id']) for r in results]
rkn_data = get_hotel_rkn_info(hotel_ids)
hotels_in_rkn = sum(1 for r in results
if rkn_data.get(str(r['hotel_id']), {}).get('rkn_registry_status', '').lower() == 'found')
avg_score = sum(r['score_percentage'] for r in results) / total_hotels if total_hotels > 0 else 0
stats = [
('Всего отелей:', total_hotels, None),
('С сайтами:', hotels_with_website, green_fill),
('Без сайтов:', hotels_without_website, red_fill),
('В реестре РКН:', hotels_in_rkn, red_fill if hotels_in_rkn > 0 else green_fill),
('Средний балл:', f"{avg_score:.1f}%", yellow_fill if avg_score < 50 else green_fill),
]
for label, value, fill in stats:
ws[f'A{row}'] = label
ws[f'B{row}'] = value
ws[f'B{row}'].font = value_font
if fill:
ws[f'B{row}'].fill = fill
ws[f'B{row}'].alignment = Alignment(horizontal='center')
row += 1
# Настройка ширины колонок
ws.column_dimensions['A'].width = 35
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
print(" 📊 Дашборд создан")
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ EXCEL ИЗ БД - РАБОЧАЯ ВЕРСИЯ")
print("=" * 50)
# Получаем данные из БД
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных аудита в БД")
return
print(f"📊 Найдено результатов аудита: {len(results)}")
# Создаём Excel отчёт
filename = create_excel_report(results)
print(f"✅ Excel отчёт сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
if results:
avg_score = sum(r['score_percentage'] for r in results) / len(results)
print(f"📈 Средний % соответствия: {avg_score:.1f}%")
if __name__ == "__main__":
main()

159
generate_simple_csv.py Normal file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
ИСПРАВЛЕННЫЙ CSV генератор
"""
import psycopg2
import json
import csv
from urllib.parse import unquote
from datetime import datetime
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def get_audit_results_from_db():
"""Получить результаты аудита из БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
cursor.execute("""
SELECT
ar.hotel_id,
hm.full_name,
hm.website_address,
hm.rkn_registry_status,
hm.rkn_registry_number,
hm.rkn_registry_date,
ar.score_percentage,
ar.criteria_results,
hm.created_at
FROM hotel_audit_results ar
JOIN hotel_main hm ON ar.hotel_id = hm.id
WHERE hm.region_name = 'Чукотский автономный округ'
ORDER BY hm.created_at DESC
""")
results = []
for row in cursor.fetchall():
result = {
'hotel_id': row[0],
'full_name': row[1],
'website_address': row[2],
'rkn_registry_status': row[3],
'rkn_registry_number': row[4],
'rkn_registry_date': row[5],
'score_percentage': row[6],
'criteria_results': row[7],
'created_at': row[8]
}
results.append(result)
cursor.close()
conn.close()
return results
except Exception as e:
print(f"❌ Ошибка получения данных: {e}")
return []
def create_csv_report(results):
"""Создать CSV отчёт"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"audit_fixed_{timestamp}.csv"
with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
# Заголовки - ТОЛЬКО БАЗОВЫЕ + РКН
headers = [
'ID отеля', 'Название отеля', 'Сайт', 'Балл (%)', 'Дата аудита',
'РКН_Реестр', 'РКН_Номерата', 'РКН_Ссылка'
]
writer.writerow(headers)
# Данные
for result in results:
# Базовые данные
row = [
result['hotel_id'],
result['full_name'],
result['website_address'] or '-',
result['score_percentage'],
str(result['created_at'])[:10]
]
# РКН данные из hotel_main
rkn_status = result.get('rkn_registry_status', '')
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
rkn_number = result.get('rkn_registry_number', '')
rkn_date = result.get('rkn_registry_date', '')
rkn_info_text = f"{rkn_number}\n{rkn_date}" if rkn_number or rkn_date else "-"
rkn_url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" if rkn_number else "-"
row.extend([rkn_in_registry, rkn_info_text, rkn_url])
writer.writerow(row)
return filename
def main():
"""Основная функция"""
print("🚀 ГЕНЕРАЦИЯ ПРОСТОГО CSV")
print("=" * 30)
try:
results = get_audit_results_from_db()
if not results:
print("❌ Нет данных для отчёта")
return
print(f"✅ Получено результатов: {len(results)}")
# Выводим сырые данные первого отеля
if results:
print("\n🔍 СЫРЫЕ ДАННЫЕ ПЕРВОГО ОТЕЛЯ:")
print(f"ID: {results[0]['hotel_id']}")
print(f"Название: {results[0]['full_name']}")
print(f"Сайт: {results[0]['website_address']}")
print(f"РКН статус: {results[0]['rkn_registry_status']}")
print(f"РКН номер: {results[0]['rkn_registry_number']}")
print(f"РКН дата: {results[0]['rkn_registry_date']}")
print(f"Балл: {results[0]['score_percentage']}")
# Проверяем критерии
criteria_results = results[0]['criteria_results']
print(f"\n📊 КРИТЕРИИ (тип: {type(criteria_results)}):")
if isinstance(criteria_results, str):
try:
parsed = json.loads(criteria_results)
print(f" JSON парсится: {len(parsed)} элементов")
if parsed and isinstance(parsed[0], dict):
print(f" Первый критерий: {parsed[0]}")
except:
print(f" Не JSON: {criteria_results[:100]}...")
else:
print(f" Не строка: {criteria_results}")
# Создаём CSV
filename = create_csv_report(results)
print(f"\n✅ CSV файл сохранён: {filename}")
print(f"📊 Обработано отелей: {len(results)}")
except Exception as e:
print(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

579
hybrid_audit_chukotka.py Normal file
View File

@@ -0,0 +1,579 @@
#!/usr/bin/env python3
"""
ГИБРИДНЫЙ АУДИТ ОТЕЛЕЙ ЧУКОТКИ
Комбинирует 3 подхода:
1. Семантический поиск (BGE-M3 embeddings)
2. Регулярные выражения (точные паттерны)
3. NER с Natasha (извлечение сущностей)
"""
import psycopg2
from psycopg2.extras import RealDictCursor
import requests
import json
import pandas as pd
from datetime import datetime
import time
from urllib.parse import unquote
import re
# Natasha для NER
from natasha import (
Segmenter,
MorphVocab,
NewsEmbedding,
NewsMorphTagger,
NewsSyntaxParser,
NewsNERTagger,
Doc
)
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
# Конфигурация для BGE-M3 API
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
# Инициализация Natasha
print("🔧 Инициализация Natasha...")
segmenter = Segmenter()
morph_vocab = MorphVocab()
emb = NewsEmbedding()
morph_tagger = NewsMorphTagger(emb)
syntax_parser = NewsSyntaxParser(emb)
ner_tagger = NewsNERTagger(emb)
print("✅ Natasha готова!")
# 18 НАСТОЯЩИХ критериев аудита с регулярками
AUDIT_CRITERIA = [
{
'id': 1,
'name': 'Юридическая идентификация и верификация',
'query': 'полное наименование организации ОПФ ИНН ОГРН ЕГРЮЛ ЕГРИП проверить',
'keywords': ['инн', 'огрн', 'егрюл', 'егрип', 'организация', 'ооо', 'ип'],
'required_patterns': [
r'\b\d{10}\b', # ИНН юридического лица (10 цифр)
r'\b\d{12}\b', # ИНН ИП (12 цифр)
r'\b\d{13}\b', # ОГРН (13 цифр)
r'\b\d{15}\b', # ОГРНИП (15 цифр)
r'инн\s*:?\s*\d{10,12}',
r'огрн\s*:?\s*\d{13}',
r'огрнип\s*:?\s*\d{15}',
],
'use_ner': True, # Использовать Natasha для извлечения названий организаций
'weight': 1.0
},
{
'id': 2,
'name': 'Адрес',
'query': 'юридический адрес фактический адрес местонахождение',
'keywords': ['адрес', 'address', 'местонахождение', 'г.', 'ул.'],
'priority_patterns': [
r'\d{6}.*?ул\.', # Индекс + ул.
r'ул\.\s*[А-Яа-яёЁA-Za-z\s]+,?\s*\d+', # ул. Название, дом
],
'use_ner': True, # Использовать Natasha для извлечения адресов
'weight': 1.0
},
{
'id': 3,
'name': 'Контакты',
'query': 'телефон email форма обратной связи чат контакты',
'keywords': ['телефон', 'phone', 'email', '@', '+7', '8-800'],
'priority_patterns': [
r'(?:\+7|8)\s*\(?\d{3,5}\)?\s*\d{1,3}[-\s]?\d{2}[-\s]?\d{2}', # Телефон
r'[\w\.-]+@[\w\.-]+\.\w{2,}', # Email
],
'weight': 1.0
},
{
'id': 4,
'name': 'Режим работы',
'query': 'часы работы график приема режим работы колл-центр',
'keywords': ['часы работы', 'график работы', 'режим работы', 'круглосуточно', '24/7'],
'priority_patterns': [
r'(?:с|с\s+)\d{1,2}(?::|\.)\d{2}\s*(?:до|по)\s*\d{1,2}(?::|\.)\d{2}', # с 9:00 до 18:00
r'\d{1,2}:\d{2}\s*-\s*\d{1,2}:\d{2}', # 9:00 - 18:00
r'круглосуточно',
r'24\s*[/\-]\s*7',
],
'weight': 1.0
},
{
'id': 5,
'name': 'Политика ПДн (152-ФЗ)',
'query': 'политика персональных данных обработка ПДн 152-ФЗ',
'keywords': ['персональных данных', 'пдн', '152-фз', 'privacy'],
'priority_patterns': [
r'политика\s+в\s+отношении\s+обработки\s+персональных\s+данных',
r'152[-\s]?фз',
r'федеральный\s+закон.*?персональных\s+данных',
],
'weight': 1.0
},
{
'id': 6,
'name': 'Роскомнадзор (реестр)',
'query': 'роскомнадзор реестр операторов персональных данных',
'keywords': ['роскомнадзор', 'реестр', 'оператор'],
'weight': 1.0
},
{
'id': 7,
'name': 'Договор-оферта / Правила оказания услуг',
'query': 'договор оферта правила оказания услуг условия',
'keywords': ['договор', 'оферта', 'правила', 'условия', 'услуг'],
'priority_patterns': [
r'публичная\s+оферта',
r'договор.*?оказани.*?услуг',
],
'weight': 1.0
},
{
'id': 8,
'name': 'Рекламации и споры',
'query': 'рекламации споры жалобы претензии решение конфликтов',
'keywords': ['рекламация', 'спор', 'жалоба', 'претензия', 'конфликт'],
'weight': 1.0
},
{
'id': 9,
'name': 'Цены/прайс',
'query': 'цены прайс тарифы стоимость номера',
'keywords': ['цена', 'прайс', 'тариф', 'стоимость', 'номер'],
'priority_patterns': [
r'\d+\s*(?:руб|₽)', # Цены в рублях
],
'weight': 1.0
},
{
'id': 10,
'name': 'Способы оплаты',
'query': 'способы оплаты платеж банковская карта наличные',
'keywords': ['оплата', 'платеж', 'карта', 'наличные', 'способ'],
'weight': 1.0
},
{
'id': 11,
'name': 'Онлайн-оплата',
'query': 'онлайн оплата интернет платеж карта через сайт',
'keywords': ['онлайн', 'интернет', 'платеж', 'карта', 'сайт'],
'weight': 1.0
},
{
'id': 12,
'name': 'Онлайн-бронирование',
'query': 'онлайн бронирование заказ номера через сайт',
'keywords': ['бронирование', 'заказ', 'номер', 'сайт', 'онлайн'],
'weight': 1.0
},
{
'id': 13,
'name': 'FAQ',
'query': 'часто задаваемые вопросы FAQ помощь',
'keywords': ['faq', 'вопрос', 'ответ', 'помощь', 'часто'],
'weight': 1.0
},
{
'id': 14,
'name': 'Доступность для ЛОВЗ',
'query': 'доступность инвалиды ЛОВЗ безбарьерная среда',
'keywords': ['доступность', 'инвалид', 'ловз', 'безбарьерная'],
'weight': 1.0
},
{
'id': 15,
'name': 'Партнёры/бренды',
'query': 'партнеры бренды сотрудничество франшиза',
'keywords': ['партнер', 'бренд', 'сотрудничество', 'франшиза'],
'weight': 1.0
},
{
'id': 16,
'name': 'Команда/сотрудники',
'query': 'команда сотрудники персонал коллектив',
'keywords': ['команда', 'сотрудник', 'персонал', 'коллектив'],
'weight': 1.0
},
{
'id': 17,
'name': 'Уголок потребителя',
'query': 'уголок потребителя права потребителя защита прав',
'keywords': ['потребитель', 'права', 'защита', 'уголок'],
'weight': 1.0
},
{
'id': 18,
'name': 'Актуальность документов',
'query': 'актуальность документов дата обновления свежая информация',
'keywords': ['актуальность', 'документ', 'дата', 'обновление', 'свежая'],
'weight': 1.0
}
]
def get_db_connection():
"""Получить подключение к БД"""
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
def generate_embedding(text: str) -> list:
"""Генерация эмбеддинга для текста через API"""
headers = {
"X-API-Key": BGE_API_KEY,
"Content-Type": "application/json"
}
payload = {"text": [text]}
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=30)
response.raise_for_status()
return response.json().get('embeddings', [[]])[0]
def semantic_search_for_criterion(hotel_id: str, query: str, limit: int = 3):
"""Семантический поиск по chunks отеля"""
try:
query_embedding = generate_embedding(query)
embedding_str = json.dumps(query_embedding)
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
query_sql = f"""
SELECT
text,
metadata->>'url' as url,
embedding <-> %s::vector as distance
FROM hotel_website_chunks
WHERE metadata->>'hotel_id' = %s AND embedding IS NOT NULL
ORDER BY embedding <-> %s::vector
LIMIT %s;
"""
cur.execute(query_sql, (embedding_str, hotel_id, embedding_str, limit))
results = cur.fetchall()
cur.close()
conn.close()
return results
except Exception as e:
print(f"Ошибка семантического поиска: {e}")
return []
def check_patterns(text: str, patterns: list) -> dict:
"""Проверка текста на соответствие регулярным выражениям"""
matches = []
for pattern in patterns:
found = re.findall(pattern, text, re.IGNORECASE)
if found:
matches.extend(found[:3]) # Макс 3 совпадения на паттерн
return {
'found': len(matches) > 0,
'matches': matches[:5], # Макс 5 совпадений всего
'count': len(matches)
}
def extract_entities_with_natasha(text: str) -> dict:
"""Извлечение сущностей с помощью Natasha"""
try:
doc = Doc(text[:5000]) # Ограничиваем длину для производительности
doc.segment(segmenter)
doc.tag_morph(morph_tagger)
doc.parse_syntax(syntax_parser)
doc.tag_ner(ner_tagger)
entities = {
'ORG': [], # Организации
'PER': [], # Люди
'LOC': [], # Локации/адреса
}
for span in doc.spans:
if span.type in entities:
entities[span.type].append(span.text)
return entities
except Exception as e:
print(f"Ошибка Natasha: {e}")
return {'ORG': [], 'PER': [], 'LOC': []}
def hybrid_audit_criterion(hotel_id: str, criterion: dict) -> dict:
"""
Гибридный аудит по одному критерию:
1. Семантический поиск
2. Проверка регулярками
3. NER с Natasha (если включено)
"""
result = {
'semantic_score': 0.0,
'pattern_score': 0.0,
'ner_score': 0.0,
'final_score': 0.0,
'evidence': [],
'explanation': '',
'approval_urls': [], # Ссылки на страницы
'approval_quotes': [] # Цитаты с контекстом
}
# 1. СЕМАНТИЧЕСКИЙ ПОИСК
semantic_matches = semantic_search_for_criterion(hotel_id, criterion['query'], limit=3)
if semantic_matches:
best_match = semantic_matches[0]
distance = best_match['distance']
url = best_match.get('url', 'Нет URL')
if distance < 0.7:
result['semantic_score'] = 1.0
result['evidence'].append(f"🔍 Семантика (отлично, {distance:.3f})")
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': best_match['text'][:300],
'method': 'Семантический поиск',
'distance': f"{distance:.3f}"
})
elif distance < 0.9:
result['semantic_score'] = 0.5
result['evidence'].append(f"🔍 Семантика (средне, {distance:.3f})")
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': best_match['text'][:300],
'method': 'Семантический поиск',
'distance': f"{distance:.3f}"
})
else:
result['semantic_score'] = 0.2
result['evidence'].append(f"🔍 Семантика (слабо, {distance:.3f})")
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': best_match['text'][:300],
'method': 'Семантический поиск',
'distance': f"{distance:.3f}"
})
# 2. ПРОВЕРКА РЕГУЛЯРКАМИ
if 'priority_patterns' in criterion or 'required_patterns' in criterion:
patterns = criterion.get('priority_patterns', []) + criterion.get('required_patterns', [])
# Проверяем все найденные семантикой тексты
for match in semantic_matches:
pattern_check = check_patterns(match['text'], patterns)
if pattern_check['found']:
result['pattern_score'] = 1.0
result['evidence'].append(f"✅ Регулярки: найдено {pattern_check['count']} совпадений")
# Добавляем цитату с найденными паттернами
url = match.get('url', 'Нет URL')
if url not in result['approval_urls']:
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': match['text'][:300],
'method': 'Регулярные выражения',
'matches': ', '.join(pattern_check['matches'])
})
break # Нашли - хватит
else:
result['pattern_score'] = 0.0
# 3. NER С NATASHA
if criterion.get('use_ner', False) and semantic_matches:
all_text = " ".join([m['text'] for m in semantic_matches])
entities = extract_entities_with_natasha(all_text)
if criterion['id'] == 1: # Юридическая идентификация
if entities['ORG']:
result['ner_score'] = 1.0
result['evidence'].append(f"🏢 Natasha (организации): {', '.join(entities['ORG'][:3])}")
# Добавляем цитату с найденными организациями
url = semantic_matches[0].get('url', 'Нет URL')
if url not in result['approval_urls']:
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': all_text[:300],
'method': 'Natasha NER (организации)',
'entities': ', '.join(entities['ORG'][:3])
})
else:
result['ner_score'] = 0.0
elif criterion['id'] == 2: # Адрес
if entities['LOC']:
result['ner_score'] = 1.0
result['evidence'].append(f"📍 Natasha (адреса): {', '.join(entities['LOC'][:3])}")
# Добавляем цитату с найденными адресами
url = semantic_matches[0].get('url', 'Нет URL')
if url not in result['approval_urls']:
result['approval_urls'].append(url)
result['approval_quotes'].append({
'url': url,
'quote': all_text[:300],
'method': 'Natasha NER (адреса)',
'entities': ', '.join(entities['LOC'][:3])
})
else:
result['ner_score'] = 0.0
# ИТОГОВАЯ ОЦЕНКА (взвешенная)
weights = {
'semantic': 0.4,
'pattern': 0.4,
'ner': 0.2
}
result['final_score'] = (
result['semantic_score'] * weights['semantic'] +
result['pattern_score'] * weights['pattern'] +
result['ner_score'] * weights['ner']
)
# Объяснение
if result['final_score'] >= 0.8:
result['explanation'] = "🟢 Высокая: информация найдена и подтверждена"
elif result['final_score'] >= 0.5:
result['explanation'] = "🟡 Средняя: информация найдена частично"
elif result['final_score'] >= 0.3:
result['explanation'] = "🟠 Низкая: информация найдена, но не подтверждена"
else:
result['explanation'] = "🔴 Очень низкая: информация не найдена"
return result
def audit_hotel_hybrid(hotel_info: dict):
"""Проводит гибридный аудит для одного отеля"""
hotel_id = hotel_info['id']
hotel_name = hotel_info['full_name']
region_name = hotel_info['region_name']
print(f"\n🏨 ГИБРИДНЫЙ АУДИТ: {hotel_name}")
print("=" * 80)
results = {
'hotel_id': hotel_id,
'hotel_name': hotel_name,
'region_name': region_name,
'total_score': 0.0,
'criteria_results': {}
}
for criterion in AUDIT_CRITERIA:
print(f" 🔍 Критерий {criterion['id']}: {criterion['name']}")
audit_result = hybrid_audit_criterion(hotel_id, criterion)
results['criteria_results'][criterion['name']] = audit_result
results['total_score'] += audit_result['final_score']
print(f" {audit_result['explanation']} (Итого: {audit_result['final_score']:.2f}/1.0)")
print(f" └─ Семантика: {audit_result['semantic_score']:.2f} | Регулярки: {audit_result['pattern_score']:.2f} | NER: {audit_result['ner_score']:.2f}")
for evidence in audit_result['evidence'][:2]: # Показываем первые 2 доказательства
print(f" {evidence}")
time.sleep(0.5) # Небольшая пауза между критериями
print(f"\n📊 ИТОГОВАЯ ОЦЕНКА: {results['total_score']:.2f}/{len(AUDIT_CRITERIA)} ({results['total_score']/len(AUDIT_CRITERIA)*100:.1f}%)")
print("=" * 80)
return results
def main():
print("🚀 ГИБРИДНЫЙ АУДИТ ОТЕЛЕЙ ЧУКОТКИ")
print("=" * 80)
print("Методы:")
print(" 1⃣ Семантический поиск (BGE-M3)")
print(" 2⃣ Регулярные выражения")
print(" 3⃣ NER с Natasha")
print("=" * 80)
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# Получаем ВСЕ отели Чукотского автономного округа с эмбеддингами
cur.execute("""
SELECT DISTINCT ON (hm.id) hm.id, hm.full_name, hm.region_name
FROM hotel_main hm
JOIN hotel_website_chunks hwc ON hm.id::text = hwc.metadata->>'hotel_id'
WHERE hm.region_name = 'Чукотский автономный округ';
""")
chukotka_hotels = cur.fetchall()
cur.close()
conn.close()
if not chukotka_hotels:
print("Не найдено отелей в Чукотском автономном округе с эмбеддингами.")
return
print(f"\n📊 Найдено {len(chukotka_hotels)} отелей для гибридного аудита:")
for hotel in chukotka_hotels:
print(f"{hotel['full_name']}")
print()
all_audit_results = []
for hotel_info in chukotka_hotels:
audit_results = audit_hotel_hybrid(hotel_info)
all_audit_results.append(audit_results)
# Создание Excel отчета
df_data = []
for hotel_result in all_audit_results:
row = {
'ID Отеля': hotel_result['hotel_id'],
'Название Отеля': hotel_result['hotel_name'],
'Регион': hotel_result['region_name'],
'Общий балл': f"{hotel_result['total_score']:.2f}/{len(AUDIT_CRITERIA)}"
}
for criterion_name, crit_data in hotel_result['criteria_results'].items():
row[f'{criterion_name} (Итого)'] = f"{crit_data['final_score']:.2f}"
row[f'{criterion_name} (Семантика)'] = f"{crit_data['semantic_score']:.2f}"
row[f'{criterion_name} (Регулярки)'] = f"{crit_data['pattern_score']:.2f}"
row[f'{criterion_name} (NER)'] = f"{crit_data['ner_score']:.2f}"
row[f'{criterion_name} (Объяснение)'] = crit_data['explanation']
row[f'{criterion_name} (Доказательства)'] = "\n".join(crit_data['evidence'])
# ДОБАВЛЯЕМ URL И ЦИТАТЫ!
if crit_data.get('approval_urls'):
row[f'{criterion_name} (URL)'] = "\n".join(crit_data['approval_urls'])
else:
row[f'{criterion_name} (URL)'] = "Не найдено"
if crit_data.get('approval_quotes'):
quotes_text = []
for quote_data in crit_data['approval_quotes']:
quote_str = f"[{quote_data['method']}]\n"
quote_str += f"URL: {quote_data['url']}\n"
quote_str += f"Цитата: {quote_data['quote']}\n"
if 'matches' in quote_data:
quote_str += f"Найдено: {quote_data['matches']}\n"
if 'entities' in quote_data:
quote_str += f"Сущности: {quote_data['entities']}\n"
if 'distance' in quote_data:
quote_str += f"Distance: {quote_data['distance']}\n"
quotes_text.append(quote_str)
row[f'{criterion_name} (Цитаты)'] = "\n---\n".join(quotes_text)
else:
row[f'{criterion_name} (Цитаты)'] = "Не найдено"
df_data.append(row)
df = pd.DataFrame(df_data)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_filename = f"hybrid_audit_chukotka_{timestamp}.xlsx"
df.to_excel(output_filename, index=False)
print(f"\n✅ Гибридный отчет сохранен в {output_filename}")
if __name__ == "__main__":
main()

530
kamchatka_crawler.py Normal file
View 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())

256
llm_client.py Normal file
View File

@@ -0,0 +1,256 @@
"""
Универсальный клиент для работы с разными LLM провайдерами
"""
import requests
import os
from typing import List, Dict, Optional
from llm_config import (
ACTIVE_PROVIDER, CURRENT_CONFIG, CHAT_MODEL,
TEMPERATURE, MAX_TOKENS, OPENAI_CONFIG, OPENROUTER_CONFIG, OLLAMA_CONFIG
)
class LLMClient:
"""Универсальный клиент для OpenAI/OpenRouter/Ollama"""
def __init__(self):
# Читаем настройки из переменных окружения или используем дефолтные
self.provider = os.environ.get('ACTIVE_PROVIDER', ACTIVE_PROVIDER)
self.model = os.environ.get('LLM_MODEL', CHAT_MODEL)
self.temperature = TEMPERATURE
self.max_tokens = MAX_TOKENS
# Определяем конфиг на основе провайдера
if self.provider == 'openai':
self.config = OPENAI_CONFIG
elif self.provider == 'openrouter':
self.config = OPENROUTER_CONFIG
elif self.provider == 'ollama':
self.config = OLLAMA_CONFIG
else:
self.config = CURRENT_CONFIG
@property
def provider_config(self):
"""Получить конфиг текущего провайдера"""
return {
"provider": self.provider,
"model": self.model,
"api_base": self.config.get('api_base', ''),
"has_key": bool(self.config.get('api_key'))
}
def chat_completion(
self,
messages: List[Dict[str, str]],
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
model: Optional[str] = None
) -> Dict:
"""
Универсальный метод для chat completion
Args:
messages: Список сообщений [{'role': 'user', 'content': '...'}]
temperature: Креативность (0.0-1.0)
max_tokens: Макс токенов в ответе
model: Модель (опционально)
Returns:
{'text': str, 'usage': dict}
"""
temperature = temperature or self.temperature
max_tokens = max_tokens or self.max_tokens
model = model or self.model
if self.provider == 'openai' or self.provider == 'openrouter':
return self._openai_style_request(messages, temperature, max_tokens, model)
elif self.provider == 'ollama':
return self._ollama_request(messages, temperature, max_tokens, model)
else:
raise ValueError(f"Unknown provider: {self.provider}")
def _openai_style_request(
self,
messages: List[Dict],
temperature: float,
max_tokens: int,
model: str
) -> Dict:
"""Запрос к OpenAI или OpenRouter"""
url = f"{self.config['api_base']}/chat/completions"
headers = {
"Authorization": f"Bearer {self.config['api_key']}",
"Content-Type": "application/json"
}
# OpenRouter требует дополнительные заголовки
if self.provider == 'openrouter':
headers["HTTP-Referer"] = "https://hotel-audit.ru"
headers["X-Title"] = "Hotel Audit System"
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens
}
# Настройка прокси
proxies = None
if self.config.get('proxy'):
proxies = {
'http': self.config['proxy'],
'https': self.config['proxy']
}
try:
response = requests.post(
url,
headers=headers,
json=payload,
proxies=proxies,
timeout=60
)
if response.status_code == 200:
data = response.json()
return {
'text': data['choices'][0]['message']['content'],
'usage': data.get('usage', {}),
'model': data.get('model', model)
}
else:
error_msg = f"API Error {response.status_code}: {response.text}"
return {
'text': f"Ошибка API: {response.status_code}",
'error': error_msg
}
except Exception as e:
return {
'text': f"Ошибка соединения: {str(e)}",
'error': str(e)
}
def _ollama_request(
self,
messages: List[Dict],
temperature: float,
max_tokens: int,
model: str
) -> Dict:
"""Запрос к локальной Ollama"""
url = f"{self.config['api_base']}/api/chat"
# Конвертируем формат сообщений
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens
}
}
try:
response = requests.post(url, json=payload, timeout=120)
if response.status_code == 200:
data = response.json()
return {
'text': data['message']['content'],
'usage': {
'prompt_tokens': data.get('prompt_eval_count', 0),
'completion_tokens': data.get('eval_count', 0)
},
'model': model
}
else:
return {
'text': f"Ошибка Ollama: {response.status_code}",
'error': response.text
}
except requests.exceptions.ConnectionError:
return {
'text': "Ошибка: Ollama не запущена. Запустите: ollama serve",
'error': 'Connection refused'
}
except Exception as e:
return {
'text': f"Ошибка Ollama: {str(e)}",
'error': str(e)
}
def simple_chat(self, prompt: str, system: Optional[str] = None) -> str:
"""
Простой метод для быстрого чата
Args:
prompt: Вопрос пользователя
system: Системный промпт (опционально)
Returns:
Ответ модели (только текст)
"""
messages = []
if system:
messages.append({'role': 'system', 'content': system})
messages.append({'role': 'user', 'content': prompt})
result = self.chat_completion(messages)
return result['text']
def get_info(self) -> Dict:
"""Информация о текущей конфигурации"""
return {
'provider': self.provider,
'model': self.model,
'temperature': self.temperature,
'max_tokens': self.max_tokens,
'api_base': self.config['api_base']
}
# Глобальный экземпляр (singleton pattern)
llm = LLMClient()
# ==================== ТЕСТЫ ====================
if __name__ == "__main__":
print("=" * 70)
print("🤖 ТЕСТ LLM КЛИЕНТА")
print("=" * 70)
info = llm.get_info()
print(f"\n📊 Конфигурация:")
print(f" Провайдер: {info['provider']}")
print(f" Модель: {info['model']}")
print(f" Temperature: {info['temperature']}")
print(f" Max tokens: {info['max_tokens']}")
print(f" API: {info['api_base']}")
print(f"\n💬 Тестовый запрос...")
response = llm.simple_chat(
prompt="Сколько будет 2+2? Ответь одним числом.",
system="Ты математический ассистент."
)
print(f" Ответ: {response}")
print("\n" + "=" * 70)
print("✅ Клиент работает!")
print("=" * 70)

191
llm_config.py Normal file
View File

@@ -0,0 +1,191 @@
"""
Конфигурация LLM провайдеров
Поддержка: OpenAI, OpenRouter, Ollama
"""
import os
from typing import Dict, Optional
# ==================== ПРОВАЙДЕРЫ ====================
# OpenAI (через прокси)
OPENAI_CONFIG = {
'provider': 'openai',
'api_key': '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',
'api_base': 'https://api.openai.com/v1',
'proxy': 'http://195.133.66.13:3128',
# Доступные модели
'models': {
'fast': 'gpt-4o-mini', # Быстрая и дешёвая
'smart': 'gpt-4o', # Умная, дорогая
'chat': 'gpt-4o-mini', # Для чата
'analysis': 'gpt-4o-mini', # Для анализа
'turbo': 'gpt-4-turbo', # Средняя по скорости/качеству
'vision': 'gpt-4o',
'big': 'gpt-4.1-mini', # Для анализа изображений
'embedding': 'text-embedding-3-small' # Для эмбеддингов
},
# Параметры по умолчанию
'default_params': {
'temperature': 0.3,
'max_tokens': 800,
'top_p': 1.0
}
}
# OpenRouter (альтернатива с кучей моделей)
OPENROUTER_CONFIG = {
'provider': 'openrouter',
'api_key': os.getenv('OPENROUTER_API_KEY', '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'),
'api_base': 'https://openrouter.ai/api/v1',
'proxy': 'http://195.133.66.13:3128',
'models': {
'fast': 'anthropic/claude-3-haiku',
'smart': 'anthropic/claude-3.5-sonnet',
'chat': 'anthropic/claude-3-haiku',
'analysis': 'anthropic/claude-3.5-sonnet',
'cheap': 'google/gemini-flash-1.5'
},
'default_params': {
'temperature': 0.3,
'max_tokens': 1000
}
}
# Ollama (локальная модель)
OLLAMA_CONFIG = {
'provider': 'ollama',
'api_base': 'http://localhost:11434',
'proxy': None, # Локальная - прокси не нужен
'models': {
'fast': 'llama3.2',
'smart': 'llama3.1:70b',
'chat': 'llama3.2',
'analysis': 'qwen2.5:14b',
'russian': 'saiga_llama3' # Для русского языка
},
'default_params': {
'temperature': 0.3,
'num_predict': 800
}
}
# ==================== АКТИВНЫЙ ПРОВАЙДЕР ====================
# Выбираем провайдера (меняйте здесь!)
ACTIVE_PROVIDER = os.getenv('LLM_PROVIDER', 'openai') # 'openai', 'openrouter', 'ollama'
# Выбираем конфигурацию
if ACTIVE_PROVIDER == 'openai':
CURRENT_CONFIG = OPENAI_CONFIG
elif ACTIVE_PROVIDER == 'openrouter':
CURRENT_CONFIG = OPENROUTER_CONFIG
elif ACTIVE_PROVIDER == 'ollama':
CURRENT_CONFIG = OLLAMA_CONFIG
else:
raise ValueError(f"Unknown provider: {ACTIVE_PROVIDER}")
# ==================== НАСТРОЙКИ МОДЕЛЕЙ ====================
# Модель для чата (меняйте здесь для экспериментов!)
CHAT_MODEL = os.getenv('CHAT_MODEL', CURRENT_CONFIG['models']['chat'])
# Модель для анализа/аудита
ANALYSIS_MODEL = os.getenv('ANALYSIS_MODEL', CURRENT_CONFIG['models']['analysis'])
# Модель для эмбеддингов
EMBEDDING_MODEL = CURRENT_CONFIG['models'].get('embedding', 'text-embedding-3-small')
# ==================== ПАРАМЕТРЫ ====================
# Температура (0.0 = детерминированно, 1.0 = креативно)
TEMPERATURE = float(os.getenv('LLM_TEMPERATURE', CURRENT_CONFIG['default_params']['temperature']))
# Максимум токенов в ответе
MAX_TOKENS = int(os.getenv('LLM_MAX_TOKENS', CURRENT_CONFIG['default_params']['max_tokens']))
# ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ====================
def get_model_info() -> Dict:
"""Получить информацию о текущей модели"""
return {
'provider': ACTIVE_PROVIDER,
'chat_model': CHAT_MODEL,
'analysis_model': ANALYSIS_MODEL,
'embedding_model': EMBEDDING_MODEL,
'temperature': TEMPERATURE,
'max_tokens': MAX_TOKENS,
'api_base': CURRENT_CONFIG['api_base'],
'uses_proxy': CURRENT_CONFIG.get('proxy') is not None
}
def get_available_models() -> Dict:
"""Получить список доступных моделей"""
return CURRENT_CONFIG['models']
def switch_model(model_type: str, model_name: str):
"""Переключить модель (для runtime изменений)"""
global CHAT_MODEL, ANALYSIS_MODEL
if model_type == 'chat':
CHAT_MODEL = model_name
elif model_type == 'analysis':
ANALYSIS_MODEL = model_name
else:
raise ValueError(f"Unknown model type: {model_type}")
# ==================== КАК ИСПОЛЬЗОВАТЬ ====================
if __name__ == "__main__":
import json
print("=" * 70)
print("🤖 КОНФИГУРАЦИЯ LLM МОДЕЛЕЙ")
print("=" * 70)
info = get_model_info()
print(f"\n📊 ТЕКУЩИЕ НАСТРОЙКИ:")
print(f" Провайдер: {info['provider'].upper()}")
print(f" Чат: {info['chat_model']}")
print(f" Анализ: {info['analysis_model']}")
print(f" Эмбеддинги: {info['embedding_model']}")
print(f" Temperature: {info['temperature']}")
print(f" Max tokens: {info['max_tokens']}")
print(f" API: {info['api_base']}")
print(f" Прокси: {'✅ Да' if info['uses_proxy'] else '❌ Нет'}")
print(f"\n🎯 ДОСТУПНЫЕ МОДЕЛИ ({info['provider'].upper()}):")
for key, model in get_available_models().items():
print(f" {key:15}{model}")
print(f"\n💡 КАК ИЗМЕНИТЬ:")
print(f" 1. В коде: измените ACTIVE_PROVIDER = 'openai'/'openrouter'/'ollama'")
print(f" 2. Через ENV: export LLM_PROVIDER=ollama")
print(f" 3. Модель чата: export CHAT_MODEL=gpt-4o")
print(f" 4. Temperature: export LLM_TEMPERATURE=0.7")
print(f"\n📋 ПРИМЕРЫ:")
print(f" # OpenAI GPT-4o-mini (по умолчанию)")
print(f" export LLM_PROVIDER=openai")
print(f" export CHAT_MODEL=gpt-4o-mini")
print(f"\n # OpenRouter с Claude")
print(f" export LLM_PROVIDER=openrouter")
print(f" export CHAT_MODEL=anthropic/claude-3-haiku")
print(f"\n # Локальная Ollama")
print(f" export LLM_PROVIDER=ollama")
print(f" export CHAT_MODEL=llama3.2")
print("=" * 70)

319
mass_crawler.py Executable file
View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
Массовый краулинг всех отелей в фоновом режиме
Обрабатывает все отели с сайтами, которые ещё не скраулены
"""
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 = 20 # Максимум страниц с одного сайта
PAGE_TIMEOUT = 20000 # Уменьшено с 30 до 20 секунд
BATCH_SIZE = 50 # Обрабатывать пачками по 50 отелей
MAX_CONCURRENT = 5 # Увеличено с 3 до 5 браузеров одновременно
# Логирование
log_filename = f'mass_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:
"""Простая очистка HTML"""
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)
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} ({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}
)
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) # Ждём JS
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)
logger.info(f" ✅ Страница {len(pages_data)}: {len(cleaned_text)} символов")
except Exception as e:
logger.warning(f" ⚠️ Ошибка страницы {link}: {e}")
continue
else:
logger.warning(f" ⚠️ Главная страница недоступна: {response.status if response else 'No response'}")
except Exception as e:
logger.error(f" ❌ Ошибка загрузки главной: {e}")
await context.close()
# Сохраняем в БД
if pages_data:
save_to_db(hotel_id, hotel_name, region, website, pages_data)
logger.info(f" 💾 Сохранено {len(pages_data)} страниц для {hotel_name}")
return True
else:
logger.warning(f" ⚠️ Нет данных для {hotel_name}")
return False
except Exception as e:
logger.error(f" ❌ Критическая ошибка для {hotel_name}: {e}")
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()
# Сохраняем метаданные
from urllib.parse import urlparse
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 ON CONSTRAINT hotel_website_raw_hotel_id_url_key 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}")
def get_unprocessed_hotels(limit: int = None) -> List[Dict]:
"""Получить необработанные отели"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
query = """
SELECT id, full_name, region_name, website_address
FROM hotel_main
WHERE website_address IS NOT NULL
AND website_address != ''
AND id NOT IN (SELECT hotel_id FROM hotel_website_meta)
ORDER BY region_name, full_name
"""
if limit:
query += f" LIMIT {limit}"
cur.execute(query)
hotels = []
for row in cur.fetchall():
hotels.append({
'id': row[0],
'full_name': row[1],
'region_name': row[2],
'website_address': row[3]
})
cur.close()
conn.close()
return hotels
async def main():
"""Главная функция"""
logger.info("🚀 Запуск массового краулинга")
# Получаем необработанные отели
hotels = get_unprocessed_hotels()
total = len(hotels)
logger.info(f"📊 Найдено необработанных отелей: {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}%)")
logger.info(f" Ошибок: {processed - success}")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("\n⚠️ Прервано пользователем")
sys.exit(0)

203
memory_agent.py Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Интеграция с MCP сервером памяти агента
"""
import requests
import json
from typing import Dict, List, Optional
import uuid
from datetime import datetime
class MemoryAgent:
"""Клиент для работы с MCP сервером памяти агента"""
def __init__(self, base_url: str = "http://185.197.75.249:9000"):
self.base_url = base_url
self.sse_url = f"{base_url}/sse"
def get_user_id(self, request) -> str:
"""Получить ID пользователя из IP адреса или других данных"""
# Получаем IP адрес
client_ip = request.headers.get('X-Forwarded-For',
request.headers.get('X-Real-IP',
request.client.host))
# Если IP через прокси, берем первый
if ',' in client_ip:
client_ip = client_ip.split(',')[0].strip()
# Создаем стабильный user_id на основе IP
user_id = f"user_{client_ip.replace('.', '_')}"
return user_id
def add_memory(self, user_id: str, content: str, source: str = "chat",
metadata: Optional[Dict] = None) -> Dict:
"""Добавить память в агента через MCP"""
payload = {
"name": f"Chat with {user_id}",
"episode_body": content,
"group_id": user_id, # Используем user_id как group_id
"source": source,
"source_description": f"Chat conversation with user {user_id}",
"metadata": metadata or {}
}
try:
# Используем правильный MCP эндпоинт
response = requests.post(
f"{self.base_url}/mcp_memory_add_memory",
json=payload,
timeout=30
)
if response.status_code == 200:
return {
"status": "success",
"data": response.json()
}
else:
return {
"status": "error",
"error": f"HTTP {response.status_code}: {response.text}"
}
except Exception as e:
return {
"status": "error",
"error": str(e)
}
def search_memory(self, user_id: str, query: str, max_results: int = 10) -> Dict:
"""Поиск в памяти агента через MCP"""
payload = {
"query": query,
"group_ids": [user_id],
"max_facts": max_results
}
try:
response = requests.post(
f"{self.base_url}/mcp_memory_search_memory_facts",
json=payload,
timeout=30
)
if response.status_code == 200:
return {
"status": "success",
"data": response.json()
}
else:
return {
"status": "error",
"error": f"HTTP {response.status_code}: {response.text}"
}
except Exception as e:
return {
"status": "error",
"error": str(e)
}
def get_user_history(self, user_id: str, last_n: int = 10) -> Dict:
"""Получить историю пользователя через MCP"""
payload = {
"group_id": user_id,
"last_n": last_n
}
try:
response = requests.post(
f"{self.base_url}/mcp_memory_get_episodes",
json=payload,
timeout=30
)
if response.status_code == 200:
return {
"status": "success",
"data": response.json()
}
else:
return {
"status": "error",
"error": f"HTTP {response.status_code}: {response.text}"
}
except Exception as e:
return {
"status": "error",
"error": str(e)
}
def test_connection(self) -> Dict:
"""Тест подключения к MCP серверу"""
try:
# Тестируем SSE эндпоинт
response = requests.get(f"{self.sse_url}", timeout=10)
if response.status_code == 200:
return {
"status": "success",
"message": "MCP сервер доступен"
}
else:
return {
"status": "error",
"error": f"HTTP {response.status_code}"
}
except Exception as e:
return {
"status": "error",
"error": f"Не удается подключиться к MCP серверу: {str(e)}"
}
# Глобальный экземпляр
memory_agent = MemoryAgent()
if __name__ == "__main__":
print("=" * 70)
print("🧠 ТЕСТ MCP СЕРВЕРА ПАМЯТИ АГЕНТА")
print("=" * 70)
# Тест подключения
print("🔗 Тестирую подключение...")
result = memory_agent.test_connection()
print(f"Результат: {result}")
if result["status"] == "success":
print("\n✅ MCP сервер доступен!")
# Тест добавления памяти
test_user = "user_192_168_1_100"
test_content = "Пользователь спрашивал про отели в Чукотке. Ответил что там 12 отелей, 4 с сайтами."
print(f"\n📝 Добавляю тестовую память для {test_user}...")
add_result = memory_agent.add_memory(test_user, test_content)
print(f"Результат: {add_result}")
if add_result["status"] == "success":
print("\n✅ Память добавлена!")
# Тест поиска
print(f"\n🔍 Ищу память по запросу 'отели чукотка'...")
search_result = memory_agent.search_memory(test_user, "отели чукотка")
print(f"Результат: {search_result}")
# Тест истории
print(f"\n📚 Получаю историю пользователя...")
history_result = memory_agent.get_user_history(test_user)
print(f"Результат: {history_result}")
else:
print(f"\n❌ Ошибка: {result['error']}")
print("=" * 70)

289
merge_audit_results.py Normal file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
Объединение результатов AI Agent и Regex в единую структуру
"""
import json
# Определяем 17 критериев
CRITERIA = [
{
"id": 1,
"name": "Юридическая идентификация и верификация",
"description": "ИНН, ОГРН, полное наименование организации"
},
{
"id": 2,
"name": "Адрес",
"description": "Юридический и фактический адрес, местонахождение"
},
{
"id": 3,
"name": "Контакты",
"description": "Телефон, email, форма обратной связи"
},
{
"id": 4,
"name": "Режим работы",
"description": "Часы работы, график приема, колл-центр"
},
{
"id": 5,
"name": "Политика ПДн (152-ФЗ)",
"description": "Политика персональных данных, обработка ПДн"
},
{
"id": 7,
"name": "Договор-оферта / Правила оказания услуг",
"description": "Публичная оферта, пользовательское соглашение, условия"
},
{
"id": 8,
"name": "Рекламации и споры",
"description": "Претензии, возврат, обмен, жалобы"
},
{
"id": 9,
"name": "Цены/прайс",
"description": "Цены, стоимость, тарифы"
},
{
"id": 10,
"name": "Способы оплаты",
"description": "Наличные, карта, СБП"
},
{
"id": 11,
"name": "Онлайн-оплата",
"description": "Эквайринг, оплата онлайн"
},
{
"id": 12,
"name": "Онлайн-бронирование",
"description": "Забронировать, booking"
},
{
"id": 13,
"name": "FAQ",
"description": "Частые вопросы, вопрос-ответ"
},
{
"id": 14,
"name": "Доступность для ЛОВЗ",
"description": "Инвалиды, безбарьерная среда, маломобильные"
},
{
"id": 15,
"name": "Партнёры/бренды",
"description": "Партнеры, поставщики, сотрудничество"
},
{
"id": 16,
"name": "Команда/сотрудники",
"description": "Команда, персонал, руководство"
},
{
"id": 17,
"name": "Уголок потребителя",
"description": "Права потребителей, защита"
},
{
"id": 18,
"name": "Актуальность документов",
"description": "Дата обновления, версия"
}
]
def merge_results(ai_results, regex_results):
"""
Объединяет результаты AI Agent и Regex
ai_results: список из 17 элементов с детальными ответами AI
regex_results: список из 17 элементов с простыми ДА/НЕТ от regex
"""
merged = []
for i, criterion in enumerate(CRITERIA):
# Берём результаты AI и Regex для этого критерия
ai_result = ai_results[i] if i < len(ai_results) else {}
regex_result = regex_results[i] if i < len(regex_results) else {}
# Извлекаем данные из AI результата
ai_output = ai_result.get('output', {})
ai_found = ai_output.get('found', False)
ai_score = ai_output.get('score', 0)
ai_quote = ai_output.get('quote', '')
ai_url = ai_output.get('url', '')
ai_details = ai_output.get('details', '')
ai_confidence = ai_output.get('confidence', 'Не определена')
ai_checked_pages = ai_output.get('checked_pages', 0)
# Извлекаем данные из Regex результата
regex_output = regex_result.get('output', {})
regex_found = regex_output.get('found', False)
regex_answer = regex_output.get('answer', 'НЕТ')
regex_extracted = regex_output.get('extracted', '')
regex_confidence = regex_output.get('confidence', 'Не определена')
# Объединяем результаты
merged_item = {
"criterion_id": criterion['id'],
"criterion_name": criterion['name'],
"criterion_description": criterion['description'],
# Общий результат (ДА если хотя бы один метод нашёл)
"found": ai_found or regex_found,
"status": "НАЙДЕНО" if (ai_found or regex_found) else "НЕ НАЙДЕНО",
# Оценка (0-1)
"score": max(ai_score, 1 if regex_found else 0),
# AI Agent результаты
"ai_agent": {
"found": ai_found,
"score": ai_score,
"quote": ai_quote,
"url": ai_url,
"details": ai_details,
"confidence": ai_confidence,
"checked_pages": ai_checked_pages
},
# Regex результаты
"regex": {
"found": regex_found,
"answer": regex_answer,
"extracted": regex_extracted,
"confidence": regex_confidence
},
# Итоговая уверенность
"final_confidence": _calculate_final_confidence(ai_confidence, regex_confidence, ai_found, regex_found)
}
merged.append(merged_item)
return merged
def _calculate_final_confidence(ai_conf, regex_conf, ai_found, regex_found):
"""Рассчитывает итоговую уверенность"""
# Если оба нашли - высокая
if ai_found and regex_found:
return "Очень высокая"
# Если один нашёл с высокой уверенностью
if (ai_found and ai_conf == "Высокая") or (regex_found and regex_conf == "Высокая"):
return "Высокая"
# Если один нашёл со средней уверенностью
if (ai_found and ai_conf == "Средняя") or (regex_found and regex_conf == "Средняя"):
return "Средняя"
# Если оба не нашли с высокой уверенностью - точно нет
if not ai_found and not regex_found and ai_conf == "Высокая" and regex_conf == "Высокая":
return "Высокая (не найдено)"
# Иначе - низкая
return "Низкая"
def format_summary(merged_results):
"""Форматирует краткую сводку"""
total = len(merged_results)
found_count = sum(1 for r in merged_results if r['found'])
not_found_count = total - found_count
summary = {
"hotel_name": "Городской отель \"Комфорт\"",
"region": "Камчатский край",
"audit_date": "2025-10-14",
"total_criteria": total,
"found": found_count,
"not_found": not_found_count,
"compliance_percentage": round(found_count / total * 100, 1),
"criteria_results": merged_results
}
return summary
def print_summary(summary):
"""Выводит красивую сводку"""
print(f"\n{'='*80}")
print(f"📊 СВОДКА АУДИТА: {summary['hotel_name']}")
print(f"{'='*80}")
print(f"\n📍 Регион: {summary['region']}")
print(f"📅 Дата аудита: {summary['audit_date']}")
print(f"\n✅ Найдено: {summary['found']}/{summary['total_criteria']} ({summary['compliance_percentage']}%)")
print(f"Не найдено: {summary['not_found']}/{summary['total_criteria']}")
print(f"\n{'='*80}")
print("ДЕТАЛЬНЫЕ РЕЗУЛЬТАТЫ:")
print(f"{'='*80}\n")
for result in summary['criteria_results']:
status_icon = "" if result['found'] else ""
print(f"{status_icon} [{result['criterion_id']}] {result['criterion_name']}")
print(f" Статус: {result['status']}")
print(f" Оценка: {result['score']}")
print(f" Уверенность: {result['final_confidence']}")
if result['ai_agent']['found']:
print(f" 🤖 AI Agent: найдено")
if result['ai_agent']['url']:
print(f" URL: {result['ai_agent']['url']}")
if result['ai_agent']['details']:
print(f" Детали: {result['ai_agent']['details'][:100]}...")
if result['regex']['found']:
print(f" 🔍 Regex: найдено")
if result['regex']['extracted']:
print(f" Извлечено: {result['regex']['extracted'][:100]}...")
print()
# Пример использования
if __name__ == "__main__":
# Загружаем данные из файла или используем переданные
import sys
if len(sys.argv) > 1:
# Загружаем из файла
with open(sys.argv[1], 'r', encoding='utf-8') as f:
data = json.load(f)
else:
# Используем тестовые данные
print("⚠️ Использование: python merge_audit_results.py <файл_с_результатами.json>")
print(" Или передайте данные через stdin")
sys.exit(1)
# Разделяем на AI и Regex результаты
# Первые 17 - от AI Agent, последние 17 - от Regex
ai_results = data[:17]
regex_results = data[17:34]
# Объединяем
merged = merge_results(ai_results, regex_results)
# Формируем сводку
summary = format_summary(merged)
# Выводим
print_summary(summary)
# Сохраняем в файл
output_file = "audit_merged_results.json"
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print(f"\n💾 Результаты сохранены в {output_file}")

277
model_providers.py Normal file
View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
API для получения списка моделей от разных провайдеров
"""
import requests
import json
from typing import Dict, List, Optional
from llm_config import OPENAI_CONFIG, OPENROUTER_CONFIG, OLLAMA_CONFIG
class ModelProvider:
"""Базовый класс для провайдеров моделей"""
def __init__(self, config: Dict):
self.config = config
self.provider = config['provider']
self.api_base = config['api_base']
self.api_key = config.get('api_key')
self.proxy = config.get('proxy')
def get_models(self) -> List[Dict]:
"""Получить список доступных моделей"""
raise NotImplementedError
def _make_request(self, endpoint: str, method: str = 'GET', data: Optional[Dict] = None) -> Optional[Dict]:
"""Универсальный запрос к API"""
url = f"{self.api_base}/{endpoint}"
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
if self.provider == 'openrouter':
headers["HTTP-Referer"] = "https://hotel-audit.ru"
headers["X-Title"] = "Hotel Audit System"
headers["Content-Type"] = "application/json"
proxies = None
if self.proxy:
proxies = {'http': self.proxy, 'https': self.proxy}
try:
if method == 'GET':
response = requests.get(url, headers=headers, proxies=proxies, timeout=30)
elif method == 'POST':
response = requests.post(url, headers=headers, json=data, proxies=proxies, timeout=30)
if response.status_code == 200:
return response.json()
else:
print(f"Ошибка {self.provider}: {response.status_code} - {response.text}")
return None
except Exception as e:
print(f"Ошибка соединения {self.provider}: {e}")
return None
class OpenAIProvider(ModelProvider):
"""Провайдер OpenAI"""
def get_models(self) -> List[Dict]:
"""Получить модели OpenAI"""
data = self._make_request("models")
if not data:
return []
models = []
for model in data.get('data', []):
model_id = model['id']
# Фильтруем только чат-модели
if any(x in model_id.lower() for x in ['gpt-3.5', 'gpt-4', 'o1']):
models.append({
'id': model_id,
'name': model_id,
'provider': 'openai',
'description': self._get_model_description(model_id),
'context_length': model.get('context_length', 0),
'pricing': self._get_pricing(model_id)
})
return sorted(models, key=lambda x: x['name'])
def _get_model_description(self, model_id: str) -> str:
"""Описание модели"""
descriptions = {
'gpt-4o-mini': 'Быстрая и дешёвая модель',
'gpt-4o': 'Самая умная модель OpenAI',
'gpt-4-turbo': 'Баланс скорости и качества',
'gpt-4': 'Классическая GPT-4',
'gpt-3.5-turbo': 'Проверенная временем модель',
'o1-preview': 'Новая рассуждающая модель',
'o1-mini': 'Компактная рассуждающая модель'
}
for key, desc in descriptions.items():
if key in model_id.lower():
return desc
return 'Модель OpenAI'
def _get_pricing(self, model_id: str) -> Dict:
"""Примерная стоимость"""
pricing = {
'gpt-4o-mini': {'input': 0.15, 'output': 0.60},
'gpt-4o': {'input': 5.00, 'output': 15.00},
'gpt-4-turbo': {'input': 10.00, 'output': 30.00},
'gpt-3.5-turbo': {'input': 0.50, 'output': 1.50},
'o1-preview': {'input': 15.00, 'output': 60.00},
'o1-mini': {'input': 3.00, 'output': 12.00},
'gpt-4': {'input': 30.00, 'output': 60.00},
'gpt-3.5': {'input': 0.50, 'output': 1.50}
}
# Ищем точное совпадение или частичное
for key, price in pricing.items():
if key in model_id.lower():
return price
# Если не найдено, возвращаем базовую цену
return {'input': 0.50, 'output': 1.50}
class OpenRouterProvider(ModelProvider):
"""Провайдер OpenRouter"""
def get_models(self) -> List[Dict]:
"""Получить модели OpenRouter"""
data = self._make_request("models")
if not data:
return []
models = []
for model in data.get('data', []):
model_id = model['id']
# Фильтруем популярные модели
if any(x in model_id.lower() for x in ['claude', 'gemini', 'llama', 'mistral', 'qwen']):
models.append({
'id': model_id,
'name': model_id,
'provider': 'openrouter',
'description': self._get_model_description(model_id),
'context_length': model.get('context_length', 0),
'pricing': self._get_pricing(model_id)
})
return sorted(models, key=lambda x: x['name'])
def _get_model_description(self, model_id: str) -> str:
"""Описание модели"""
if 'claude' in model_id.lower():
if 'haiku' in model_id.lower():
return 'Claude 3 Haiku - быстрая модель Anthropic'
elif 'sonnet' in model_id.lower():
return 'Claude 3.5 Sonnet - умная модель Anthropic'
elif 'opus' in model_id.lower():
return 'Claude 3 Opus - самая мощная модель Anthropic'
else:
return 'Claude модель от Anthropic'
elif 'gemini' in model_id.lower():
return 'Gemini модель от Google'
elif 'llama' in model_id.lower():
return 'Llama модель от Meta'
elif 'mistral' in model_id.lower():
return 'Mistral модель'
elif 'qwen' in model_id.lower():
return 'Qwen модель от Alibaba'
return 'Модель через OpenRouter'
def _get_pricing(self, model_id: str) -> Dict:
"""Примерная стоимость"""
pricing = {
'claude-3-haiku': {'input': 0.25, 'output': 1.25},
'claude-3.5-sonnet': {'input': 3.00, 'output': 15.00},
'claude-3-opus': {'input': 15.00, 'output': 75.00},
'gemini-flash': {'input': 0.075, 'output': 0.30},
'gemini-pro': {'input': 0.50, 'output': 1.50},
'llama3.1': {'input': 0.20, 'output': 0.20},
'mistral': {'input': 0.25, 'output': 0.25}
}
for key, price in pricing.items():
if key in model_id.lower():
return price
return {'input': 0, 'output': 0}
class OllamaProvider(ModelProvider):
"""Провайдер Ollama (локальный)"""
def get_models(self) -> List[Dict]:
"""Получить модели Ollama"""
data = self._make_request("api/tags")
if not data:
return []
models = []
for model in data.get('models', []):
model_id = model['name']
models.append({
'id': model_id,
'name': model_id,
'provider': 'ollama',
'description': self._get_model_description(model_id),
'context_length': model.get('size', 0),
'pricing': {'input': 0, 'output': 0} # Бесплатно
})
return sorted(models, key=lambda x: x['name'])
def _get_model_description(self, model_id: str) -> str:
"""Описание модели"""
if 'llama' in model_id.lower():
return 'Llama модель (локальная)'
elif 'mistral' in model_id.lower():
return 'Mistral модель (локальная)'
elif 'qwen' in model_id.lower():
return 'Qwen модель (локальная)'
elif 'codellama' in model_id.lower():
return 'Code Llama для программирования'
else:
return 'Локальная модель Ollama'
def get_all_models() -> Dict[str, List[Dict]]:
"""Получить все модели от всех провайдеров"""
providers = {
'openai': OpenAIProvider(OPENAI_CONFIG),
'openrouter': OpenRouterProvider(OPENROUTER_CONFIG),
'ollama': OllamaProvider(OLLAMA_CONFIG)
}
all_models = {}
for provider_name, provider in providers.items():
print(f"Загружаю модели {provider_name}...")
try:
models = provider.get_models()
all_models[provider_name] = models
print(f"{provider_name}: {len(models)} моделей")
except Exception as e:
print(f"{provider_name}: ошибка - {e}")
all_models[provider_name] = []
return all_models
if __name__ == "__main__":
print("=" * 70)
print("🤖 ЗАГРУЗКА МОДЕЛЕЙ ОТ ПРОВАЙДЕРОВ")
print("=" * 70)
all_models = get_all_models()
print(f"\n📊 ИТОГО:")
for provider, models in all_models.items():
print(f" {provider.upper()}: {len(models)} моделей")
print(f"\n🎯 ПРИМЕРЫ МОДЕЛЕЙ:")
for provider, models in all_models.items():
if models:
print(f"\n{provider.upper()}:")
for model in models[:3]: # Показываем первые 3
print(f" - {model['name']}: {model['description']}")
print("=" * 70)

90
monitor_db_recovery.py Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
Мониторинг восстановления базы данных
"""
from urllib.parse import unquote
import psycopg2
import time
from datetime import datetime
DB_CONFIG = {
'host': "147.45.189.234",
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS"),
'connect_timeout': 5
}
def check_db():
"""Проверить состояние БД"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Проверим базовые таблицы
cur.execute('SELECT COUNT(*) FROM hotel_main')
hotels = cur.fetchone()[0]
cur.execute('SELECT COUNT(*) FROM hotel_main WHERE region_name = %s', ('Чукотский автономный округ',))
chukotka = cur.fetchone()[0]
cur.execute('''
SELECT
COUNT(DISTINCT h.id) as total,
COUNT(DISTINCT p.hotel_id) as processed
FROM hotel_main h
LEFT JOIN hotel_website_processed p ON p.hotel_id = h.id
WHERE h.region_name = 'г. Санкт-Петербург'
AND h.website_address IS NOT NULL
AND h.website_address != ''
AND h.website_address != 'Нет'
''')
spb_total, spb_processed = cur.fetchone()
cur.close()
conn.close()
return {
'status': 'OK',
'hotels_total': hotels,
'chukotka_hotels': chukotka,
'spb_total': spb_total,
'spb_processed': spb_processed,
'spb_percent': spb_processed/spb_total*100 if spb_total > 0 else 0
}
except Exception as e:
return {'status': 'ERROR', 'error': str(e)}
def main():
"""Мониторинг восстановления"""
print("🔍 МОНИТОРИНГ ВОССТАНОВЛЕНИЯ БД")
print("=" * 50)
start_time = datetime.now()
while True:
current_time = datetime.now()
elapsed = (current_time - start_time).total_seconds()
result = check_db()
if result['status'] == 'OK':
print(f"🎉 БД ВОССТАНОВЛЕНА! Время восстановления: {elapsed/60:.1f} мин")
print(f"📊 Статистика:")
print(f" Всего отелей: {result['hotels_total']}")
print(f" Чукотка: {result['chukotka_hotels']} отелей")
print(f" СПб: {result['spb_processed']}/{result['spb_total']} ({result['spb_percent']:.1f}%)")
break
else:
print(f"[{current_time.strftime('%H:%M:%S')}] ❌ БД недоступна: {result['error']}")
time.sleep(30) # Проверяем каждые 30 секунд
if __name__ == "__main__":
main()

68
natasha_curl_example.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# 🎯 Примеры cURL для Natasha NER API
# Для импорта в n8n HTTP Request Node
# ═══════════════════════════════════════════════════════════════════════════
# 1. ПРОВЕРКА ЗДОРОВЬЯ API
# ═══════════════════════════════════════════════════════════════════════════
curl -X GET 'http://185.197.75.249:8004/health' \
-H 'Accept: application/json'
# ═══════════════════════════════════════════════════════════════════════════
# 2. ИЗВЛЕЧЕНИЕ СУЩНОСТЕЙ (УПРОЩЁННЫЙ ФОРМАТ)
# ═══════════════════════════════════════════════════════════════════════════
curl -X POST 'http://185.197.75.249:8004/extract_simple' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"text": "ИП Фролов С.А. находится по адресу г. Петропавловск-Камчатский, ул. Пограничная 39/1. Директор Иван Петров.",
"max_length": 5000
}'
# ═══════════════════════════════════════════════════════════════════════════
# 3. ИЗВЛЕЧЕНИЕ СУЩНОСТЕЙ (ПОЛНЫЙ ФОРМАТ)
# ═══════════════════════════════════════════════════════════════════════════
curl -X POST 'http://185.197.75.249:8004/extract' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"text": "Муниципальное предприятие «Чаунское районное коммунальное хозяйство». ИНН: 8707003759, ОГРН: 1028700516476. Юридический адрес: 689400, г. Певек, ул. Пугачева, 42",
"max_length": 5000
}'
# ═══════════════════════════════════════════════════════════════════════════
# 4. ПРИМЕР ДЛЯ n8n (с динамическими данными)
# ═══════════════════════════════════════════════════════════════════════════
# В n8n используй:
# URL: http://147.45.146.17:8004/extract_simple
# Method: POST
# Body (JSON):
# {
# "text": "{{ $json.quote }}",
# "max_length": 5000
# }
# ═══════════════════════════════════════════════════════════════════════════
# 5. ТЕСТ С РЕАЛЬНЫМ ТЕКСТОМ ИЗ БД
# ═══════════════════════════════════════════════════════════════════════════
curl -X POST 'http://185.197.75.249:8004/extract_simple' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{
"text": "Россия, 683032, г. Петропавловск-Камчатский, Пограничная 39/1 +7 (4152) 42-20-25. Copyright © 2018 - 2025 ИП Фролов С.А.",
"max_length": 5000
}'
# ═══════════════════════════════════════════════════════════════════════════
# ОЖИДАЕМЫЙ ОТВЕТ:
# ═══════════════════════════════════════════════════════════════════════════
# {
# "organizations": ["ИП"],
# "persons": ["Фролов С.А."],
# "locations": ["Россия", "Петропавловск-Камчатский", "Пограничная"],
# "has_organizations": true,
# "has_persons": true,
# "has_locations": true,
# "total": 4
# }

241
natasha_ner_api.py Normal file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
FastAPI сервис для Natasha NER (Named Entity Recognition)
Извлекает организации, адреса, имена из текста
Для использования в n8n через HTTP Request
"""
from fastapi import FastAPI, HTTPException, Header, Depends
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from typing import List, Optional
import uvicorn
import os
# Natasha для NER
from natasha import (
Segmenter,
MorphVocab,
NewsEmbedding,
NewsMorphTagger,
NewsSyntaxParser,
NewsNERTagger,
Doc
)
app = FastAPI(
title="Natasha NER API",
description="Извлечение сущностей из русского текста",
version="1.0.0"
)
# 🔐 API KEY для защиты доступа
API_KEY = "CH2BAYBYGYDDSWpaEd_CvJrH04DoVSGtZi_mah2nXbw"
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def verify_api_key(api_key: str = Depends(api_key_header)) -> bool:
"""Проверка API ключа"""
if api_key is None or api_key != API_KEY:
raise HTTPException(
status_code=401,
detail="Неверный или отсутствующий API ключ. Используйте заголовок X-API-Key"
)
return True
# Инициализация Natasha при старте
print("🔧 Инициализация Natasha...")
print(f"🔐 API защищён ключом: {API_KEY[:10]}...")
segmenter = Segmenter()
morph_vocab = MorphVocab()
emb = NewsEmbedding()
morph_tagger = NewsMorphTagger(emb)
syntax_parser = NewsSyntaxParser(emb)
ner_tagger = NewsNERTagger(emb)
print("✅ Natasha готова!")
class NERRequest(BaseModel):
text: str
max_length: int = 5000 # Ограничение длины текста для производительности
class Entity(BaseModel):
type: str # ORG, PER, LOC
text: str
start: int
end: int
class NERResponse(BaseModel):
organizations: List[str] # ORG - организации
persons: List[str] # PER - люди
locations: List[str] # LOC - локации/адреса
entities: List[Entity] # Все сущности с позициями
total_entities: int
@app.get("/")
async def root():
"""Информация о сервисе"""
return {
"service": "Natasha NER API",
"version": "1.1.0",
"description": "Извлечение сущностей из русского текста",
"security": "Требуется API ключ в заголовке X-API-Key",
"endpoints": {
"/extract": "POST - извлечь сущности из текста (требует API ключ)",
"/extract_simple": "POST - упрощённое извлечение (требует API ключ)",
"/health": "GET - проверка здоровья сервиса (без ключа)"
}
}
@app.get("/health")
async def health():
"""Проверка здоровья сервиса"""
return {
"status": "healthy",
"natasha": "ready"
}
@app.post("/extract", response_model=NERResponse)
async def extract_entities(request: NERRequest, authenticated: bool = Depends(verify_api_key)):
"""
Извлечение сущностей из текста (требует API ключ)
Возвращает:
- organizations: список названий организаций
- persons: список имён людей
- locations: список локаций/адресов
- entities: все сущности с позициями
"""
try:
# Ограничиваем длину текста для производительности
text = request.text[:request.max_length]
# Обработка текста Natasha
doc = Doc(text)
doc.segment(segmenter)
doc.tag_morph(morph_tagger)
doc.parse_syntax(syntax_parser)
doc.tag_ner(ner_tagger)
# Извлекаем сущности
organizations = []
persons = []
locations = []
entities = []
for span in doc.spans:
entity = Entity(
type=span.type,
text=span.text,
start=span.start,
end=span.stop
)
entities.append(entity)
if span.type == 'ORG':
organizations.append(span.text)
elif span.type == 'PER':
persons.append(span.text)
elif span.type == 'LOC':
locations.append(span.text)
return NERResponse(
organizations=list(set(organizations)), # Уникальные
persons=list(set(persons)),
locations=list(set(locations)),
entities=entities,
total_entities=len(entities)
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка NER: {str(e)}")
@app.post("/extract_simple")
async def extract_simple(request: NERRequest, authenticated: bool = Depends(verify_api_key)):
"""
Упрощённое извлечение - только списки сущностей
Для удобного использования в n8n (требует API ключ)
С умной фильтрацией ложноположительных результатов
"""
try:
text = request.text[:request.max_length]
doc = Doc(text)
doc.segment(segmenter)
doc.tag_morph(morph_tagger)
doc.parse_syntax(syntax_parser)
doc.tag_ner(ner_tagger)
organizations = []
persons = []
locations = []
# Паттерны для фильтрации
org_keywords = ['ип', 'ооо', 'оао', 'зао', 'ао', 'пао', 'нао', 'ндо', 'гуп', 'муп', 'фгуп', 'гбу', 'мбу']
ignore_org_patterns = [
r'^\d+', # Начинается с цифр (адреса)
r'\+\d', # Содержит телефон
r'^[А-Яа-я]{1,2}\s', # Короткие слова (предлоги)
]
ignore_loc_words = ['нужен', 'нужна', 'нужно', 'требуется']
for span in doc.spans:
entity_text = span.text.strip()
entity_lower = entity_text.lower()
if span.type == 'ORG':
# Проверяем, что это действительно организация
is_valid_org = False
# Проверка 1: содержит ключевые слова юрлиц
if any(keyword in entity_lower for keyword in org_keywords):
is_valid_org = True
# Проверка 2: не содержит паттерны адресов/телефонов
import re
has_ignore_pattern = any(re.search(pattern, entity_text) for pattern in ignore_org_patterns)
if is_valid_org and not has_ignore_pattern:
organizations.append(entity_text)
elif span.type == 'PER':
persons.append(entity_text)
elif span.type == 'LOC':
# Фильтруем мусорные "локации"
if entity_lower not in ignore_loc_words and len(entity_text) > 2:
locations.append(entity_text)
# Уникальные значения
organizations = list(set(organizations))
persons = list(set(persons))
locations = list(set(locations))
return {
"organizations": organizations,
"persons": persons,
"locations": locations,
"has_organizations": len(organizations) > 0,
"has_persons": len(persons) > 0,
"has_locations": len(locations) > 0,
"total": len(organizations) + len(persons) + len(locations)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка NER: {str(e)}")
if __name__ == "__main__":
print("🚀 Запуск Natasha NER API на порту 8004...")
uvicorn.run(app, host="0.0.0.0", port=8004)

View File

@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
Скрипт для обработки всех отелей:
1. Создание chunks из hotel_website_processed
2. Генерация эмбеддингов через BGE-M3 API
3. Сохранение в hotel_website_chunks с metadata
"""
import psycopg2
from urllib.parse import unquote
import requests
import json
import time
import logging
from typing import List, Dict, Tuple
import uuid
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('embeddings_processing.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Конфигурация
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
CHUNK_SIZE = 600
CHUNK_OVERLAP = 100
BATCH_SIZE = 8 # Размер батча для API (уменьшен из-за перегрузки)
MAX_RETRIES = 3 # Количество попыток при ошибке
class EmbeddingProcessor:
def __init__(self):
self.conn = None
self.cur = None
self.connect_db()
def connect_db(self):
"""Подключение к базе данных"""
try:
self.conn = psycopg2.connect(
host='147.45.189.234',
port=5432,
database='default_db',
user='gen_user',
password=unquote('2~~9_%5EkVsU%3F2%5CS')
)
self.conn.autocommit = True
self.cur = self.conn.cursor()
logger.info("✅ Подключение к БД установлено")
except Exception as e:
logger.error(f"❌ Ошибка подключения к БД: {e}")
raise
def get_hotel_info(self, hotel_id: str) -> Dict:
"""Получение информации об отеле из hotel_main"""
try:
self.cur.execute("""
SELECT id, full_name, region_name
FROM hotel_main
WHERE id = %s;
""", (hotel_id,))
result = self.cur.fetchone()
if result:
return {
'hotel_id': result[0],
'hotel_name': result[1],
'region_name': result[2]
}
return None
except Exception as e:
logger.error(f"❌ Ошибка получения информации об отеле {hotel_id}: {e}")
return None
def create_chunks_from_text(self, text: str, hotel_id: str, url: str, raw_page_id: int) -> List[Dict]:
"""Создание chunks из текста"""
if not text or len(text.strip()) < 50:
return []
chunks = []
start = 0
while start < len(text):
end = start + CHUNK_SIZE
chunk_text = text[start:end]
if end < len(text):
# Ищем хорошую точку разрыва (конец предложения)
last_period = chunk_text.rfind('.')
last_newline = chunk_text.rfind('\n')
break_point = max(last_period, last_newline)
if break_point > start + CHUNK_SIZE // 2:
chunk_text = text[start:start + break_point + 1]
end = start + break_point + 1
# Создаём metadata для chunk
chunk_metadata = {
'hotel_id': hotel_id,
'url': url,
'raw_page_id': raw_page_id,
'chunk_start': start,
'chunk_end': end,
'chunk_length': len(chunk_text)
}
chunks.append({
'id': str(uuid.uuid4()),
'text': chunk_text.strip(),
'metadata': chunk_metadata
})
# Следующий chunk с перекрытием
start = end - CHUNK_OVERLAP
if start >= len(text):
break
return chunks
def generate_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
"""Генерация эмбеддингов батчем через API с retry логикой"""
for attempt in range(MAX_RETRIES):
try:
headers = {
"X-API-Key": BGE_API_KEY,
"Content-Type": "application/json"
}
payload = {
"text": texts
}
# Увеличиваем таймаут для больших батчей
timeout = 120 if len(texts) > 20 else 60
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=timeout)
if response.status_code == 200:
result = response.json()
embeddings = result.get('embeddings', [])
if len(embeddings) == len(texts):
return embeddings
else:
logger.warning(f"⚠️ Неполный ответ API: {len(embeddings)}/{len(texts)} эмбеддингов")
if attempt < MAX_RETRIES - 1:
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
time.sleep(5) # Пауза перед повтором
continue
else:
logger.error(f"❌ Ошибка API: {response.status_code} - {response.text}")
if attempt < MAX_RETRIES - 1:
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
time.sleep(10) # Пауза перед повтором
continue
except requests.exceptions.Timeout:
logger.warning(f"⚠️ Таймаут API (попытка {attempt + 1}/{MAX_RETRIES})")
if attempt < MAX_RETRIES - 1:
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
time.sleep(10) # Пауза перед повтором
continue
except Exception as e:
logger.error(f"❌ Ошибка генерации эмбеддингов (попытка {attempt + 1}/{MAX_RETRIES}): {e}")
if attempt < MAX_RETRIES - 1:
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
time.sleep(5) # Пауза перед повтором
continue
logger.error(f"Не удалось получить эмбеддинги после {MAX_RETRIES} попыток")
return []
def save_chunks_to_db(self, chunks: List[Dict], hotel_info: Dict):
"""Сохранение chunks в базу данных"""
try:
# Разбиваем chunks на батчи для API
all_embeddings = []
for i in range(0, len(chunks), BATCH_SIZE):
batch_chunks = chunks[i:i + BATCH_SIZE]
batch_texts = [chunk['text'] for chunk in batch_chunks]
logger.info(f" 🔄 Обрабатываем батч {i//BATCH_SIZE + 1}: {len(batch_texts)} chunks")
# Генерируем эмбеддинги для батча
batch_embeddings = self.generate_embeddings_batch(batch_texts)
if len(batch_embeddings) == len(batch_texts):
all_embeddings.extend(batch_embeddings)
logger.info(f" ✅ Батч успешно обработан")
else:
logger.error(f" ❌ Ошибка в батче: {len(batch_embeddings)}/{len(batch_texts)} эмбеддингов")
return False
# Небольшая пауза между батчами
if i + BATCH_SIZE < len(chunks):
time.sleep(1)
if len(all_embeddings) != len(chunks):
logger.error(f"❌ Количество эмбеддингов ({len(all_embeddings)}) не совпадает с количеством chunks ({len(chunks)})")
return False
# Обновляем metadata с информацией об отеле и сохраняем в БД
for i, chunk in enumerate(chunks):
chunk['metadata']['hotel_name'] = hotel_info['hotel_name']
chunk['metadata']['region_name'] = hotel_info['region_name']
# Сохраняем в БД
embedding_str = json.dumps(all_embeddings[i])
self.cur.execute("""
INSERT INTO hotel_website_chunks (id, text, metadata, embedding)
VALUES (%s, %s, %s, %s::vector)
ON CONFLICT (id) DO UPDATE SET
text = EXCLUDED.text,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding;
""", (
chunk['id'],
chunk['text'],
json.dumps(chunk['metadata']),
embedding_str
))
logger.info(f"✅ Сохранено {len(chunks)} chunks для отеля {hotel_info['hotel_name'][:50]}...")
return True
except Exception as e:
logger.error(f"❌ Ошибка сохранения chunks: {e}")
return False
def process_hotel(self, hotel_id: str) -> bool:
"""Обработка одного отеля"""
try:
# Получаем информацию об отеле
hotel_info = self.get_hotel_info(hotel_id)
if not hotel_info:
logger.warning(f"⚠️ Отель {hotel_id} не найден в hotel_main")
return False
# Получаем страницы отеля
self.cur.execute("""
SELECT id, url, cleaned_text
FROM hotel_website_processed
WHERE hotel_id = %s
AND cleaned_text IS NOT NULL
AND LENGTH(cleaned_text) > 50
ORDER BY id;
""", (hotel_id,))
pages = self.cur.fetchall()
logger.info(f"🏨 Обрабатываем отель: {hotel_info['hotel_name'][:50]}...")
logger.info(f" 📄 Найдено {len(pages)} страниц")
total_chunks = 0
for page_id, url, text in pages:
# Создаём chunks
chunks = self.create_chunks_from_text(text, hotel_id, url, page_id)
if chunks:
# Сохраняем chunks
if self.save_chunks_to_db(chunks, hotel_info):
total_chunks += len(chunks)
logger.info(f" ✅ Страница {page_id}: {len(chunks)} chunks")
else:
logger.error(f" ❌ Ошибка сохранения chunks для страницы {page_id}")
logger.info(f"🎉 Отель {hotel_info['hotel_name'][:50]}... обработан: {total_chunks} chunks")
return True
except Exception as e:
logger.error(f"❌ Ошибка обработки отеля {hotel_id}: {e}")
return False
def get_hotels_to_process(self) -> List[str]:
"""Получение списка отелей для обработки"""
try:
# Получаем отели, которые есть в hotel_website_processed, но нет в chunks
self.cur.execute("""
SELECT DISTINCT p.hotel_id
FROM hotel_website_processed p
LEFT JOIN hotel_website_chunks c ON p.hotel_id::text = c.metadata->>'hotel_id'
WHERE p.cleaned_text IS NOT NULL
AND LENGTH(p.cleaned_text) > 50
AND c.id IS NULL
ORDER BY p.hotel_id;
""")
hotels = [row[0] for row in self.cur.fetchall()]
logger.info(f"📊 Найдено {len(hotels)} отелей для обработки")
return hotels
except Exception as e:
logger.error(f"❌ Ошибка получения списка отелей: {e}")
return []
def get_processing_stats(self) -> Dict:
"""Получение статистики обработки"""
try:
self.cur.execute("""
SELECT
COUNT(DISTINCT p.hotel_id) as total_hotels,
COUNT(p.id) as total_pages,
COUNT(DISTINCT c.metadata->>'hotel_id') as processed_hotels,
COUNT(c.id) as total_chunks
FROM hotel_website_processed p
LEFT JOIN hotel_website_chunks c ON p.hotel_id::text = c.metadata->>'hotel_id'
WHERE p.cleaned_text IS NOT NULL AND LENGTH(p.cleaned_text) > 50;
""")
result = self.cur.fetchone()
return {
'total_hotels': result[0],
'total_pages': result[1],
'processed_hotels': result[2],
'total_chunks': result[3]
}
except Exception as e:
logger.error(f"❌ Ошибка получения статистики: {e}")
return {}
def close(self):
"""Закрытие соединения с БД"""
if self.cur:
self.cur.close()
if self.conn:
self.conn.close()
def main():
"""Основная функция"""
logger.info("🚀 Запуск обработки отелей для эмбеддингов")
processor = EmbeddingProcessor()
try:
# Получаем статистику
stats = processor.get_processing_stats()
logger.info(f"📊 Текущая статистика:")
logger.info(f" Всего отелей: {stats.get('total_hotels', 0)}")
logger.info(f" Всего страниц: {stats.get('total_pages', 0)}")
logger.info(f" Обработано отелей: {stats.get('processed_hotels', 0)}")
logger.info(f" Всего chunks: {stats.get('total_chunks', 0)}")
# Получаем отели для обработки
hotels_to_process = processor.get_hotels_to_process()
if not hotels_to_process:
logger.info("Все отели уже обработаны!")
return
# Обрабатываем отели
successful = 0
failed = 0
for i, hotel_id in enumerate(hotels_to_process, 1):
logger.info(f"\n🔄 Обрабатываем отель {i}/{len(hotels_to_process)}: {hotel_id}")
start_time = time.time()
if processor.process_hotel(hotel_id):
successful += 1
processing_time = time.time() - start_time
logger.info(f"✅ Успешно за {processing_time:.2f} сек")
else:
failed += 1
logger.error(f"❌ Ошибка обработки")
# Показываем прогресс каждые 10 отелей
if i % 10 == 0:
logger.info(f"\n📈 Прогресс: {i}/{len(hotels_to_process)} отелей")
logger.info(f" ✅ Успешно: {successful}")
logger.info(f" ❌ Ошибок: {failed}")
# Финальная статистика
final_stats = processor.get_processing_stats()
logger.info(f"\n🎉 ОБРАБОТКА ЗАВЕРШЕНА!")
logger.info(f"📊 Финальная статистика:")
logger.info(f" Обработано отелей: {final_stats.get('processed_hotels', 0)}")
logger.info(f" Всего chunks: {final_stats.get('total_chunks', 0)}")
logger.info(f" ✅ Успешно: {successful}")
logger.info(f" ❌ Ошибок: {failed}")
except Exception as e:
logger.error(f"❌ Критическая ошибка: {e}")
finally:
processor.close()
if __name__ == "__main__":
main()

130
process_all_regions.py Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
Массовая векторизация всех регионов без chunks
Обрабатывает по приоритету: сначала маленькие, потом крупные
"""
import sys
sys.path.insert(0, '/root/engine/public_oversight/hotels')
from process_all_hotels_embeddings import EmbeddingProcessor
import logging
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('all_regions_embeddings.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def main():
logger.info("🚀 Массовая векторизация всех регионов")
processor = EmbeddingProcessor()
try:
# Получаем ВСЕ отели без chunks по регионам
processor.cur.execute("""
WITH processed_stats AS (
SELECT
m.region_name,
COUNT(DISTINCT p.hotel_id) as processed_hotels
FROM hotel_website_processed p
INNER JOIN hotel_main m ON p.hotel_id = m.id
GROUP BY m.region_name
),
chunks_stats AS (
SELECT
metadata->>'region_name' as region_name,
COUNT(DISTINCT metadata->>'hotel_id') as chunked_hotels
FROM hotel_website_chunks
WHERE metadata->>'region_name' IS NOT NULL
GROUP BY metadata->>'region_name'
)
SELECT
p.hotel_id,
m.full_name,
m.region_name
FROM hotel_website_processed p
INNER JOIN hotel_main m ON p.hotel_id = m.id
LEFT JOIN hotel_website_chunks c ON p.hotel_id::text = c.metadata->>'hotel_id'
WHERE p.cleaned_text IS NOT NULL
AND LENGTH(p.cleaned_text) > 50
AND c.id IS NULL
ORDER BY
-- Сначала маленькие регионы
(SELECT COUNT(*) FROM hotel_website_processed p2
INNER JOIN hotel_main m2 ON p2.hotel_id = m2.id
WHERE m2.region_name = m.region_name) ASC,
m.region_name,
m.full_name
""")
hotels = processor.cur.fetchall()
logger.info(f"📊 Найдено отелей без chunks: {len(hotels)}")
if not hotels:
logger.info("Все отели уже обработаны!")
return
# Группируем по регионам для статистики
regions_count = {}
for _, _, region in hotels:
regions_count[region] = regions_count.get(region, 0) + 1
logger.info(f"\n📍 Регионов к обработке: {len(regions_count)}")
for region, count in sorted(regions_count.items(), key=lambda x: x[1]):
logger.info(f"{region}: {count} отелей")
# Обрабатываем
successful = 0
failed = 0
current_region = None
region_count = 0
for i, (hotel_id, hotel_name, region) in enumerate(hotels, 1):
# Логируем смену региона
if region != current_region:
if current_region:
logger.info(f"\n✅ Регион '{current_region}' завершён: {region_count} отелей")
current_region = region
region_count = 0
logger.info(f"\n{'='*80}")
logger.info(f"📍 Начинаю регион: {region}")
logger.info(f"{'='*80}")
region_count += 1
logger.info(f"\n[{i}/{len(hotels)}] 🏨 {hotel_name[:50]}")
logger.info(f" Регион: {region}")
if processor.process_hotel(hotel_id):
successful += 1
else:
failed += 1
# Показываем прогресс каждые 10 отелей
if i % 10 == 0:
logger.info(f"\n📈 ОБЩИЙ ПРОГРЕСС: {i}/{len(hotels)} отелей")
logger.info(f" ✅ Успешно: {successful}")
logger.info(f" ❌ Ошибок: {failed}")
logger.info(f" 📊 Success rate: {successful*100/i:.1f}%")
logger.info(f"\n🎉 ВСЯ ОБРАБОТКА ЗАВЕРШЕНА!")
logger.info(f" ✅ Успешно: {successful}")
logger.info(f" ❌ Ошибок: {failed}")
logger.info(f" 📊 Итого обработано: {successful + failed}")
except Exception as e:
logger.error(f"❌ Критическая ошибка: {e}")
finally:
processor.close()
if __name__ == "__main__":
main()

76
process_kamchatka_only.py Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Векторизация ТОЛЬКО Камчатского края (осталось 7 отелей)
"""
import sys
sys.path.insert(0, '/root/engine/public_oversight/hotels')
from process_all_hotels_embeddings import EmbeddingProcessor
import logging
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('kamchatka_embeddings.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def main():
logger.info("🚀 Векторизация Камчатского края")
processor = EmbeddingProcessor()
try:
# Получаем отели Камчатки без chunks
processor.cur.execute("""
SELECT DISTINCT p.hotel_id, m.full_name
FROM hotel_website_processed p
INNER JOIN hotel_main m ON p.hotel_id = m.id
LEFT JOIN hotel_website_chunks c ON p.hotel_id::text = c.metadata->>'hotel_id'
WHERE m.region_name = 'Камчатский край'
AND p.cleaned_text IS NOT NULL
AND LENGTH(p.cleaned_text) > 50
AND c.id IS NULL
ORDER BY m.full_name
""")
hotels = processor.cur.fetchall()
logger.info(f"📊 Найдено отелей Камчатки без chunks: {len(hotels)}")
if not hotels:
logger.info("Все отели Камчатки уже обработаны!")
return
# Обрабатываем
successful = 0
failed = 0
for i, (hotel_id, hotel_name) in enumerate(hotels, 1):
logger.info(f"\n[{i}/{len(hotels)}] 🏨 {hotel_name}")
logger.info(f" ID: {hotel_id}")
if processor.process_hotel(hotel_id):
successful += 1
logger.info(f" ✅ Успешно")
else:
failed += 1
logger.error(f" ❌ Ошибка")
logger.info(f"\n🎉 ЗАВЕРШЕНО!")
logger.info(f" ✅ Успешно: {successful}")
logger.info(f" ❌ Ошибок: {failed}")
except Exception as e:
logger.error(f"❌ Критическая ошибка: {e}")
finally:
processor.close()
if __name__ == "__main__":
main()

237
process_orel_embeddings.py Normal file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
Обработка chunks и embeddings только для Орловской области
"""
import psycopg2
from urllib.parse import unquote
import requests
import json
import time
import logging
from typing import List, Dict, Tuple
import uuid
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('orel_embeddings.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Конфигурация
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
CHUNK_SIZE = 600
CHUNK_OVERLAP = 100
BATCH_SIZE = 8
MAX_RETRIES = 3
class EmbeddingProcessor:
def __init__(self):
self.conn = None
self.cur = None
self.connect_db()
def connect_db(self):
"""Подключение к базе данных"""
try:
self.conn = psycopg2.connect(
host='147.45.189.234',
port=5432,
database='default_db',
user='gen_user',
password=unquote('2~~9_%5EkVsU%3F2%5CS')
)
self.cur = self.conn.cursor()
logger.info("✅ Подключение к БД установлено")
except Exception as e:
logger.error(f"❌ Ошибка подключения к БД: {e}")
raise
def create_chunks(self, text: str) -> List[str]:
"""Создание chunks из текста"""
if not text or len(text.strip()) < 50:
return []
chunks = []
start = 0
while start < len(text):
end = start + CHUNK_SIZE
if end >= len(text):
chunks.append(text[start:].strip())
break
# Ищем ближайший пробел или перенос строки
while end > start and text[end] not in [' ', '\n', '\t']:
end -= 1
if end == start: # Если не нашли пробел, берем по символам
end = start + CHUNK_SIZE
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start = end - CHUNK_OVERLAP
return chunks
def get_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
"""Получение эмбеддингов для батча текстов"""
for attempt in range(MAX_RETRIES):
try:
response = requests.post(
BGE_API_URL,
headers={
'Authorization': f'Bearer {BGE_API_KEY}',
'Content-Type': 'application/json'
},
json={'text': texts},
timeout=30
)
if response.status_code == 200:
result = response.json()
return result.get('embeddings', [])
else:
logger.warning(f"⚠️ API вернул статус {response.status_code}: {response.text}")
except Exception as e:
logger.warning(f"⚠️ Попытка {attempt + 1} неудачна: {e}")
if attempt < MAX_RETRIES - 1:
time.sleep(2 ** attempt) # Экспоненциальная задержка
logger.error(f"Не удалось получить эмбеддинги для батча из {len(texts)} текстов")
return []
def process_hotel(self, hotel_id: str) -> bool:
"""Обработка одного отеля"""
try:
# Получаем HTML для отеля
self.cur.execute("""
SELECT html FROM hotel_website_raw
WHERE hotel_id = %s
""", (hotel_id,))
result = self.cur.fetchone()
if not result or not result[0]:
logger.warning(f"⚠️ Нет HTML для отеля {hotel_id}")
return False
html = result[0]
# Очищаем HTML до текста
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
for script in soup(["script", "style"]):
script.decompose()
text = soup.get_text()
# Создаем chunks
chunks = self.create_chunks(text)
if not chunks:
logger.warning(f"⚠️ Нет chunks для отеля {hotel_id}")
return False
logger.info(f"📄 Создано {len(chunks)} chunks")
# Удаляем старые chunks
self.cur.execute("DELETE FROM hotel_website_chunks WHERE metadata->>'hotel_id' = %s", (hotel_id,))
# Обрабатываем chunks батчами
total_chunks = 0
for i in range(0, len(chunks), BATCH_SIZE):
batch = chunks[i:i + BATCH_SIZE]
logger.info(f"🔄 Обрабатываем батч {i//BATCH_SIZE + 1}: {len(batch)} chunks")
embeddings = self.get_embeddings_batch(batch)
if not embeddings:
logger.error(f"Не удалось получить эмбеддинги для батча")
continue
# Сохраняем chunks с эмбеддингами
for j, (chunk, embedding) in enumerate(zip(batch, embeddings)):
chunk_id = str(uuid.uuid4())
metadata = {
'hotel_id': hotel_id,
'chunk_index': i + j,
'created_at': time.time()
}
self.cur.execute("""
INSERT INTO hotel_website_chunks (id, text, metadata, embedding)
VALUES (%s, %s, %s, %s::vector)
""", (chunk_id, chunk, json.dumps(metadata), json.dumps(embedding)))
total_chunks += 1
logger.info(f"✅ Батч успешно обработан")
self.conn.commit()
logger.info(f"✅ Сохранено {total_chunks} chunks для отеля")
return True
except Exception as e:
logger.error(f"❌ Ошибка обработки отеля {hotel_id}: {e}")
self.conn.rollback()
return False
def process_orel_region(self):
"""Обработка всех отелей Орловской области"""
try:
# Получаем отели Орловской области с HTML но без chunks
self.cur.execute("""
SELECT DISTINCT h.id, h.full_name
FROM hotel_main h
INNER JOIN hotel_website_raw hwr ON h.id = hwr.hotel_id
LEFT JOIN hotel_website_chunks hc ON h.id::text = hc.metadata->>'hotel_id'
WHERE h.region_name = 'Орловская область'
AND hwr.html IS NOT NULL
AND hc.id IS NULL
ORDER BY h.full_name
""")
hotels = self.cur.fetchall()
logger.info(f"📊 Найдено {len(hotels)} отелей для обработки")
if not hotels:
logger.info("Все отели Орловской области уже обработаны!")
return
for i, (hotel_id, hotel_name) in enumerate(hotels, 1):
logger.info(f"🔄 Обрабатываем отель {i}/{len(hotels)}: {hotel_name}")
success = self.process_hotel(hotel_id)
if success:
logger.info(f"✅ Отель {hotel_name} обработан успешно")
else:
logger.error(f"❌ Ошибка обработки отеля {hotel_name}")
except Exception as e:
logger.error(f"❌ Ошибка обработки региона: {e}")
def close(self):
"""Закрытие соединения с БД"""
if self.cur:
self.cur.close()
if self.conn:
self.conn.close()
def main():
logger.info("🚀 Запуск обработки Орловской области")
processor = EmbeddingProcessor()
try:
processor.process_orel_region()
logger.info("✅ Обработка завершена!")
finally:
processor.close()
if __name__ == "__main__":
main()

76
process_orel_only.py Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Векторизация ТОЛЬКО Орловской области (осталось 5 отелей)
"""
import sys
sys.path.insert(0, '/root/engine/public_oversight/hotels')
from process_all_hotels_embeddings import EmbeddingProcessor
import logging
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('orel_embeddings.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def main():
logger.info("🚀 Векторизация Орловской области")
processor = EmbeddingProcessor()
try:
# Получаем отели Орла без chunks
processor.cur.execute("""
SELECT DISTINCT p.hotel_id, m.full_name
FROM hotel_website_processed p
INNER JOIN hotel_main m ON p.hotel_id = m.id
LEFT JOIN hotel_website_chunks c ON p.hotel_id::text = c.metadata->>'hotel_id'
WHERE m.region_name = 'Орловская область'
AND p.cleaned_text IS NOT NULL
AND LENGTH(p.cleaned_text) > 50
AND c.id IS NULL
ORDER BY m.full_name
""")
hotels = processor.cur.fetchall()
logger.info(f"📊 Найдено отелей Орла без chunks: {len(hotels)}")
if not hotels:
logger.info("Все отели Орловской области уже обработаны!")
return
# Обрабатываем
successful = 0
failed = 0
for i, (hotel_id, hotel_name) in enumerate(hotels, 1):
logger.info(f"\n[{i}/{len(hotels)}] 🏨 {hotel_name}")
logger.info(f" ID: {hotel_id}")
if processor.process_hotel(hotel_id):
successful += 1
logger.info(f" ✅ Успешно")
else:
failed += 1
logger.error(f" ❌ Ошибка")
logger.info(f"\n🎉 ЗАВЕРШЕНО!")
logger.info(f" ✅ Успешно: {successful}")
logger.info(f" ❌ Ошибок: {failed}")
except Exception as e:
logger.error(f"❌ Критическая ошибка: {e}")
finally:
processor.close()
if __name__ == "__main__":
main()

200
process_raw_to_cleaned.py Normal file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Обработка сырого HTML в очищенный текст для аудита
Из hotel_website_raw → hotel_website_processed
"""
import psycopg2
from psycopg2.extras import RealDictCursor, Json
from urllib.parse import unquote
from datetime import datetime
import logging
import re
from bs4 import BeautifulSoup
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'process_raw_{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")
}
class HTMLProcessor:
"""Обработка HTML в чистый текст"""
@staticmethod
def clean_html(html: str) -> str:
"""Очистка HTML"""
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)
@staticmethod
def extract_structured_data(text: str) -> dict:
"""Извлечение структурированных данных"""
data = {
'phones': [],
'emails': [],
'inns': [],
'ogrn': [],
'addresses': [],
'prices': []
}
# Телефоны
phone_patterns = [
r'\+?[78][\s\-\(\)]?\d{3}[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{2}[\s\-\(\)]?\d{2}',
r'8[\s\-]?800[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}'
]
for pattern in phone_patterns:
data['phones'].extend(re.findall(pattern, text))
# Email
data['emails'] = re.findall(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text)
# ИНН
inns = re.findall(r'\b\d{10,12}\b', text)
data['inns'] = [inn for inn in inns if len(inn) in [10, 12]]
# ОГРН
ogrns = re.findall(r'\b\d{13,15}\b', text)
data['ogrn'] = [ogrn for ogrn in ogrns if len(ogrn) in [13, 15]]
# Цены (руб, ₽)
data['prices'] = re.findall(r'(\d[\d\s]*\d)\s*(?:руб|₽|рублей)', text)
# Удаляем дубликаты
for key in data:
data[key] = list(set(data[key]))[:10] # Максимум 10 значений
return data
def process_region(region_name: str):
"""Обработка региона"""
conn = psycopg2.connect(**DB_CONFIG)
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
# Получаем сырые данные для обработки
cur.execute('''
SELECT DISTINCT w.hotel_id, h.full_name
FROM hotel_website_raw w
JOIN hotel_main h ON h.id = w.hotel_id
WHERE h.region_name ILIKE %s
AND NOT EXISTS (
SELECT 1 FROM hotel_website_processed p
WHERE p.hotel_id = w.hotel_id
)
ORDER BY h.full_name
''', (f'%{region_name}%',))
hotels = cur.fetchall()
logger.info(f"\n{'='*70}")
logger.info(f"🔄 ОБРАБОТКА СЫРЫХ ДАННЫХ: {region_name}")
logger.info(f"📊 Отелей для обработки: {len(hotels)}")
logger.info(f"{'='*70}\n")
if len(hotels) == 0:
logger.info("Все данные уже обработаны!")
return
processed = 0
for i, hotel in enumerate(hotels, 1):
logger.info(f"[{i}/{len(hotels)}] {hotel['full_name']}")
# Получаем все страницы отеля
cur.execute('''
SELECT url, html, page_title
FROM hotel_website_raw
WHERE hotel_id = %s
ORDER BY depth, crawled_at
''', (hotel['hotel_id'],))
pages = cur.fetchall()
# Обрабатываем каждую страницу
for page in pages:
# Очищаем HTML
cleaned_text = HTMLProcessor.clean_html(page['html'])
# Извлекаем данные
extracted_data = HTMLProcessor.extract_structured_data(cleaned_text)
# Проверяем есть ли уже
cur.execute('''
SELECT id FROM hotel_website_processed
WHERE hotel_id = %s AND url = %s
''', (hotel['hotel_id'], page['url']))
if cur.fetchone():
continue # Уже обработано
# Сохраняем в processed
cur.execute('''
INSERT INTO hotel_website_processed
(hotel_id, url, cleaned_text, extracted_data, text_length, processed_at)
VALUES (%s, %s, %s, %s, %s, %s)
''', (
hotel['hotel_id'],
page['url'],
cleaned_text,
Json(extracted_data),
len(cleaned_text),
datetime.now()
))
conn.commit()
logger.info(f" ✓ Обработано {len(pages)} страниц")
processed += 1
logger.info(f"\n{'='*70}")
logger.info(f"✅ ГОТОВО! Обработано {processed} отелей")
logger.info(f"{'='*70}")
finally:
cur.close()
conn.close()
if __name__ == "__main__":
import sys
region = sys.argv[1] if len(sys.argv) > 1 else 'Камчатский край'
logger.info(f"📍 Регион: {region}")
process_region(region)

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Преобразование данных Санкт-Петербурга из hotel_website_raw в hotel_website_processed
Обрабатывает 807 отелей СПб, которые есть в raw, но нет в processed
"""
from urllib.parse import unquote
import psycopg2
from bs4 import BeautifulSoup
import re
import logging
from datetime import datetime
from typing import List, Dict
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
# Логирование
log_filename = f'process_spb_{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:
"""Очистка HTML от мусора"""
soup = BeautifulSoup(html, 'html.parser')
# Удаляем скрипты, стили и прочее
for tag in soup(['script', 'style', 'meta', 'link', 'noscript']):
tag.decompose()
text = soup.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text)
return text.strip()
def get_spb_raw_data():
"""Получить все raw данные СПб, которых нет в processed"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute('''
SELECT
r.id as raw_id,
r.hotel_id,
r.url,
r.html,
r.status_code,
r.crawled_at,
h.full_name
FROM hotel_website_raw r
JOIN hotel_main h ON h.id = r.hotel_id
LEFT JOIN hotel_website_processed p ON p.hotel_id = r.hotel_id AND p.url = r.url
WHERE h.region_name = 'г. Санкт-Петербург'
AND p.hotel_id IS NULL
ORDER BY r.hotel_id, r.url
''')
raw_data = cur.fetchall()
cur.close()
conn.close()
return raw_data
def process_batch(raw_data_batch: List):
"""Обработать пачку данных"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
processed_count = 0
batch_start_time = datetime.now()
for raw_id, hotel_id, url, html, status_code, crawled_at, hotel_name in raw_data_batch:
try:
# Очищаем HTML
cleaned_text = TextCleaner.clean_html(html)
text_length = len(cleaned_text)
# Вставляем в processed
cur.execute("""
INSERT INTO hotel_website_processed
(raw_page_id, hotel_id, url, cleaned_text, text_length, processed_at)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (hotel_id, url) DO UPDATE SET
cleaned_text = EXCLUDED.cleaned_text,
text_length = EXCLUDED.text_length,
processed_at = EXCLUDED.processed_at
""", (raw_id, hotel_id, url, cleaned_text, text_length))
processed_count += 1
if processed_count % 100 == 0:
logger.info(f" ✅ Обработано {processed_count} страниц...")
except Exception as e:
logger.error(f" ❌ Ошибка обработки {hotel_id} {url}: {e}")
continue
conn.commit()
cur.close()
conn.close()
batch_time = (datetime.now() - batch_start_time).total_seconds()
logger.info(f" ⏱️ Пачка обработана за {batch_time:.1f} сек")
return processed_count
def main():
"""Главная функция"""
logger.info("🚀 Начинаю обработку данных СПб из raw в processed")
# Получаем данные
raw_data = get_spb_raw_data()
total_pages = len(raw_data)
logger.info(f"📊 Найдено {total_pages} страниц для обработки")
if not raw_data:
logger.info("✅ Нет данных для обработки")
return
# Группируем по отелям для статистики
hotels = {}
for _, hotel_id, _, _, _, _, hotel_name in raw_data:
if hotel_id not in hotels:
hotels[hotel_id] = hotel_name
logger.info(f"🏨 Всего отелей: {len(hotels)}")
# Обрабатываем пачками по 50 (меньше памяти)
batch_size = 50
total_processed = 0
for i in range(0, total_pages, batch_size):
batch = raw_data[i:i + batch_size]
logger.info(f"📦 Обрабатываю пачку {i//batch_size + 1}: страницы {i+1}-{min(i+batch_size, total_pages)}")
processed_count = process_batch(batch)
total_processed += processed_count
logger.info(f" ✅ Пачка завершена: {processed_count} страниц")
logger.info(f"\n🎉 ОБРАБОТКА ЗАВЕРШЕНА!")
logger.info(f" Всего обработано: {total_processed}/{total_pages} страниц")
logger.info(f" Отелей: {len(hotels)}")
# Проверяем результат
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute('''
SELECT COUNT(DISTINCT p.hotel_id)
FROM hotel_website_processed p
JOIN hotel_main h ON h.id = p.hotel_id
WHERE h.region_name = 'г. Санкт-Петербург'
''')
processed_hotels = cur.fetchone()[0]
logger.info(f" 📊 Итого отелей СПб в processed: {processed_hotels}")
cur.close()
conn.close()
if __name__ == "__main__":
main()

187
process_spb_robust.py Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
Устойчивая версия обработки СПб с retry логикой
"""
from urllib.parse import unquote
import psycopg2
from bs4 import BeautifulSoup
import re
import logging
from datetime import datetime
import time
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS"),
'connect_timeout': 10,
'keepalives_idle': 600,
'keepalives_interval': 30,
'keepalives_count': 3
}
# Логирование
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'spb_robust_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def clean_html(html: str) -> str:
"""Очистка HTML"""
soup = BeautifulSoup(html, 'html.parser')
for tag in soup(['script', 'style', 'meta', 'link', 'noscript']):
tag.decompose()
text = soup.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text)
return text.strip()
def get_connection():
"""Получить соединение с retry"""
for attempt in range(3):
try:
conn = psycopg2.connect(**DB_CONFIG)
return conn
except Exception as e:
logger.warning(f"Попытка {attempt + 1} подключения: {e}")
if attempt < 2:
time.sleep(5)
else:
raise
def process_spb_robust():
"""Обрабатываем СПб с устойчивостью к сбоям"""
conn = None
total_processed = 0
batch_size = 50 # Уменьшили размер пачки
try:
while True:
try:
# Получаем новое соединение для каждой итерации
if conn:
conn.close()
conn = get_connection()
cur = conn.cursor()
# Получаем следующую порцию
cur.execute('''
SELECT
r.id as raw_id,
r.hotel_id,
r.url,
r.html
FROM hotel_website_raw r
JOIN hotel_main h ON h.id = r.hotel_id
LEFT JOIN hotel_website_processed p ON p.hotel_id = r.hotel_id AND p.url = r.url
WHERE h.region_name = 'г. Санкт-Петербург'
AND p.hotel_id IS NULL
ORDER BY r.id
LIMIT %s
''', (batch_size,))
batch = cur.fetchall()
if not batch:
logger.info("🎉 Все данные обработаны!")
break
logger.info(f"📦 Обрабатываю пачку: {len(batch)} страниц")
# Обрабатываем пачку
for raw_id, hotel_id, url, html in batch:
try:
cleaned_text = clean_html(html)
text_length = len(cleaned_text)
# Вставляем в processed
cur.execute("""
INSERT INTO hotel_website_processed
(raw_page_id, hotel_id, url, cleaned_text, text_length, processed_at)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (hotel_id, url) DO UPDATE SET
cleaned_text = EXCLUDED.cleaned_text,
text_length = EXCLUDED.text_length,
processed_at = EXCLUDED.processed_at
""", (raw_id, hotel_id, url, cleaned_text, text_length))
total_processed += 1
except Exception as e:
logger.error(f"❌ Ошибка обработки {hotel_id} {url}: {e}")
continue
# Коммитим пачку
conn.commit()
cur.close()
# Проверяем прогресс
cur = conn.cursor()
cur.execute('''
SELECT COUNT(*)
FROM hotel_website_raw r
JOIN hotel_main h ON h.id = r.hotel_id
LEFT JOIN hotel_website_processed p ON p.hotel_id = r.hotel_id AND p.url = r.url
WHERE h.region_name = 'г. Санкт-Петербург'
AND p.hotel_id IS NULL
''')
remaining = cur.fetchone()[0]
total_pages = total_processed + remaining
percent = total_processed / total_pages * 100 if total_pages > 0 else 0
logger.info(f"✅ Обработано: {total_processed}/{total_pages} ({percent:.1f}%)")
cur.close()
# Небольшая пауза между пачками
time.sleep(1)
except Exception as e:
logger.error(f"❌ Ошибка в цикле: {e}")
if conn:
try:
conn.close()
except:
pass
conn = None
time.sleep(10) # Пауза перед повтором
continue
# Финальная статистика
conn = get_connection()
cur = conn.cursor()
cur.execute('''
SELECT COUNT(DISTINCT p.hotel_id)
FROM hotel_website_processed p
JOIN hotel_main h ON h.id = p.hotel_id
WHERE h.region_name = 'г. Санкт-Петербург'
''')
processed_hotels = cur.fetchone()[0]
logger.info(f"🎉 ЗАВЕРШЕНО! Отелей СПб в processed: {processed_hotels}")
cur.close()
finally:
if conn:
conn.close()
if __name__ == "__main__":
process_spb_robust()

140
process_spb_simple.py Normal file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Простая версия: преобразование данных СПб порциями
"""
from urllib.parse import unquote
import psycopg2
from bs4 import BeautifulSoup
import re
import logging
from datetime import datetime
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
# Логирование
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'spb_simple_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def clean_html(html: str) -> str:
"""Очистка HTML"""
soup = BeautifulSoup(html, 'html.parser')
for tag in soup(['script', 'style', 'meta', 'link', 'noscript']):
tag.decompose()
text = soup.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text)
return text.strip()
def process_spb_in_batches():
"""Обрабатываем СПб порциями по 100 записей"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Сначала получим общее количество
cur.execute('''
SELECT COUNT(*)
FROM hotel_website_raw r
JOIN hotel_main h ON h.id = r.hotel_id
LEFT JOIN hotel_website_processed p ON p.hotel_id = r.hotel_id AND p.url = r.url
WHERE h.region_name = 'г. Санкт-Петербург'
AND p.hotel_id IS NULL
''')
total_count = cur.fetchone()[0]
logger.info(f"📊 Всего страниц для обработки: {total_count}")
if total_count == 0:
logger.info("✅ Нет данных для обработки")
return
processed = 0
batch_size = 100
while processed < total_count:
# Получаем следующую порцию
cur.execute('''
SELECT
r.id as raw_id,
r.hotel_id,
r.url,
r.html
FROM hotel_website_raw r
JOIN hotel_main h ON h.id = r.hotel_id
LEFT JOIN hotel_website_processed p ON p.hotel_id = r.hotel_id AND p.url = r.url
WHERE h.region_name = 'г. Санкт-Петербург'
AND p.hotel_id IS NULL
ORDER BY r.id
LIMIT %s
''', (batch_size,))
batch = cur.fetchall()
if not batch:
break
logger.info(f"📦 Обрабатываю пачку: {len(batch)} страниц")
# Обрабатываем пачку
for raw_id, hotel_id, url, html in batch:
try:
cleaned_text = clean_html(html)
text_length = len(cleaned_text)
# Вставляем в processed
cur.execute("""
INSERT INTO hotel_website_processed
(raw_page_id, hotel_id, url, cleaned_text, text_length, processed_at)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (hotel_id, url) DO UPDATE SET
cleaned_text = EXCLUDED.cleaned_text,
text_length = EXCLUDED.text_length,
processed_at = EXCLUDED.processed_at
""", (raw_id, hotel_id, url, cleaned_text, text_length))
processed += 1
except Exception as e:
logger.error(f"❌ Ошибка: {e}")
continue
# Коммитим пачку
conn.commit()
logger.info(f"✅ Обработано: {processed}/{total_count} ({processed/total_count*100:.1f}%)")
# Финальная статистика
cur.execute('''
SELECT COUNT(DISTINCT p.hotel_id)
FROM hotel_website_processed p
JOIN hotel_main h ON h.id = p.hotel_id
WHERE h.region_name = 'г. Санкт-Петербург'
''')
processed_hotels = cur.fetchone()[0]
logger.info(f"🎉 ЗАВЕРШЕНО! Отелей СПб в processed: {processed_hotels}")
cur.close()
conn.close()
if __name__ == "__main__":
process_spb_in_batches()

43
quick_check.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import subprocess
import os
os.chdir('/root/engine/public_oversight/hotels')
# Проверяем процессы
result = subprocess.run(['ps', 'aux'], capture_output=True, text=True, timeout=5)
print("🔍 ПРОЦЕССЫ КРАУЛЕРА:")
for line in result.stdout.split('\n'):
if 'mass_crawler.py' in line and 'grep' not in line:
parts = line.split()
print(f"\n✅ PID: {parts[1]}")
print(f" CPU: {parts[2]}%")
print(f" RAM: {parts[3]}%")
print(f" Команда: {' '.join(parts[10:])}")
# Проверяем последний лог
import glob
from datetime import datetime
logs = sorted(glob.glob('mass_crawler_*.log'), key=os.path.getmtime, reverse=True)
if logs:
latest = logs[0]
mtime = datetime.fromtimestamp(os.path.getmtime(latest)).strftime('%H:%M:%S')
size = os.path.getsize(latest)
print(f"\n📄 ПОСЛЕДНИЙ ЛОГ: {latest}")
print(f" Изменён: {mtime}")
print(f" Размер: {size:,} байт")
# Последние 10 строк
with open(latest, 'r') as f:
lines = f.readlines()
print(f"\n📋 ПОСЛЕДНИЕ СТРОКИ ({len(lines)} всего):")
for line in lines[-10:]:
if line.strip():
print(f" {line.rstrip()}")

240
recheck_unclear_rkn.py Normal file
View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
Перепроверка отелей с неясным результатом РКН
С улучшенным распознаванием разных форматов
"""
import asyncio
import psycopg2
from psycopg2.extras import RealDictCursor
from playwright.async_api import async_playwright
from urllib.parse import unquote
from datetime import datetime
import logging
import re
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'rkn_recheck_{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")
}
REQUEST_DELAY = 2
async def check_inn_improved(page, inn: str) -> dict:
"""Улучшенная проверка ИНН с разными форматами"""
try:
url = f'https://pd.rkn.gov.ru/operators-registry/operators-list/?act=search&inn={inn}'
logger.info(f" 🔍 Проверка ИНН: {inn}")
await asyncio.sleep(REQUEST_DELAY)
response = await 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)
# Получаем HTML и текст
html = await page.content()
text = await page.evaluate('() => document.body.innerText')
# Проверка 1: Явное "Не найдено"
if 'Не найдено' in text or 'не найдено' in text.lower():
logger.info(f"Не найден в реестре")
return {'found': False, 'status': 'not_found'}
# Проверка 2: Ищем регистрационный номер в разных форматах
# Форматы: 41-14-000746, 10-0107355, 77-20-016698
reg_patterns = [
r'(\d{2}-\d{2}-\d{6,7})', # 41-14-000746
r'(\d{2}-\d{4}-\d{6,7})', # 10-0107-355555
r'href="\?id=([^"]+)"', # Из ссылки ?id=41-14-000746
]
reg_number = None
for pattern in reg_patterns:
match = re.search(pattern, html)
if match:
reg_number = match.group(1)
break
# Проверка 3: Ищем дату регистрации
date_patterns = [
r'Приказ[^0-9]*(\d{2}\.\d{2}\.\d{4})',
r'(\d{2}\.\d{2}\.\d{4})', # Любая дата в формате ДД.ММ.ГГГГ
]
reg_date = None
for pattern in date_patterns:
match = re.search(pattern, text)
if match:
reg_date = match.group(1)
break
# Проверка 4: Ищем название организации с ИНН
org_pattern = f'(?:Общество|Индивидуальный предприниматель|Акционерное общество|ОБЩЕСТВО|ООО|ИП|АО)[^<]*?ИНН:\\s*{inn}'
org_match = re.search(org_pattern, html, re.IGNORECASE | re.DOTALL)
if org_match or reg_number:
logger.info(f" ✅ Найден: {reg_number or 'номер не распознан'} ({reg_date or 'дата не распознана'})")
return {
'found': True,
'status': 'found',
'reg_number': reg_number,
'reg_date': reg_date
}
# Проверка 5: Есть ли таблица с результатами?
if 'class="TblList"' in html or 'id="ResList' in html:
# Таблица есть, но не смогли распознать
logger.info(f" ⚠️ Таблица найдена, но данные не распознаны")
# Сохраняем HTML для ручного анализа
with open(f'rkn_unclear_{inn}.html', 'w', encoding='utf-8') as f:
f.write(html)
return {
'found': None,
'status': 'unclear',
'message': 'Таблица найдена, но данные не распознаны',
'html_saved': f'rkn_unclear_{inn}.html'
}
logger.info(f" ❌ Результаты не найдены")
return {'found': False, 'status': 'not_found'}
except Exception as e:
logger.error(f" ✗ Ошибка: {e}")
return {'found': False, 'status': 'error', 'message': str(e)}
async def main():
"""Основная функция"""
import sys
region = sys.argv[1] if len(sys.argv) > 1 else 'Камчатский край'
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
try:
cur = conn.cursor()
# Получаем отели с неясным результатом
cur.execute('''
SELECT id, full_name, owner_inn, website_address
FROM hotel_main
WHERE region_name ILIKE %s
AND rkn_registry_status = 'unclear'
ORDER BY full_name
''', (f'%{region}%',))
hotels = cur.fetchall()
cur.close()
logger.info(f"\n{'='*70}")
logger.info(f"🔄 ПЕРЕПРОВЕРКА НЕЯСНЫХ РЕЗУЛЬТАТОВ: {region}")
logger.info(f"📊 Отелей для перепроверки: {len(hotels)}")
logger.info(f"⏱️ Примерное время: {len(hotels) * REQUEST_DELAY / 60:.1f} минут")
logger.info(f"{'='*70}\n")
if len(hotels) == 0:
logger.info("✅ Нет отелей для перепроверки!")
return
# Открываем браузер
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'
})
results = {
'found': 0,
'not_found': 0,
'still_unclear': 0,
'error': 0
}
for i, hotel in enumerate(hotels, 1):
logger.info(f"\n[{i}/{len(hotels)}] {'='*50}")
logger.info(f"🏨 {hotel['full_name']}")
logger.info(f"🌐 {hotel['website_address']}")
logger.info(f"🔢 ИНН: {hotel['owner_inn']}")
# Проверяем
result = await check_inn_improved(page, hotel['owner_inn'])
# Сохраняем результат
cur = 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']
))
conn.commit()
cur.close()
# Обновляем статистику
if result['found'] == True:
results['found'] += 1
elif result['found'] == False:
if result['status'] == 'not_found':
results['not_found'] += 1
else:
results['error'] += 1
else:
results['still_unclear'] += 1
await browser.close()
# Итоги
logger.info(f"\n{'='*70}")
logger.info("📊 ИТОГИ ПЕРЕПРОВЕРКИ:")
logger.info(f" ✅ Теперь найдено: {results['found']}")
logger.info(f"Не найдено: {results['not_found']}")
logger.info(f"Все еще неясно: {results['still_unclear']}")
logger.info(f" ⚠️ Ошибки: {results['error']}")
logger.info(f"{'='*70}")
finally:
conn.close()
if __name__ == "__main__":
asyncio.run(main())

273
regional_crawler.py Executable file
View File

@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""
Региональный краулер для массового сбора сайтов отелей
Параллельная версия с поддержкой указания региона
"""
import asyncio
import logging
from typing import List, Dict, Optional
from datetime import datetime
import psycopg2
from psycopg2.extras import RealDictCursor
from playwright.async_api import async_playwright, Page
import sys
# Конфигурация БД
from urllib.parse import unquote
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
# Настройка логирования
def setup_logging(region_name: str):
log_filename = f"crawler_{region_name.replace(' ', '_').replace('.', '')}.log"
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_filename, encoding='utf-8'),
logging.StreamHandler()
]
)
return logging.getLogger(__name__)
def get_hotels_to_crawl(region_name: str, limit: int = None) -> List[Dict]:
"""Получить необработанные отели конкретного региона"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
query = """
SELECT
h.id,
h.full_name,
h.region_name,
h.website_address,
hwm.error_message
FROM hotel_main h
LEFT JOIN hotel_website_raw hwr ON hwr.hotel_id = h.id
LEFT JOIN hotel_website_meta hwm ON hwm.hotel_id = h.id
WHERE h.website_address IS NOT NULL
AND h.website_address != ''
AND h.region_name = %s
AND hwr.hotel_id IS NULL
AND (hwm.error_message IS NULL OR hwm.error_message = '')
ORDER BY h.full_name
"""
if limit:
query += f" LIMIT {limit}"
cur.execute(query, (region_name,))
hotels = cur.fetchall()
cur.close()
conn.close()
return hotels
def mark_hotel_failed(hotel_id: str, error_message: str):
"""Помечает отель как проблемный"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
INSERT INTO hotel_website_meta (hotel_id, error_message, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (hotel_id)
DO UPDATE SET
error_message = EXCLUDED.error_message,
updated_at = NOW()
""", (hotel_id, error_message))
conn.commit()
cur.close()
conn.close()
except Exception as e:
logging.error(f"Ошибка пометки отеля как failed: {e}")
def save_to_db(hotel_id: str, website_url: str, pages_data: List[Dict]):
"""Сохранение в PostgreSQL"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Сохраняем каждую страницу в hotel_website_raw
for page in pages_data:
cur.execute("""
INSERT INTO hotel_website_raw (hotel_id, url, html, created_at)
VALUES (%s, %s, %s, NOW())
""", (hotel_id, page['url'], page['html']))
# Обновляем метаданные
cur.execute("""
INSERT INTO hotel_website_meta (hotel_id, pages_crawled, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (hotel_id)
DO UPDATE SET
pages_crawled = EXCLUDED.pages_crawled,
error_message = NULL,
updated_at = NOW()
""", (hotel_id, len(pages_data)))
conn.commit()
cur.close()
conn.close()
return True
except Exception as e:
logging.error(f"Ошибка сохранения в БД: {e}")
return False
async def crawl_hotel(hotel: Dict, logger) -> bool:
"""Краулинг одного отеля"""
hotel_id = str(hotel['id'])
website = hotel['website_address'].strip()
hotel_name = hotel['full_name']
region = hotel['region_name']
logger.info(f"🏨 {hotel_name} ({region})")
logger.info(f" URL: {website}")
# Нормализация URL
if not website.startswith('http'):
website = f"https://{website}"
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
page = await context.new_page()
# Загружаем главную страницу
try:
await page.goto(website, wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(2000)
except Exception as e:
error_msg = str(e)[:200]
logger.warning(f" ❌ Ошибка загрузки: {error_msg}")
mark_hotel_failed(hotel_id, error_msg)
await browser.close()
return False
# Проверяем статус
if page.url.startswith('https://www.reg.ru/domain/') or 'Домен припаркован' in await page.content():
logger.warning(f" ⚠️ Домен припаркован")
mark_hotel_failed(hotel_id, "Domain parked")
await browser.close()
return False
# Собираем главную страницу
main_html = await page.content()
main_text_length = len(await page.inner_text('body'))
logger.info(f" ✅ Главная: {main_text_length} символов")
pages_data = [{
'url': page.url,
'html': main_html
}]
# Собираем внутренние ссылки
internal_links = await page.evaluate("""
() => {
const links = Array.from(document.querySelectorAll('a[href]'));
const baseUrl = window.location.origin;
return [...new Set(
links
.map(a => a.href)
.filter(href => href.startsWith(baseUrl) && !href.includes('#'))
)].slice(0, 14);
}
""")
logger.info(f" 📄 Найдено {len(internal_links)} внутренних ссылок")
# Обходим внутренние страницы
for link in internal_links[:14]:
try:
await page.goto(link, wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(500)
link_html = await page.content()
pages_data.append({
'url': page.url,
'html': link_html
})
except Exception:
continue
await browser.close()
# Сохраняем в БД
if save_to_db(hotel_id, website, pages_data):
logger.info(f" 💾 Сохранено {len(pages_data)} страниц")
return True
else:
logger.error(f" ❌ Ошибка сохранения в БД")
return False
except Exception as e:
logger.error(f" ❌ Критическая ошибка: {e}")
mark_hotel_failed(hotel_id, str(e)[:200])
return False
async def main(region_name: str):
"""Главная функция"""
logger = setup_logging(region_name)
logger.info(f"🚀 ЗАПУСК РЕГИОНАЛЬНОГО КРАУЛЕРА")
logger.info(f"🌍 Регион: {region_name}")
logger.info("="*60)
# Получаем отели для обработки
hotels = get_hotels_to_crawl(region_name)
if not hotels:
logger.info(f"Все отели региона {region_name} уже обработаны!")
return
logger.info(f"📊 Найдено отелей для обработки: {len(hotels)}\n")
# Обрабатываем отели последовательно
success_count = 0
error_count = 0
for idx, hotel in enumerate(hotels, 1):
logger.info(f"\n[{idx}/{len(hotels)}] " + "="*50)
result = await crawl_hotel(hotel, logger)
if result:
success_count += 1
else:
error_count += 1
# Небольшая пауза между отелями
await asyncio.sleep(1)
# Финальная статистика
logger.info("\n" + "="*60)
logger.info(f"✅ КРАУЛИНГ ЗАВЕРШЁН")
logger.info(f"📊 Обработано: {success_count}/{len(hotels)}")
logger.info(f"❌ Ошибок: {error_count}")
logger.info("="*60)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование: python3 regional_crawler.py 'Название региона'")
print("Пример: python3 regional_crawler.py 'г. Москва'")
sys.exit(1)
region_name = sys.argv[1]
asyncio.run(main(region_name))

281
rescan_10_pages.py Normal file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Пересканирование отелей, у которых было собрано ровно 10 страниц (старый лимит)
Теперь соберем до 20 страниц с каждого
"""
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 = 20
PAGE_TIMEOUT = 20000
MAX_CONCURRENT = 3 # Меньше чтобы не мешать основному краулеру
# Логирование
log_filename = f'rescan_{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:
"""Очистка HTML от мусора"""
soup = BeautifulSoup(html, 'html.parser')
# Удаляем скрипты, стили и прочее
for tag in soup(['script', 'style', 'meta', 'link', 'noscript']):
tag.decompose()
text = soup.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text)
return text.strip()
def get_hotels_to_rescan():
"""Получить список отелей для пересканирования"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Получаем отели с ровно 10 страницами
cur.execute('''
SELECT DISTINCT p.hotel_id, m.full_name, m.website_address
FROM hotel_website_processed p
JOIN hotel_main m ON p.hotel_id = m.id
WHERE p.hotel_id IN (
SELECT hotel_id
FROM hotel_website_processed
GROUP BY hotel_id
HAVING COUNT(*) = 10
)
ORDER BY p.hotel_id
''')
hotels = cur.fetchall()
cur.close()
conn.close()
return [{'hotel_id': h[0], 'name': h[1], 'website': h[2]} for h in hotels]
async def crawl_hotel(hotel: Dict, semaphore: asyncio.Semaphore, playwright):
"""Краулинг одного отеля"""
async with semaphore:
hotel_id = hotel['hotel_id']
hotel_name = hotel['name']
website = hotel['website']
if not website:
logger.warning(f" ⚠️ Нет сайта для {hotel_name}")
return False
logger.info(f"🏨 Пересканирую: {hotel_name}")
logger.info(f" URL: {website}")
# Сначала удалим старые данные
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("DELETE FROM hotel_website_processed WHERE hotel_id = %s", (hotel_id,))
cur.execute("DELETE FROM hotel_website_raw WHERE hotel_id = %s", (hotel_id,))
conn.commit()
logger.info(f" 🗑️ Удалены старые данные (10 страниц)")
cur.close()
conn.close()
# Теперь запускаем полный краулинг
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
try:
page = await context.new_page()
# Нормализуем URL
if not website.startswith(('http://', 'https://')):
base_url = f'https://{website}'
else:
base_url = website
parsed_base = urlparse(base_url)
base_domain = parsed_base.netloc
# Загружаем главную
try:
response = await page.goto(base_url, wait_until='domcontentloaded', timeout=PAGE_TIMEOUT)
if not response or response.status >= 400:
logger.warning(f" ⚠️ Главная страница недоступна: {response.status if response else 'No response'}")
await browser.close()
return False
except Exception as e:
logger.error(f" ❌ Ошибка загрузки главной: {e}")
await browser.close()
return False
# Собираем данные
pages_data = []
# Главная страница
main_html = await page.content()
main_text = TextCleaner.clean_html(main_html)
pages_data.append({
'url': base_url,
'html': main_html,
'text': main_text,
'status': response.status
})
logger.info(f" ✅ Главная: {len(main_text)} символов")
# Собираем ссылки
links = await page.evaluate('''() => {
return Array.from(document.querySelectorAll('a[href]'))
.map(a => a.href)
.filter(href => href && !href.startsWith('mailto:') && !href.startsWith('tel:'))
}''')
# Фильтруем только внутренние ссылки
internal_links = []
for link in links:
parsed = urlparse(link)
if parsed.netloc == base_domain or not parsed.netloc:
clean_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip('/')
if clean_url != base_url.rstrip('/') and clean_url not in [p['url'] for p in pages_data]:
internal_links.append(clean_url)
# Убираем дубли
internal_links = list(dict.fromkeys(internal_links))[:MAX_PAGES_PER_SITE - 1]
logger.info(f" 📄 Найдено {len(internal_links)} внутренних ссылок")
# Обходим внутренние страницы
for i, link_url in enumerate(internal_links, 1):
if len(pages_data) >= MAX_PAGES_PER_SITE:
break
try:
page2 = await context.new_page()
response2 = await page2.goto(link_url, wait_until='domcontentloaded', timeout=PAGE_TIMEOUT)
if response2 and response2.status < 400:
html2 = await page2.content()
text2 = TextCleaner.clean_html(html2)
pages_data.append({
'url': link_url,
'html': html2,
'text': text2,
'status': response2.status
})
logger.info(f" ✅ Страница {i}: {len(text2)} символов")
await page2.close()
except Exception as e:
logger.warning(f" ⚠️ Ошибка страницы {link_url}: {e}")
try:
await page2.close()
except:
pass
# Сохраняем в БД
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
for page_data in pages_data:
# Сохраняем raw
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 = NOW()
RETURNING id
""", (hotel_id, page_data['url'], page_data['html'], page_data['status']))
raw_id = cur.fetchone()[0]
# Сохраняем processed
cur.execute("""
INSERT INTO hotel_website_processed
(raw_page_id, hotel_id, url, cleaned_text, text_length, processed_at)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (hotel_id, url) DO UPDATE
SET cleaned_text = EXCLUDED.cleaned_text, text_length = EXCLUDED.text_length, processed_at = NOW()
""", (raw_id, hotel_id, page_data['url'], page_data['text'], len(page_data['text'])))
conn.commit()
cur.close()
conn.close()
logger.info(f" 💾 Сохранено {len(pages_data)} страниц для {hotel_name}")
await browser.close()
return True
except Exception as e:
logger.error(f" ❌ Ошибка при краулинге {hotel_name}: {e}")
await browser.close()
return False
async def main():
"""Главная функция"""
logger.info("🚀 Начинаю пересканирование отелей с 10 страницами")
hotels = get_hotels_to_rescan()
logger.info(f"📊 Найдено {len(hotels)} отелей для пересканирования")
if not hotels:
logger.info("✅ Нет отелей для пересканирования")
return
async with async_playwright() as playwright:
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
tasks = [crawl_hotel(hotel, semaphore, playwright) for hotel in hotels]
results = []
for i, task in enumerate(asyncio.as_completed(tasks), 1):
result = await task
results.append(result)
logger.info(f"📈 Прогресс: {i}/{len(hotels)} ({i/len(hotels)*100:.1f}%)")
success_count = sum(1 for r in results if r)
logger.info(f"\n✅ Завершено! Успешно: {success_count}/{len(hotels)}")
if __name__ == "__main__":
asyncio.run(main())

12
run_check.py Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
import subprocess
result = subprocess.run(['python3', '/root/engine/public_oversight/hotels/quick_check.py'], capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print("STDERR:", result.stderr)
print("EXIT CODE:", result.returncode)

481
scraper_detailed.py Normal file
View File

@@ -0,0 +1,481 @@
#!/usr/bin/env python3
"""
Сбор детальной информации по всем отелям
Собирает данные из 4 endpoint'ов для каждого отеля
"""
import requests
import psycopg2
from psycopg2.extras import execute_batch, Json
import time
import logging
from datetime import datetime
from urllib.parse import unquote
from typing import Optional, Dict, List
import json
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'scraper_detailed_{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")
}
API_BASE_URL = "https://tourism.fsa.gov.ru/api/v1"
RATE_LIMIT_DELAY = 0.1 # 10 запросов в секунду (осторожно)
BATCH_SIZE = 100
CHECKPOINT_INTERVAL = 1000 # Чаще checkpoint для длительного процесса
class DetailedScraper:
def __init__(self):
self.conn = None
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (compatible; HotelDataCollector/1.0)'
})
self.processed_count = 0
self.error_count = 0
self.start_time = None
def connect_db(self):
"""Подключение к базе данных"""
self.conn = psycopg2.connect(**DB_CONFIG)
logger.info("✓ Подключено к базе данных")
def api_request(self, url: str) -> Optional[Dict]:
"""Безопасный запрос к API с rate limiting"""
time.sleep(RATE_LIMIT_DELAY)
try:
response = self.session.get(url, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
logger.debug(f"API request failed: {url} - {e}")
return None
def get_hotel_ids(self, limit=None, offset=0):
"""Получить ID всех отелей из базы"""
cur = self.conn.cursor()
sql = "SELECT id FROM hotel_main ORDER BY id"
if limit:
sql += f" LIMIT {limit} OFFSET {offset}"
cur.execute(sql)
ids = [row[0] for row in cur.fetchall()]
cur.close()
return ids
def get_detailed_info(self, hotel_id: str) -> Dict:
"""Получить детальную информацию об отеле"""
result = {
'hotel_id': hotel_id,
'main': None,
'additional_info': None,
'sanatorium': None,
'drawer': None
}
# Main info
url = f"{API_BASE_URL}/resorts/hotels/{hotel_id}/main"
result['main'] = self.api_request(url)
# Additional info
url = f"{API_BASE_URL}/resorts/common/{hotel_id}/additional-info"
result['additional_info'] = self.api_request(url)
# Sanatorium info
url = f"{API_BASE_URL}/resorts/hotels/{hotel_id}/sanatoriumDrawer"
result['sanatorium'] = self.api_request(url)
# Drawer (услуги)
url = f"{API_BASE_URL}/resorts/hotels/{hotel_id}/drawer"
result['drawer'] = self.api_request(url)
return result
def save_main_updates(self, data_list: List[Dict]):
"""Обновить основную таблицу hotel_main"""
if not data_list:
return
cur = self.conn.cursor()
updates = []
for item in data_list:
main = item.get('main')
if not main:
continue
updates.append((
main.get('shortName'),
main.get('phone'),
main.get('email'),
main.get('websiteAddress'),
main.get('ownerFullName'),
item['hotel_id']
))
if updates:
sql = """
UPDATE hotel_main SET
short_name = %s,
phone = %s,
email = %s,
website_address = %s,
owner_full_name = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_batch(cur, sql, updates, page_size=BATCH_SIZE)
self.conn.commit()
cur.close()
def save_additional_info(self, data_list: List[Dict]):
"""Сохранить дополнительную информацию"""
if not data_list:
return
cur = self.conn.cursor()
records = []
for item in data_list:
info = item.get('additional_info')
if not info:
continue
records.append((
item['hotel_id'],
info.get('ownerOgrn'),
info.get('ownerInn'),
info.get('ownerKpp'),
info.get('ownerShortName'),
info.get('ownerPhone'),
info.get('ownerEmail'),
info.get('resortFullName'),
info.get('ownerAddressName'),
info.get('ownerLegalTypeId'),
info.get('phone'),
info.get('email')
))
if records:
sql = """
INSERT INTO hotel_additional_info
(hotel_id, owner_ogrn, owner_inn, owner_kpp, owner_short_name,
owner_phone, owner_email, resort_full_name, owner_address_name,
owner_legal_type_id, phone, email)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (hotel_id) DO UPDATE SET
owner_ogrn = EXCLUDED.owner_ogrn,
owner_inn = EXCLUDED.owner_inn,
owner_kpp = EXCLUDED.owner_kpp,
owner_short_name = EXCLUDED.owner_short_name,
owner_phone = EXCLUDED.owner_phone,
owner_email = EXCLUDED.owner_email,
resort_full_name = EXCLUDED.resort_full_name,
owner_address_name = EXCLUDED.owner_address_name,
owner_legal_type_id = EXCLUDED.owner_legal_type_id,
phone = EXCLUDED.phone,
email = EXCLUDED.email
"""
execute_batch(cur, sql, records, page_size=BATCH_SIZE)
self.conn.commit()
cur.close()
def save_sanatorium_info(self, data_list: List[Dict]):
"""Сохранить санаторную информацию"""
if not data_list:
return
cur = self.conn.cursor()
records = []
for item in data_list:
san = item.get('sanatorium')
if not san or not isinstance(san, dict) or 'sanatoriumInfo' not in san:
continue
info = san.get('sanatoriumInfo', {})
if not info:
continue
records.append((
item['hotel_id'],
info.get('oid'),
info.get('fullName'),
info.get('shortName'),
info.get('ogrn'),
info.get('inn'),
info.get('legalAddress'),
info.get('actualAddress'),
info.get('phone'),
info.get('email'),
info.get('webSite'),
info.get('medicalLicense'),
info.get('farmLicense'),
info.get('terrenkur'),
info.get('resortName'),
info.get('hasWaterSupply'),
info.get('hasHeating'),
info.get('hasSewage'),
info.get('hasAirConditioning'),
info.get('hasElevator'),
info.get('hasTelephone'),
info.get('hasInternet'),
info.get('hasMobilityLift'),
info.get('hasGym'),
info.get('hasConferenceRoom'),
Json(san.get('swimmingPoolInfo')),
Json(san.get('plageInfo')),
Json(san.get('landDocumentInfo')),
Json(san.get('roomsInfo'))
))
if records:
sql = """
INSERT INTO hotel_sanatorium
(hotel_id, oid, full_name, short_name, ogrn, inn, legal_address,
actual_address, phone, email, web_site, medical_license, farm_license,
terrenkur, resort_name, has_water_supply, has_heating, has_sewage,
has_air_conditioning, has_elevator, has_telephone, has_internet,
has_mobility_lift, has_gym, has_conference_room,
swimming_pool_info, plage_info, land_document_info, rooms_info)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (hotel_id) DO UPDATE SET
oid = EXCLUDED.oid,
full_name = EXCLUDED.full_name,
short_name = EXCLUDED.short_name,
ogrn = EXCLUDED.ogrn,
inn = EXCLUDED.inn,
legal_address = EXCLUDED.legal_address,
actual_address = EXCLUDED.actual_address,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
web_site = EXCLUDED.web_site,
medical_license = EXCLUDED.medical_license,
farm_license = EXCLUDED.farm_license,
terrenkur = EXCLUDED.terrenkur,
resort_name = EXCLUDED.resort_name,
has_water_supply = EXCLUDED.has_water_supply,
has_heating = EXCLUDED.has_heating,
has_sewage = EXCLUDED.has_sewage,
has_air_conditioning = EXCLUDED.has_air_conditioning,
has_elevator = EXCLUDED.has_elevator,
has_telephone = EXCLUDED.has_telephone,
has_internet = EXCLUDED.has_internet,
has_mobility_lift = EXCLUDED.has_mobility_lift,
has_gym = EXCLUDED.has_gym,
has_conference_room = EXCLUDED.has_conference_room,
swimming_pool_info = EXCLUDED.swimming_pool_info,
plage_info = EXCLUDED.plage_info,
land_document_info = EXCLUDED.land_document_info,
rooms_info = EXCLUDED.rooms_info
"""
execute_batch(cur, sql, records, page_size=BATCH_SIZE)
self.conn.commit()
cur.close()
def save_services_and_rooms(self, data_list: List[Dict]):
"""Сохранить услуги и номера из drawer"""
if not data_list:
return
cur = self.conn.cursor()
for item in data_list:
drawer = item.get('drawer')
if not drawer or not isinstance(drawer, dict):
continue
hotel_id = item['hotel_id']
# Услуги
services = []
for service_group in drawer.get('hotelServiceInfoList', []):
cat_id = service_group.get('id')
cat_name = service_group.get('name')
for service in service_group.get('servicesList', []):
services.append((
hotel_id,
cat_id,
cat_name,
service.get('id'),
service.get('name')
))
if services:
sql = """
INSERT INTO hotel_services
(hotel_id, service_category_id, service_category_name, service_id, service_name)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (hotel_id, service_id) DO NOTHING
"""
execute_batch(cur, sql, services, page_size=200)
# Номера
rooms = []
for room in drawer.get('roomInfoList', []):
rooms.append((
hotel_id,
room.get('roomCategory', {}).get('id'),
room.get('roomCategory', {}).get('name'),
room.get('apartmentCount'),
room.get('numberSeats'),
Json(room.get('equipmentList', [])),
room.get('familyRoomCount'),
room.get('disabilityRoomCount')
))
if rooms:
sql = """
INSERT INTO hotel_rooms
(hotel_id, room_category_id, room_category_name, apartment_count,
number_seats, equipment_list, family_room_count, disability_room_count)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
execute_batch(cur, sql, rooms, page_size=100)
self.conn.commit()
cur.close()
def save_raw_json(self, data_list: List[Dict]):
"""Сохранить сырые JSON для backup"""
if not data_list:
return
cur = self.conn.cursor()
records = []
for item in data_list:
records.append((
item['hotel_id'],
Json(item.get('main')),
Json(item.get('additional_info')),
Json(item.get('sanatorium')),
Json(item.get('drawer'))
))
sql = """
INSERT INTO hotel_raw_json
(hotel_id, main_data, additional_info, sanatorium_data, drawer_data)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (hotel_id) DO UPDATE SET
main_data = EXCLUDED.main_data,
additional_info = EXCLUDED.additional_info,
sanatorium_data = EXCLUDED.sanatorium_data,
drawer_data = EXCLUDED.drawer_data
"""
execute_batch(cur, sql, records, page_size=BATCH_SIZE)
self.conn.commit()
cur.close()
def run(self, limit=None, offset=0):
"""Запустить сбор детальной информации"""
self.start_time = datetime.now()
self.connect_db()
# Получаем список ID отелей
hotel_ids = self.get_hotel_ids(limit=limit, offset=offset)
total = len(hotel_ids)
logger.info("=" * 70)
logger.info(f"Запуск сбора детальной информации")
logger.info(f"Отелей к обработке: {total}")
logger.info(f"Начало: {self.start_time}")
logger.info("=" * 70)
batch = []
try:
for idx, hotel_id in enumerate(hotel_ids, 1):
try:
details = self.get_detailed_info(hotel_id)
batch.append(details)
self.processed_count += 1
# Сохраняем батч
if len(batch) >= BATCH_SIZE:
self.save_batch(batch)
batch = []
# Checkpoint и статистика
if self.processed_count % CHECKPOINT_INTERVAL == 0:
elapsed = (datetime.now() - self.start_time).total_seconds()
rate = self.processed_count / elapsed
remaining = (total - self.processed_count) / rate / 60
logger.info(
f"Progress: {self.processed_count}/{total} ({self.processed_count/total*100:.1f}%) | "
f"Speed: {rate:.1f} hotels/sec | "
f"ETA: {remaining:.1f} min | "
f"Errors: {self.error_count}"
)
except Exception as e:
logger.error(f"Error processing hotel {hotel_id}: {e}")
self.error_count += 1
# Сохраняем остаток
if batch:
self.save_batch(batch)
except KeyboardInterrupt:
logger.info("\n⚠ Прервано пользователем")
if batch:
self.save_batch(batch)
finally:
if self.conn:
self.conn.close()
elapsed = (datetime.now() - self.start_time).total_seconds()
logger.info("=" * 70)
logger.info("Сбор детальной информации завершен")
logger.info(f"Обработано: {self.processed_count}/{total} отелей")
logger.info(f"Ошибок: {self.error_count}")
logger.info(f"Время: {elapsed/60:.1f} минут")
logger.info(f"Скорость: {self.processed_count/elapsed:.1f} отелей/сек")
logger.info("=" * 70)
def save_batch(self, batch):
"""Сохранить батч данных"""
logger.debug(f"Сохраняю батч из {len(batch)} отелей...")
try:
self.save_main_updates(batch)
self.save_additional_info(batch)
self.save_sanatorium_info(batch)
self.save_services_and_rooms(batch)
self.save_raw_json(batch)
except Exception as e:
logger.error(f"Ошибка сохранения батча: {e}")
self.error_count += len(batch)
if __name__ == "__main__":
import sys
limit = int(sys.argv[1]) if len(sys.argv) > 1 else None
offset = int(sys.argv[2]) if len(sys.argv) > 2 else 0
logger.info(f"Параметры: limit={limit or 'all'}, offset={offset}")
scraper = DetailedScraper()
scraper.run(limit=limit, offset=offset)

196
scraper_missing.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Парсер для ОСТАВШИХСЯ необработанных отелей
С автоматическим переподключением к БД
"""
import requests
import psycopg2
from psycopg2.extras import execute_batch, Json
import time
import logging
from datetime import datetime
from urllib.parse import unquote
from typing import Optional, Dict, List
import json
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'scraper_missing_{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")
}
API_BASE_URL = "https://tourism.fsa.gov.ru/api/v1"
RATE_LIMIT_DELAY = 0.1
BATCH_SIZE = 50
class MissingScraper:
def __init__(self, limit=None, offset=0):
self.limit = limit
self.offset = offset
self.conn = None
self.session = requests.Session()
self.processed = 0
self.errors = 0
def reconnect_db(self):
"""Переподключение к БД"""
if self.conn:
try:
self.conn.close()
except:
pass
self.conn = psycopg2.connect(**DB_CONFIG)
def get_missing_hotel_ids(self):
"""Получить ID необработанных отелей"""
self.reconnect_db()
cur = self.conn.cursor()
sql = """
SELECT m.id
FROM hotel_main m
LEFT JOIN hotel_raw_json r ON m.id = r.hotel_id
WHERE r.hotel_id IS NULL
ORDER BY m.id
"""
if self.limit:
sql += f" LIMIT {self.limit} OFFSET {self.offset}"
cur.execute(sql)
ids = [row[0] for row in cur.fetchall()]
cur.close()
return ids
def api_request(self, url: str) -> Optional[Dict]:
"""API запрос"""
time.sleep(RATE_LIMIT_DELAY)
try:
response = self.session.get(url, timeout=30)
response.raise_for_status()
return response.json()
except:
return None
def get_hotel_details(self, hotel_id: str) -> Dict:
"""Получить детали отеля"""
return {
'hotel_id': hotel_id,
'main': self.api_request(f"{API_BASE_URL}/resorts/hotels/{hotel_id}/main"),
'additional_info': self.api_request(f"{API_BASE_URL}/resorts/common/{hotel_id}/additional-info"),
'sanatorium': self.api_request(f"{API_BASE_URL}/resorts/hotels/{hotel_id}/sanatoriumDrawer"),
'drawer': self.api_request(f"{API_BASE_URL}/resorts/hotels/{hotel_id}/drawer")
}
def save_batch(self, batch: List[Dict]):
"""Сохранить батч с переподключением"""
if not batch:
return
# Переподключаемся перед каждым сохранением
self.reconnect_db()
cur = self.conn.cursor()
try:
# Сохраняем в hotel_raw_json
records = [(item['hotel_id'], Json(item['main']), Json(item['additional_info']),
Json(item['sanatorium']), Json(item['drawer'])) for item in batch]
sql = """
INSERT INTO hotel_raw_json
(hotel_id, main_data, additional_info, sanatorium_data, drawer_data)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (hotel_id) DO UPDATE SET
main_data = EXCLUDED.main_data,
additional_info = EXCLUDED.additional_info,
sanatorium_data = EXCLUDED.sanatorium_data,
drawer_data = EXCLUDED.drawer_data
"""
execute_batch(cur, sql, records, page_size=BATCH_SIZE)
self.conn.commit()
logger.info(f"✓ Сохранено {len(batch)} отелей")
except Exception as e:
logger.error(f"Ошибка сохранения: {e}")
self.conn.rollback()
self.errors += len(batch)
finally:
cur.close()
def run(self):
"""Запуск"""
start = datetime.now()
logger.info(f"🚀 Запуск парсинга НЕОБРАБОТАННЫХ отелей")
# Получаем список необработанных
hotel_ids = self.get_missing_hotel_ids()
total = len(hotel_ids)
logger.info(f"📊 Необработанных отелей: {total}")
if total == 0:
logger.info("Все отели уже обработаны!")
return
batch = []
for idx, hotel_id in enumerate(hotel_ids, 1):
try:
details = self.get_hotel_details(hotel_id)
batch.append(details)
self.processed += 1
# Сохраняем батч
if len(batch) >= BATCH_SIZE:
self.save_batch(batch)
batch = []
# Прогресс
if idx % 100 == 0:
elapsed = (datetime.now() - start).total_seconds()
speed = self.processed / elapsed
eta_min = (total - idx) / speed / 60
logger.info(f"Progress: {idx}/{total} ({idx/total*100:.1f}%) | "
f"Speed: {speed:.1f}/sec | ETA: {eta_min:.0f} min")
except Exception as e:
logger.error(f"Ошибка обработки {hotel_id}: {e}")
self.errors += 1
# Остаток
if batch:
self.save_batch(batch)
elapsed = (datetime.now() - start).total_seconds()
logger.info(f"\n{'='*70}")
logger.info(f"Завершено: {self.processed}/{total}")
logger.info(f"Ошибок: {self.errors}")
logger.info(f"Время: {elapsed/60:.1f} минут")
logger.info(f"{'='*70}")
if __name__ == "__main__":
import sys
limit = int(sys.argv[1]) if len(sys.argv) > 1 else None
offset = int(sys.argv[2]) if len(sys.argv) > 2 else 0
scraper = MissingScraper(limit=limit, offset=offset)
scraper.run()

305
scraper_safe.py Normal file
View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
Безопасный парсер данных об отелях с tourism.fsa.gov.ru
Особенности:
- Rate limiting (5 req/sec)
- Checkpoint каждые 100 отелей
- Batch INSERT по 50 записей
- Возможность возобновления
"""
import requests
import psycopg2
from psycopg2.extras import execute_batch
import time
import logging
from datetime import datetime
from urllib.parse import unquote
from typing import Optional, Dict, List
import json
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'scraper_{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")
}
# Параметры парсинга
API_BASE_URL = "https://tourism.fsa.gov.ru/api/v1"
RATE_LIMIT_DELAY = 0.2 # 5 запросов в секунду
BATCH_SIZE = 50 # Записей в одном INSERT
CHECKPOINT_INTERVAL = 100 # Сохранять прогресс каждые N отелей
PAGE_SIZE = 100 # Отелей на страницу
class HotelScraper:
def __init__(self):
self.conn = None
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (compatible; HotelDataCollector/1.0)'
})
self.processed_count = 0
self.error_count = 0
self.start_time = None
def connect_db(self):
"""Подключение к базе данных"""
try:
self.conn = psycopg2.connect(**DB_CONFIG)
logger.info("✓ Подключено к базе данных")
except Exception as e:
logger.error(f"✗ Ошибка подключения к БД: {e}")
raise
def api_request(self, url: str, method='GET', **kwargs) -> Optional[Dict]:
"""Безопасный запрос к API с rate limiting"""
time.sleep(RATE_LIMIT_DELAY)
try:
response = self.session.request(method, url, timeout=30, **kwargs)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"API request failed: {url} - {e}")
return None
def get_hotels_list(self, page: int) -> Optional[List[Dict]]:
"""Получить список отелей с страницы"""
url = f"{API_BASE_URL}/resorts/hotels/showcase"
params = {'page': page, 'limit': PAGE_SIZE}
logger.info(f"Загружаю страницу {page}...")
data = self.api_request(url, params=params)
if data and 'data' in data:
return data['data']
return None
def get_hotel_details(self, hotel_id: str) -> Dict[str, Optional[Dict]]:
"""Получить детальную информацию об отеле"""
details = {
'main': None,
'additional_info': None,
'sanatorium': None,
'drawer': None
}
# Main info
url = f"{API_BASE_URL}/resorts/hotels/{hotel_id}/main"
details['main'] = self.api_request(url)
# Additional info
url = f"{API_BASE_URL}/resorts/common/{hotel_id}/additional-info"
details['additional_info'] = self.api_request(url)
# Sanatorium info (может не быть для обычных отелей)
url = f"{API_BASE_URL}/resorts/hotels/{hotel_id}/sanatoriumDrawer"
details['sanatorium'] = self.api_request(url)
# Drawer (услуги)
url = f"{API_BASE_URL}/resorts/hotels/{hotel_id}/drawer"
details['drawer'] = self.api_request(url)
return details
def save_hotel_batch(self, hotels_data: List[tuple]):
"""Сохранить батч отелей в базу"""
if not hotels_data:
return
cur = self.conn.cursor()
try:
# INSERT hotel_main
insert_sql = """
INSERT INTO hotel_main
(id, full_name, short_name, status_id, status_name,
category_id, category_name, region_id, region_name,
hotel_type_id, hotel_type_name, register_record,
register_record_date, owner_full_name, owner_ogrn, owner_inn,
phone, email, website_address, addresses, photo_ids,
has_seasonal, activation_datetime)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
full_name = EXCLUDED.full_name,
updated_at = CURRENT_TIMESTAMP;
"""
execute_batch(cur, insert_sql, hotels_data, page_size=BATCH_SIZE)
self.conn.commit()
logger.info(f"✓ Сохранено {len(hotels_data)} отелей")
except Exception as e:
self.conn.rollback()
logger.error(f"✗ Ошибка сохранения батча: {e}")
self.error_count += len(hotels_data)
finally:
cur.close()
def save_checkpoint(self, page: int, total_pages: int, status='in_progress'):
"""Сохранить контрольную точку"""
cur = self.conn.cursor()
try:
cur.execute("""
INSERT INTO hotel_parsing_progress
(page_number, total_pages, processed_count, status, started_at)
VALUES (%s, %s, %s, %s, %s)
""", (page, total_pages, self.processed_count, status, self.start_time))
self.conn.commit()
except Exception as e:
logger.error(f"Ошибка сохранения checkpoint: {e}")
finally:
cur.close()
def parse_showcase_data(self, hotel: Dict) -> tuple:
"""Распарсить данные из showcase"""
try:
addresses = json.dumps(hotel.get('addressList', []))
photo_ids = [photo for photo in [hotel.get('photoId')] if photo]
return (
hotel.get('id'),
hotel.get('fullName'),
None, # short_name не в showcase
hotel.get('status', {}).get('id'),
hotel.get('status', {}).get('name'),
hotel.get('category', {}).get('id'),
hotel.get('category', {}).get('name'),
hotel.get('region', {}).get('id'),
hotel.get('region', {}).get('name'),
hotel.get('hotelType', {}).get('id'),
hotel.get('hotelType', {}).get('name'),
hotel.get('registerRecord'),
hotel.get('registerRecordDate'),
hotel.get('ownerName'),
hotel.get('ownerOgrn'),
hotel.get('ownerInn'),
None, # phone не в showcase
None, # email не в showcase
None, # website не в showcase
addresses,
photo_ids,
None, # has_seasonal не в showcase
hotel.get('activationDateTime')
)
except Exception as e:
logger.error(f"Ошибка парсинга отеля {hotel.get('id')}: {e}")
return None
def run(self, start_page=0, max_pages=None):
"""Запустить парсинг"""
self.start_time = datetime.now()
self.connect_db()
logger.info("=" * 60)
logger.info("Запуск парсера отелей tourism.fsa.gov.ru")
logger.info(f"Начало: {self.start_time}")
logger.info("=" * 60)
page = start_page
batch = []
try:
while True:
# Проверка лимита страниц
if max_pages and page >= start_page + max_pages:
logger.info(f"Достигнут лимит страниц: {max_pages}")
break
# Получаем список отелей
hotels = self.get_hotels_list(page)
if not hotels:
logger.info("Больше нет данных или ошибка API")
break
# Обрабатываем каждый отель
for hotel in hotels:
hotel_data = self.parse_showcase_data(hotel)
if hotel_data:
batch.append(hotel_data)
self.processed_count += 1
# Сохраняем батч
if len(batch) >= BATCH_SIZE:
self.save_hotel_batch(batch)
batch = []
# Checkpoint
if self.processed_count % CHECKPOINT_INTERVAL == 0:
self.save_checkpoint(page, -1)
elapsed = (datetime.now() - self.start_time).total_seconds()
rate = self.processed_count / elapsed if elapsed > 0 else 0
logger.info(f"Progress: {self.processed_count} отелей, {rate:.1f} отелей/сек")
page += 1
# Если вернулось меньше PAGE_SIZE, значит это последняя страница
if len(hotels) < PAGE_SIZE:
logger.info("Достигнута последняя страница")
break
# Сохраняем остаток
if batch:
self.save_hotel_batch(batch)
# Финальный checkpoint
self.save_checkpoint(page, page, 'completed')
except KeyboardInterrupt:
logger.info("\n⚠ Парсинг прерван пользователем")
if batch:
logger.info("Сохраняю незавершенный батч...")
self.save_hotel_batch(batch)
self.save_checkpoint(page, -1, 'interrupted')
except Exception as e:
logger.error(f"✗ Критическая ошибка: {e}")
self.save_checkpoint(page, -1, 'failed')
finally:
if self.conn:
self.conn.close()
elapsed = (datetime.now() - self.start_time).total_seconds()
logger.info("=" * 60)
logger.info("Парсинг завершен")
logger.info(f"Обработано: {self.processed_count} отелей")
logger.info(f"Ошибок: {self.error_count}")
logger.info(f"Время работы: {elapsed/60:.1f} минут")
logger.info(f"Скорость: {self.processed_count/elapsed:.1f} отелей/сек")
logger.info("=" * 60)
if __name__ == "__main__":
import sys
# Параметры запуска
start_page = int(sys.argv[1]) if len(sys.argv) > 1 else 0
max_pages = int(sys.argv[2]) if len(sys.argv) > 2 else None
logger.info(f"Параметры: start_page={start_page}, max_pages={max_pages or 'все'}")
scraper = HotelScraper()
scraper.run(start_page=start_page, max_pages=max_pages)

107
search_hotel_content.py Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Прямой semantic search по данным отелей в Neo4j
Использует vector similarity для поиска релевантных чанков
"""
import os
import sys
import requests
from neo4j import GraphDatabase
from urllib.parse import unquote
# Neo4j
NEO4J_URI = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASSWORD = "supersecret"
# OpenAI для генерации query embedding
OPENAI_API_KEY = "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"
OPENAI_API_BASE = "https://api.openai.com/v1"
HTTP_PROXY = "http://195.133.66.13:3128"
def generate_embedding(text: str):
"""Генерирует эмбеддинг для текста"""
response = requests.post(
f"{OPENAI_API_BASE}/embeddings",
headers={
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json"
},
json={
"model": "text-embedding-3-small",
"input": text
},
proxies={"http": HTTP_PROXY, "https": HTTP_PROXY},
timeout=30
)
if response.status_code == 200:
return response.json()["data"][0]["embedding"]
else:
raise Exception(f"OpenAI API error: {response.status_code}")
def search_hotel_content(query: str, group_id: str = "hotel_spb", limit: int = 5):
"""Поиск по контенту отеля через vector similarity"""
print(f"🔍 Запрос: {query}")
print(f"📊 Group ID: {group_id}")
print(f"🎯 Limit: {limit}\n")
# Генерируем эмбеддинг запроса
print("⚙️ Генерирую эмбеддинг запроса...")
query_embedding = generate_embedding(query)
print(f"✓ Эмбеддинг: {len(query_embedding)} размерность\n")
# Подключаемся к Neo4j
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
with driver.session() as session:
# Vector similarity search
print("🔎 Поиск похожих эпизодов...\n")
result = session.run("""
MATCH (e:Episode)
WHERE e.group_id = $group_id
AND e.embedding IS NOT NULL
AND size(e.embedding) > 0
WITH e,
reduce(dot = 0.0, i IN range(0, size(e.embedding)-1) |
dot + e.embedding[i] * $query_embedding[i]
) / (
sqrt(reduce(sum = 0.0, x IN e.embedding | sum + x * x)) *
sqrt(reduce(sum = 0.0, x IN $query_embedding | sum + x * x))
) AS similarity
WHERE similarity > 0.3
RETURN e.name AS name,
e.content AS content,
similarity
ORDER BY similarity DESC
LIMIT $limit
""", {
"group_id": group_id,
"query_embedding": query_embedding,
"limit": limit
})
results = list(result)
if results:
print(f"✅ Найдено {len(results)} релевантных результатов:\n")
print("=" * 70)
for idx, record in enumerate(results, 1):
print(f"\n{idx}. SCORE: {record['similarity']:.3f}")
print(f" Name: {record['name']}")
print(f" Content:\n {record['content'][:300]}...")
print()
else:
print("❌ Ничего не найдено")
driver.close()
return results
if __name__ == "__main__":
query = sys.argv[1] if len(sys.argv) > 1 else "адрес отеля и контакты"
results = search_hotel_content(query)

389
semantic_audit_chukotka.py Normal file
View File

@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
Семантический аудит отелей Чукотки с использованием эмбеддингов
18 критериев аудита
"""
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import requests
import json
import pandas as pd
from datetime import datetime
import time
# Конфигурация
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
# 18 НАСТОЯЩИХ критериев аудита из audit_system_new.py
AUDIT_CRITERIA = [
{
'id': 1,
'name': 'Юридическая идентификация и верификация',
'query': 'полное наименование организации ОПФ ИНН ОГРН ЕГРЮЛ ЕГРИП проверить',
'keywords': ['инн', 'огрн', 'егрюл', 'егрип', 'организация', 'ооо', 'ип'],
'weight': 1.0
},
{
'id': 2,
'name': 'Адрес',
'query': 'юридический адрес фактический адрес местонахождение',
'keywords': ['адрес', 'address', 'местонахождение', 'г.', 'ул.'],
'weight': 1.0
},
{
'id': 3,
'name': 'Контакты',
'query': 'телефон email форма обратной связи чат контакты',
'keywords': ['телефон', 'phone', 'email', '@', '+7', '8-800'],
'weight': 1.0
},
{
'id': 4,
'name': 'Режим работы',
'query': 'часы работы график приема режим работы колл-центр',
'keywords': ['часы работы', 'график работы', 'режим работы', 'круглосуточно', '24/7', 'пн-пт', 'пн-вс', 'время работы'],
'weight': 1.0
},
{
'id': 5,
'name': 'Политика ПДн (152-ФЗ)',
'query': 'политика персональных данных обработка ПДн 152-ФЗ',
'keywords': ['персональных данных', 'пдн', '152-фз', 'privacy'],
'weight': 1.0
},
{
'id': 6,
'name': 'Роскомнадзор (реестр)',
'query': 'роскомнадзор реестр операторов персональных данных',
'keywords': ['роскомнадзор', 'реестр', 'оператор'],
'weight': 1.0
},
{
'id': 7,
'name': 'Договор-оферта / Правила оказания услуг',
'query': 'договор оферта правила оказания услуг условия',
'keywords': ['договор', 'оферта', 'правила', 'условия', 'услуг'],
'weight': 1.0
},
{
'id': 8,
'name': 'Рекламации и споры',
'query': 'рекламации споры жалобы претензии решение конфликтов',
'keywords': ['рекламация', 'спор', 'жалоба', 'претензия', 'конфликт'],
'weight': 1.0
},
{
'id': 9,
'name': 'Цены/прайс',
'query': 'цены прайс тарифы стоимость номера',
'keywords': ['цена', 'прайс', 'тариф', 'стоимость', 'номер'],
'weight': 1.0
},
{
'id': 10,
'name': 'Способы оплаты',
'query': 'способы оплаты платеж банковская карта наличные',
'keywords': ['оплата', 'платеж', 'карта', 'наличные', 'способ'],
'weight': 1.0
},
{
'id': 11,
'name': 'Онлайн-оплата',
'query': 'онлайн оплата интернет платеж карта через сайт',
'keywords': ['онлайн', 'интернет', 'платеж', 'карта', 'сайт'],
'weight': 1.0
},
{
'id': 12,
'name': 'Онлайн-бронирование',
'query': 'онлайн бронирование заказ номера через сайт',
'keywords': ['бронирование', 'заказ', 'номер', 'сайт', 'онлайн'],
'weight': 1.0
},
{
'id': 13,
'name': 'FAQ',
'query': 'часто задаваемые вопросы FAQ помощь',
'keywords': ['faq', 'вопрос', 'ответ', 'помощь', 'часто'],
'weight': 1.0
},
{
'id': 14,
'name': 'Доступность для ЛОВЗ',
'query': 'доступность инвалиды ЛОВЗ безбарьерная среда',
'keywords': ['доступность', 'инвалид', 'ловз', 'безбарьерная'],
'weight': 1.0
},
{
'id': 15,
'name': 'Партнёры/бренды',
'query': 'партнеры бренды сотрудничество франшиза',
'keywords': ['партнер', 'бренд', 'сотрудничество', 'франшиза'],
'weight': 1.0
},
{
'id': 16,
'name': 'Команда/сотрудники',
'query': 'команда сотрудники персонал коллектив',
'keywords': ['команда', 'сотрудник', 'персонал', 'коллектив'],
'weight': 1.0
},
{
'id': 17,
'name': 'Уголок потребителя',
'query': 'уголок потребителя права потребителя защита прав',
'keywords': ['потребитель', 'права', 'защита', 'уголок'],
'weight': 1.0
},
{
'id': 18,
'name': 'Актуальность документов',
'query': 'актуальность документов дата обновления свежая информация',
'keywords': ['актуальность', 'документ', 'дата', 'обновление', 'свежая'],
'weight': 1.0
}
]
def get_db_connection():
"""Получить подключение к БД"""
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
def semantic_search_hotel(hotel_id: str, query: str, limit: int = 5):
"""Семантический поиск для конкретного отеля"""
try:
# Генерируем эмбеддинг для запроса
headers = {
"X-API-Key": BGE_API_KEY,
"Content-Type": "application/json"
}
payload = {"text": [query]}
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=30)
if response.status_code != 200:
return []
result = response.json()
query_embedding = result.get('embeddings', [[]])[0]
if not query_embedding:
return []
embedding_str = json.dumps(query_embedding)
# Поиск по конкретному отелю
conn = get_db_connection()
cur = conn.cursor()
query_sql = """
SELECT
metadata->>'hotel_name' as hotel_name,
metadata->>'url' as url,
text,
embedding <-> %s::vector as distance
FROM hotel_website_chunks
WHERE metadata->>'hotel_id' = %s
ORDER BY embedding <-> %s::vector
LIMIT %s;
"""
cur.execute(query_sql, [embedding_str, hotel_id, embedding_str, limit])
results = cur.fetchall()
cur.close()
conn.close()
return results
except Exception as e:
print(f"Ошибка семантического поиска для отеля {hotel_id}: {e}")
return []
def evaluate_criterion(hotel_id: str, hotel_name: str, criterion: dict):
"""Оценка критерия для отеля"""
print(f" 🔍 Критерий {criterion['id']}: {criterion['name']}")
# Семантический поиск
search_results = semantic_search_hotel(hotel_id, criterion['query'], limit=3)
if not search_results:
return {
'criterion_id': criterion['id'],
'criterion_name': criterion['name'],
'score': 0,
'max_score': criterion['weight'],
'found_text': None,
'relevance': 0.0,
'url': None
}
# Анализируем результаты
best_result = search_results[0]
distance = best_result['distance']
# Конвертируем расстояние в оценку (чем меньше расстояние, тем выше оценка)
if distance < 0.7:
score = criterion['weight'] # Отлично
relevance = "🟢 Высокая"
elif distance < 0.9:
score = criterion['weight'] * 0.7 # Хорошо
relevance = "🟡 Средняя"
elif distance < 1.1:
score = criterion['weight'] * 0.4 # Удовлетворительно
relevance = "🟠 Низкая"
else:
score = 0 # Не найдено
relevance = "🔴 Очень низкая"
# Проверяем наличие ключевых слов в тексте
text_lower = best_result['text'].lower()
keywords_found = [kw for kw in criterion['keywords'] if kw in text_lower]
if keywords_found:
score = min(score + 0.1 * len(keywords_found), criterion['weight'])
return {
'criterion_id': criterion['id'],
'criterion_name': criterion['name'],
'score': round(score, 2),
'max_score': criterion['weight'],
'found_text': best_result['text'][:200] + "..." if len(best_result['text']) > 200 else best_result['text'],
'relevance': relevance,
'distance': round(distance, 3),
'keywords_found': keywords_found,
'url': best_result['url']
}
def audit_hotel(hotel_id: str, hotel_name: str):
"""Аудит одного отеля"""
print(f"\n🏨 АУДИТ ОТЕЛЯ: {hotel_name}")
print("=" * 60)
results = []
total_score = 0
max_total_score = sum(criterion['weight'] for criterion in AUDIT_CRITERIA)
for criterion in AUDIT_CRITERIA:
result = evaluate_criterion(hotel_id, hotel_name, criterion)
results.append(result)
total_score += result['score']
print(f"{criterion['name']}: {result['score']}/{result['max_score']} {result['relevance']}")
if result['found_text']:
print(f" 📄 {result['found_text']}")
print()
# Небольшая пауза между запросами
time.sleep(0.5)
percentage = (total_score / max_total_score) * 100 if max_total_score > 0 else 0
print(f"📊 ИТОГОВАЯ ОЦЕНКА: {total_score:.2f}/{max_total_score} ({percentage:.1f}%)")
return {
'hotel_id': hotel_id,
'hotel_name': hotel_name,
'total_score': round(total_score, 2),
'max_score': max_total_score,
'percentage': round(percentage, 1),
'criteria_results': results
}
def main():
"""Основная функция"""
print("🚀 СЕМАНТИЧЕСКИЙ АУДИТ ОТЕЛЕЙ ЧУКОТКИ")
print("=" * 50)
# Получаем отели Чукотки с эмбеддингами
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT
h.id,
h.full_name,
COUNT(c.id) as chunks_count
FROM hotel_main h
JOIN hotel_website_chunks c ON h.id::text = c.metadata->>'hotel_id'
WHERE h.region_name ILIKE '%чукот%'
AND c.id IS NOT NULL
GROUP BY h.id, h.full_name
HAVING COUNT(c.id) > 0
ORDER BY chunks_count DESC;
""")
hotels = cur.fetchall()
cur.close()
conn.close()
print(f"📊 Найдено {len(hotels)} отелей с эмбеддингами:")
for hotel in hotels:
print(f"{hotel['full_name']} ({hotel['chunks_count']} chunks)")
# Проводим аудит
audit_results = []
for hotel in hotels:
result = audit_hotel(hotel['id'], hotel['full_name'])
audit_results.append(result)
# Создаем Excel отчет
create_excel_report(audit_results)
print(f"\n✅ АУДИТ ЗАВЕРШЕН! Результаты сохранены в Excel.")
def create_excel_report(audit_results):
"""Создание Excel отчета"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"semantic_audit_chukotka_{timestamp}.xlsx"
with pd.ExcelWriter(filename, engine='openpyxl') as writer:
# Сводная таблица
summary_data = []
for result in audit_results:
summary_data.append({
'Отель': result['hotel_name'],
'Общая оценка': result['total_score'],
'Максимальная оценка': result['max_score'],
'Процент': f"{result['percentage']}%",
'Статус': 'Отлично' if result['percentage'] >= 80 else 'Хорошо' if result['percentage'] >= 60 else 'Удовлетворительно' if result['percentage'] >= 40 else 'Неудовлетворительно'
})
summary_df = pd.DataFrame(summary_data)
summary_df.to_excel(writer, sheet_name='Сводка', index=False)
# Детальные результаты по каждому отелю
for result in audit_results:
sheet_name = result['hotel_name'][:30] # Ограничиваем длину имени листа
detailed_data = []
for criterion in result['criteria_results']:
detailed_data.append({
'Критерий': criterion['criterion_name'],
'Оценка': criterion['score'],
'Максимальная оценка': criterion['max_score'],
'Релевантность': criterion['relevance'],
'Расстояние': criterion['distance'],
'Найденный текст': criterion['found_text'],
'Найденные ключевые слова': ', '.join(criterion.get('keywords_found', [])),
'URL': criterion['url']
})
detailed_df = pd.DataFrame(detailed_data)
detailed_df.to_excel(writer, sheet_name=sheet_name, index=False)
print(f"📊 Excel отчет сохранен: {filename}")
if __name__ == "__main__":
main()

294
semantic_search_api.py Normal file
View File

@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""
API для семантического поиска по эмбеддингам
Интеграция с веб-интерфейсом
"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Dict, Optional
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
import requests
import json
app = FastAPI(
title="Semantic Search API",
description="API для семантического поиска по эмбеддингам отелей",
version="1.0.0"
)
# Конфигурация
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
class SearchRequest(BaseModel):
query: str
region: Optional[str] = None
hotel_id: Optional[str] = None
limit: int = 10
min_distance: float = 0.3
class SearchResult(BaseModel):
hotel_name: str
region_name: str
url: str
text: str
distance: float
relevance: str
def get_db_connection():
"""Получить подключение к БД"""
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
def generate_query_embedding(query: str):
"""Генерация эмбеддинга для поискового запроса"""
try:
headers = {
"X-API-Key": BGE_API_KEY,
"Content-Type": "application/json"
}
payload = {"text": query}
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=30)
if response.status_code == 200:
result = response.json()
return result.get('embeddings', [[]])[0]
else:
raise HTTPException(status_code=500, detail=f"BGE API error: {response.status_code}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Embedding generation error: {str(e)}")
@app.get("/")
async def root():
"""Информация об API"""
return {
"name": "Semantic Search API",
"version": "1.0.0",
"description": "API для семантического поиска по эмбеддингам отелей",
"endpoints": [
"POST /search - Семантический поиск",
"GET /regions - Список регионов",
"GET /hotels - Список отелей",
"GET /stats - Статистика"
]
}
@app.post("/search", response_model=List[SearchResult])
async def semantic_search(request: SearchRequest):
"""Семантический поиск по эмбеддингам"""
try:
# Генерируем эмбеддинг для запроса
query_embedding = generate_query_embedding(request.query)
embedding_str = json.dumps(query_embedding)
# Строим SQL запрос с фильтрами
where_conditions = ["embedding IS NOT NULL"]
params = []
if request.region:
where_conditions.append("metadata->>'region_name' = %s")
params.append(request.region)
if request.hotel_id:
where_conditions.append("metadata->>'hotel_id' = %s")
params.append(request.hotel_id)
where_clause = " AND ".join(where_conditions)
query = f"""
SELECT
metadata->>'hotel_name' as hotel_name,
metadata->>'region_name' as region_name,
metadata->>'url' as url,
LEFT(text, 300) as text,
embedding <-> %s::vector as distance
FROM hotel_website_chunks
WHERE {where_clause}
ORDER BY embedding <-> %s::vector
LIMIT %s;
"""
# Добавляем параметры в правильном порядке
params = [embedding_str] + params + [embedding_str, request.limit]
conn = get_db_connection()
cur = conn.cursor()
cur.execute(query, params)
results = []
for row in cur.fetchall():
distance = row['distance']
if distance < 0.9:
relevance = "🟢 Высокая"
elif distance < 1.0:
relevance = "🟡 Средняя"
else:
relevance = "🔴 Низкая"
results.append(SearchResult(
hotel_name=row['hotel_name'] or "Неизвестный отель",
region_name=row['region_name'] or "Неизвестный регион",
url=row['url'] or "",
text=row['text'] or "",
distance=float(distance),
relevance=relevance
))
cur.close()
conn.close()
return results
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/regions")
async def get_regions():
"""Получить список регионов с эмбеддингами"""
try:
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT
metadata->>'region_name' as region_name,
COUNT(DISTINCT metadata->>'hotel_id') as hotels_count,
COUNT(*) as chunks_count
FROM hotel_website_chunks
WHERE metadata->>'region_name' IS NOT NULL
GROUP BY metadata->>'region_name'
ORDER BY chunks_count DESC;
""")
regions = []
for row in cur.fetchall():
regions.append({
"region_name": row['region_name'],
"hotels_count": row['hotels_count'],
"chunks_count": row['chunks_count']
})
cur.close()
conn.close()
return {"regions": regions}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/hotels")
async def get_hotels(region: Optional[str] = None):
"""Получить список отелей с эмбеддингами"""
try:
conn = get_db_connection()
cur = conn.cursor()
if region:
cur.execute("""
SELECT DISTINCT
metadata->>'hotel_id' as hotel_id,
metadata->>'hotel_name' as hotel_name,
metadata->>'region_name' as region_name,
COUNT(*) as chunks_count
FROM hotel_website_chunks
WHERE metadata->>'region_name' = %s
GROUP BY metadata->>'hotel_id', metadata->>'hotel_name', metadata->>'region_name'
ORDER BY chunks_count DESC;
""", (region,))
else:
cur.execute("""
SELECT DISTINCT
metadata->>'hotel_id' as hotel_id,
metadata->>'hotel_name' as hotel_name,
metadata->>'region_name' as region_name,
COUNT(*) as chunks_count
FROM hotel_website_chunks
GROUP BY metadata->>'hotel_id', metadata->>'hotel_name', metadata->>'region_name'
ORDER BY chunks_count DESC;
""")
hotels = []
for row in cur.fetchall():
hotels.append({
"hotel_id": row['hotel_id'],
"hotel_name": row['hotel_name'],
"region_name": row['region_name'],
"chunks_count": row['chunks_count']
})
cur.close()
conn.close()
return {"hotels": hotels}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/stats")
async def get_stats():
"""Получить статистику по эмбеддингам"""
try:
conn = get_db_connection()
cur = conn.cursor()
# Общая статистика
cur.execute("""
SELECT
COUNT(*) as total_chunks,
COUNT(DISTINCT metadata->>'hotel_id') as total_hotels,
COUNT(DISTINCT metadata->>'region_name') as total_regions,
AVG(LENGTH(text)) as avg_chunk_length
FROM hotel_website_chunks;
""")
stats = cur.fetchone()
# Статистика по регионам
cur.execute("""
SELECT
metadata->>'region_name' as region_name,
COUNT(DISTINCT metadata->>'hotel_id') as hotels_count,
COUNT(*) as chunks_count
FROM hotel_website_chunks
WHERE metadata->>'region_name' IS NOT NULL
GROUP BY metadata->>'region_name'
ORDER BY chunks_count DESC;
""")
regions_stats = []
for row in cur.fetchall():
regions_stats.append({
"region_name": row['region_name'],
"hotels_count": row['hotels_count'],
"chunks_count": row['chunks_count']
})
cur.close()
conn.close()
return {
"total_chunks": stats['total_chunks'],
"total_hotels": stats['total_hotels'],
"total_regions": stats['total_regions'],
"avg_chunk_length": float(stats['avg_chunk_length']) if stats['avg_chunk_length'] else 0,
"regions": regions_stats
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

173
simple_web.py Normal file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Упрощенный веб-интерфейс без сложных БД запросов
"""
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
import uvicorn
app = FastAPI(title="Система аудита отелей")
@app.get("/")
async def root():
return HTMLResponse("""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Аудит Отелей - Общественный Контроль</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 10px; }
h1 { color: #2c3e50; text-align: center; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }
.stat-card { background: #3498db; color: white; padding: 20px; border-radius: 8px; text-align: center; }
.stat-number { font-size: 2em; font-weight: bold; }
.stat-label { font-size: 0.9em; opacity: 0.9; }
.regions { margin-top: 30px; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #34495e; color: white; }
.btn { background: #e74c3c; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; }
.btn:hover { background: #c0392b; }
.btn-small { background: #27ae60; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; font-size: 0.8em; }
.btn-small:hover { background: #229954; }
</style>
</head>
<body>
<div class="container">
<h1>🏨 Система аудита отелей</h1>
<div class="stats">
<div class="stat-card">
<div class="stat-number" id="total-hotels">33,773</div>
<div class="stat-label">Всего отелей</div>
</div>
<div class="stat-card">
<div class="stat-number" id="crawled-sites">115</div>
<div class="stat-label">Спарсено сайтов</div>
</div>
<div class="stat-card">
<div class="stat-number" id="audited-hotels">239</div>
<div class="stat-label">Проведено аудитов</div>
</div>
<div class="stat-card">
<div class="stat-number" id="avg-score">11.0</div>
<div class="stat-label">Средний балл</div>
</div>
</div>
<div class="regions">
<h2>📊 Статус по регионам</h2>
<table>
<thead>
<tr>
<th>Регион</th>
<th>Отелей</th>
<th>Спарсено</th>
<th>Проверено</th>
<th>Средний балл</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr>
<td>Камчатский край</td>
<td>159</td>
<td>82</td>
<td>159</td>
<td>10.8/20</td>
<td><button class="btn-small" onclick="downloadAudit('Камчатский край')">📥 Скачать</button></td>
</tr>
<tr>
<td>Орловская область</td>
<td>68</td>
<td>29</td>
<td>68</td>
<td>10.6/20</td>
<td><button class="btn-small" onclick="downloadAudit('Орловская область')">📥 Скачать</button></td>
</tr>
<tr>
<td>Чукотский автономный округ</td>
<td>12</td>
<td>4</td>
<td>12</td>
<td>11.7/20</td>
<td><span style="color: #999;">Старый аудит</span></td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top: 30px; text-align: center;">
<button class="btn" onclick="location.reload()">🔄 Обновить</button>
<button class="btn" onclick="alert('Полная версия: http://localhost:8888')">🔗 Полная версия</button>
</div>
</div>
<script>
function downloadAudit(regionName) {
const url = `/api/audit/download/${encodeURIComponent(regionName)}`;
const link = document.createElement('a');
link.href = url;
link.download = `audit_${regionName.replace(/\\s+/g, '_')}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
</body>
</html>
""")
@app.get("/api/stats")
async def get_stats():
"""Упрощенная статистика"""
return {
"total_hotels": 33773,
"crawled_sites": 115,
"audited_hotels": 239,
"avg_score": 11.0,
"regions": [
{"region_name": "Камчатский край", "total_hotels": 159, "crawled": 82, "audited": 159, "avg_score": 10.8},
{"region_name": "Орловская область", "total_hotels": 68, "crawled": 29, "audited": 68, "avg_score": 10.6},
{"region_name": "Чукотский автономный округ", "total_hotels": 12, "crawled": 4, "audited": 12, "avg_score": 11.7}
]
}
@app.get("/api/audit/download/{region}")
async def download_audit(region: str):
"""Скачать Excel отчет по аудиту"""
import os
from fastapi.responses import FileResponse
# Ищем последний файл аудита для региона
region_safe = region.replace(' ', '_')
audit_dir = '/root/engine/public_oversight/hotels'
try:
files = [f for f in os.listdir(audit_dir) if f.startswith(f'audit_{region_safe}') and f.endswith('.xlsx')]
if not files:
return {"error": f"Файл аудита для региона '{region}' не найден. Сначала запустите аудит."}
# Берем последний файл (по дате в имени)
files.sort(reverse=True)
latest_file = files[0]
file_path = os.path.join(audit_dir, latest_file)
return FileResponse(
path=file_path,
filename=latest_file,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
return {"error": str(e)}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8889)

184
single_hotel_crawler.py Normal file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Краулинг одного конкретного отеля
"""
import asyncio
import psycopg2
from psycopg2.extras import Json, RealDictCursor
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
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 = 10
PAGE_TIMEOUT = 30000
# Логирование
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class TextCleaner:
"""Очистка HTML"""
@classmethod
def clean_text(cls, html: str) -> str:
"""Очистка HTML до чистого текста"""
if not html:
return ""
soup = BeautifulSoup(html, 'html.parser')
# Удаляем скрипты и стили
for script in soup(["script", "style"]):
script.decompose()
# Получаем текст
text = soup.get_text()
# Очищаем от лишних пробелов и переносов
lines = (line.strip() for line in text.splitlines())
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
text = ' '.join(chunk for chunk in chunks if chunk)
return text
async def crawl_hotel(hotel_id: str):
"""Краулинг одного отеля"""
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
cur = conn.cursor()
try:
# Получаем данные отеля
cur.execute("""
SELECT id, full_name, website_address, region_name
FROM hotel_main
WHERE id = %s
""", (hotel_id,))
hotel = cur.fetchone()
if not hotel:
print(f"❌ Отель с ID {hotel_id} не найден")
return
print(f"🏨 Краулим: {hotel['full_name']}")
print(f"🔗 URL: {hotel['website_address']}")
print(f"📍 Регион: {hotel['region_name']}")
url = hotel['website_address']
if not url:
print("У отеля нет URL")
return
# Добавляем протокол если нет
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
print(f"🌐 Полный URL: {url}")
# Запускаем браузер
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
try:
# Переходим на главную страницу
print("📄 Загружаем главную страницу...")
await page.goto(url, timeout=PAGE_TIMEOUT)
# Получаем HTML
html = await page.content()
cleaned_text = TextCleaner.clean_text(html)
print(f"✅ Получено {len(html)} символов HTML")
print(f"📝 Очищено до {len(cleaned_text)} символов текста")
# Удаляем старую запись если есть
cur.execute("DELETE FROM hotel_website_raw WHERE hotel_id = %s", (hotel_id,))
# Сохраняем в hotel_website_raw
cur.execute("""
INSERT INTO hotel_website_raw (hotel_id, url, html, crawled_at)
VALUES (%s, %s, %s, %s)
""", (hotel_id, url, html, datetime.now()))
# Обновляем метаданные
cur.execute("""
INSERT INTO hotel_website_meta (hotel_id, crawl_status, pages_crawled, total_size_bytes, crawl_started_at, crawl_finished_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (hotel_id) DO UPDATE SET
crawl_status = EXCLUDED.crawl_status,
pages_crawled = EXCLUDED.pages_crawled,
total_size_bytes = EXCLUDED.total_size_bytes,
crawl_started_at = EXCLUDED.crawl_started_at,
crawl_finished_at = EXCLUDED.crawl_finished_at,
error_message = NULL
""", (hotel_id, 'completed', 1, len(html), datetime.now(), datetime.now()))
# Обновляем статус отеля
cur.execute("""
UPDATE hotel_main
SET website_status = 'accessible'
WHERE id = %s
""", (hotel_id,))
conn.commit()
print("✅ Краулинг завершен успешно!")
except Exception as e:
print(f"❌ Ошибка краулинга: {e}")
# Сохраняем ошибку
cur.execute("""
INSERT INTO hotel_website_meta (hotel_id, crawl_status, error_message, crawl_started_at, crawl_finished_at)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (hotel_id) DO UPDATE SET
crawl_status = EXCLUDED.crawl_status,
error_message = EXCLUDED.error_message,
crawl_started_at = EXCLUDED.crawl_started_at,
crawl_finished_at = EXCLUDED.crawl_finished_at
""", (hotel_id, 'failed', str(e), datetime.now(), datetime.now()))
conn.commit()
finally:
await browser.close()
finally:
cur.close()
conn.close()
def main():
if len(sys.argv) != 2:
print("Использование: python3 single_hotel_crawler.py <hotel_id>")
sys.exit(1)
hotel_id = sys.argv[1]
print(f"🚀 Запуск краулинга для отеля: {hotel_id}")
asyncio.run(crawl_hotel(hotel_id))
if __name__ == "__main__":
main()

466
smart_crawler.py Executable file
View File

@@ -0,0 +1,466 @@
#!/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)

364
test_comfort_hotel.py Normal file
View File

@@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""
Тестирование гибридного аудита для отеля "Комфорт" (Камчатка)
Сравнение с результатами n8n AI Agent
"""
import psycopg2
from psycopg2.extras import RealDictCursor
import requests
import json
from urllib.parse import unquote
import re
# Natasha для NER
from natasha import (
Segmenter,
MorphVocab,
NewsEmbedding,
NewsMorphTagger,
NewsSyntaxParser,
NewsNERTagger,
Doc
)
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
# BGE-M3 API
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
# ID отеля Комфорт
HOTEL_ID = "303958ee-c607-11ef-92da-f9f9e6a4072b"
# Инициализация Natasha
print("🔧 Инициализация Natasha...")
segmenter = Segmenter()
morph_vocab = MorphVocab()
emb = NewsEmbedding()
morph_tagger = NewsMorphTagger(emb)
syntax_parser = NewsSyntaxParser(emb)
ner_tagger = NewsNERTagger(emb)
print("✅ Natasha готова!\n")
# Результаты от n8n AI Agent для сравнения
N8N_RESULTS = {
1: {"score": 0.0, "found": False},
2: {"score": 1.0, "found": True, "url": "https://hotelcomfort41.ru/o-kompanii"},
3: {"score": 0.5, "found": True},
4: {"score": 1.0, "found": True, "url": "https://hotelcomfort41.ru"},
5: {"score": 0.5, "found": True},
7: {"score": 1.0, "found": True},
8: {"score": 0.0, "found": False},
9: {"score": 0.5, "found": True},
10: {"score": 0.0, "found": False},
11: {"score": 0.0, "found": False},
12: {"score": 1.0, "found": True},
13: {"score": 0.0, "found": False},
14: {"score": 0.0, "found": False},
15: {"score": 0.0, "found": False},
16: {"score": 0.0, "found": False},
17: {"score": 0.0, "found": False},
18: {"score": 0.0, "found": False}
}
def get_db_connection():
return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
def get_all_hotel_text(hotel_id: str) -> list:
"""Получить весь текст отеля из chunks"""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT text, metadata->>'url' as url
FROM hotel_website_chunks
WHERE metadata->>'hotel_id' = %s
ORDER BY id;
""", (hotel_id,))
chunks = cur.fetchall()
cur.close()
conn.close()
return chunks
def check_criterion_with_regex(chunks: list, criterion: dict) -> dict:
"""Проверка критерия регулярными выражениями"""
result = {
'found': False,
'matches': [],
'urls': [],
'quotes': []
}
patterns = criterion.get('patterns', [])
keywords = criterion.get('keywords', [])
for chunk in chunks:
text = chunk['text']
url = chunk.get('url', 'Нет URL')
# Проверяем регулярки
for pattern in patterns:
matches = re.findall(pattern, text, re.IGNORECASE)
if matches:
result['found'] = True
result['matches'].extend(matches[:3])
if url not in result['urls']:
result['urls'].append(url)
# Находим контекст
match_obj = re.search(pattern, text, re.IGNORECASE)
if match_obj:
idx = match_obj.start()
start = max(0, idx - 100)
end = min(len(text), idx + 200)
quote = text[start:end].strip()
result['quotes'].append({
'url': url,
'quote': quote,
'match': matches[0]
})
# Проверяем ключевые слова
if not result['found']:
text_lower = text.lower()
for keyword in keywords:
if keyword.lower() in text_lower:
result['found'] = True
if url not in result['urls']:
result['urls'].append(url)
# Находим контекст вокруг ключевого слова
idx = text_lower.find(keyword.lower())
if idx >= 0:
start = max(0, idx - 100)
end = min(len(text), idx + 200)
quote = text[start:end].strip()
result['quotes'].append({
'url': url,
'quote': quote,
'match': keyword
})
break
return result
# 17 критериев с регулярками
CRITERIA = [
{
'id': 1,
'name': 'Юридическая идентификация и верификация',
'patterns': [
r'\b\d{10}\b', # ИНН юр.лица
r'\b\d{12}\b', # ИНН ИП
r'\b\d{13}\b', # ОГРН
r'инн\s*:?\s*\d{10,12}',
r'огрн\s*:?\s*\d{13}',
],
'keywords': ['инн', 'огрн', 'егрюл', 'реквизиты']
},
{
'id': 2,
'name': 'Адрес',
'patterns': [
r'\d{6}.*?ул\.',
r'ул\.\s*[А-Яа-яёЁA-Za-z\s]+,?\s*\d+',
r'\d{6},?\s*г\.\s*[А-Яа-яёЁ-]+',
],
'keywords': ['адрес', 'местонахождение', 'г.', 'ул.']
},
{
'id': 3,
'name': 'Контакты',
'patterns': [
r'(?:\+7|8)\s*\(?\d{3,5}\)?\s*\d{1,3}[-\s]?\d{2}[-\s]?\d{2}',
r'[\w\.-]+@[\w\.-]+\.\w{2,}',
],
'keywords': ['телефон', 'email', 'контакт']
},
{
'id': 4,
'name': 'Режим работы',
'patterns': [
r'(?:с|с\s+)\d{1,2}(?::|\.)\d{2}\s*(?:до|по)\s*\d{1,2}(?::|\.)\d{2}',
r'круглосуточно',
r'24\s*[/\-]\s*7',
],
'keywords': ['режим работы', 'часы работы', 'график']
},
{
'id': 5,
'name': 'Политика ПДн (152-ФЗ)',
'patterns': [
r'152[-\s]?фз',
r'политика\s+в\s+отношении\s+обработки\s+персональных\s+данных',
],
'keywords': ['персональных данных', 'пдн', '152-фз', 'политика конфиденциальности']
},
{
'id': 7,
'name': 'Договор-оферта / Правила оказания услуг',
'patterns': [
r'публичная\s+оферта',
r'договор.*?оказани.*?услуг',
r'пользовательское\s+соглашение',
],
'keywords': ['оферта', 'договор', 'правила', 'соглашение']
},
{
'id': 8,
'name': 'Рекламации и споры',
'patterns': [],
'keywords': ['рекламация', 'претензия', 'жалоба', 'спор']
},
{
'id': 9,
'name': 'Цены/прайс',
'patterns': [
r'\d+\s*(?:руб|₽)',
r'(?:от|цена|стоимость)\s*\d+',
],
'keywords': ['цена', 'прайс', 'стоимость', 'тариф']
},
{
'id': 10,
'name': 'Способы оплаты',
'patterns': [],
'keywords': ['оплата картой', 'наличные', 'безналичный', 'карта', 'visa', 'mastercard']
},
{
'id': 11,
'name': 'Онлайн-оплата',
'patterns': [],
'keywords': ['онлайн оплата', 'оплатить онлайн', 'эквайринг']
},
{
'id': 12,
'name': 'Онлайн-бронирование',
'patterns': [],
'keywords': ['забронировать', 'бронирование', 'booking', 'форма заявки']
},
{
'id': 13,
'name': 'FAQ',
'patterns': [],
'keywords': ['faq', 'частые вопросы', 'часто задаваемые']
},
{
'id': 14,
'name': 'Доступность для ЛОВЗ',
'patterns': [],
'keywords': ['ловз', 'инвалид', 'доступность', 'безбарьерная']
},
{
'id': 15,
'name': 'Партнёры/бренды',
'patterns': [],
'keywords': ['партнер', 'партнёр', 'бренд', 'сотрудничество']
},
{
'id': 16,
'name': 'Команда/сотрудники',
'patterns': [],
'keywords': ['команда', 'сотрудник', 'персонал', 'руководство']
},
{
'id': 17,
'name': 'Уголок потребителя',
'patterns': [],
'keywords': ['уголок потребителя', 'права потребителя', 'защита прав']
},
{
'id': 18,
'name': 'Актуальность документов',
'patterns': [
r'\d{4}[-/.]\d{1,2}[-/.]\d{1,2}', # Даты
r'обновлено',
r'актуализировано',
],
'keywords': ['дата обновления', 'актуально', 'обновлено']
}
]
print('🏨 ТЕСТИРОВАНИЕ ОТЕЛЯ: Городской отель \"Комфорт\" (Камчатский край)')
print('='*80)
print(f'ID отеля: {HOTEL_ID}')
print()
# Получаем chunks
chunks = get_all_hotel_text(HOTEL_ID)
print(f'📊 Загружено {len(chunks)} chunks')
print()
# Проверяем каждый критерий
print('🔍 ПРОВЕРКА ПО РЕГУЛЯРКАМИ И КЛЮЧЕВЫМ СЛОВАМ:')
print('='*80)
total_score = 0.0
for criterion in CRITERIA:
crit_id = criterion['id']
crit_name = criterion['name']
print(f'\n{crit_id}. {crit_name}')
print('-'*80)
result = check_criterion_with_regex(chunks, criterion)
# Оценка
score = 0.0
if result['found'] and result['matches']:
score = 1.0
elif result['found']:
score = 0.5
total_score += score
# n8n результат для сравнения
n8n_score = N8N_RESULTS.get(crit_id, {}).get('score', 0.0)
print(f'Регулярки: {score}/1.0')
print(f'n8n AI: {n8n_score}/1.0')
if score != n8n_score:
diff = score - n8n_score
if diff > 0:
print(f'✅ Регулярки ЛУЧШЕ на {diff:.1f}')
else:
print(f'❌ n8n AI ЛУЧШЕ на {-diff:.1f}')
else:
print('🟰 Одинаково')
if result['found']:
print(f'Найдено совпадений: {len(result["matches"])}')
if result['matches']:
print(f'Примеры: {result["matches"][:3]}')
if result['urls']:
print(f'URL: {result["urls"][0]}')
if result['quotes']:
quote_text = result['quotes'][0]['quote'][:150]
print(f'Цитата: {quote_text}...')
else:
print('Не найдено')
print()
print('='*80)
print(f'📊 ИТОГО:')
print(f'Регулярки: {total_score}/17 ({total_score/17*100:.1f}%)')
print(f'n8n AI: 6.0/17 (35.3%)')
print()
if total_score > 6.0:
print(f'✅ Регулярки лучше на {total_score - 6.0:.1f} баллов!')
elif total_score < 6.0:
print(f'❌ n8n AI лучше на {6.0 - total_score:.1f} баллов!')
else:
print('🟰 Результаты одинаковые!')

72
test_data_processing.py Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки обработки данных
"""
import psycopg2
import json
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'default_db',
'user': 'gen_user',
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
}
def test_data_processing():
"""Тестируем обработку данных"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute('SELECT criteria_results FROM hotel_audit_results LIMIT 1')
row = cur.fetchone()
if row:
criteria = row[0]
print('🔍 Исходные данные из БД:')
print(f' Тип: {type(criteria)}')
print(f' Длина: {len(criteria)}')
# Проверяем критерий 2
criterion_02 = criteria.get('criterion_02', {})
print(f'\n📋 Критерий 2 (Адрес):')
print(f' found: {criterion_02.get("found")}')
print(f' approval_urls: {criterion_02.get("approval_urls")}')
print(f' quote: {criterion_02.get("quote", "")[:50]}...')
# Тестируем обработку
print(f'\n🔧 Тестируем обработку:')
# URL
url = '-'
if criterion_02.get('approval_urls'):
url = criterion_02['approval_urls'][0]
print(f' URL: {url}')
# Комментарий
comment = "Не найдено"
if criterion_02['found']:
if criterion_02.get('quote'):
comment = criterion_02['quote']
elif criterion_02.get('approval_quotes'):
first_quote = criterion_02['approval_quotes'][0]
if isinstance(first_quote, dict):
comment = first_quote.get('quote', 'Найдено')
else:
comment = str(first_quote)
else:
comment = "Найдено"
comment = comment[:100] + "..." if len(comment) > 100 else comment
print(f' Комментарий: {comment[:50]}...')
cur.close()
conn.close()
if __name__ == "__main__":
test_data_processing()

53
test_rkn_fix.py Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки РКН колонок
"""
import psycopg2
import json
from urllib.parse import unquote
# Конфигурация БД
DB_CONFIG = {
'host': '147.45.189.234',
'port': 5432,
'database': 'hotels_db',
'user': 'gen_user',
'password': unquote('gen_user%40password')
}
def test_rkn_data():
"""Тестируем РКН данные"""
try:
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
# Получаем данные отеля с РКН
cursor.execute("""
SELECT id, full_name, rkn_registry_status, rkn_registry_number, rkn_registry_date
FROM hotel_main
WHERE region_name = 'Чукотский автономный округ'
LIMIT 1
""")
result = cursor.fetchone()
if result:
print(f"Отель: {result[1]}")
print(f"РКН статус: {result[2]}")
print(f"РКН номер: {result[3]}")
print(f"РКН дата: {result[4]}")
# Проверяем логику
rkn_status = result[2]
rkn_in_registry = "ДА" if rkn_status and rkn_status.lower() == 'found' else "НЕТ"
print(f"Результат: {rkn_in_registry}")
cursor.close()
conn.close()
except Exception as e:
print(f"Ошибка: {e}")
if __name__ == "__main__":
test_rkn_data()

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
Тест семантического поиска по Чукотскому автономному округу
на основе готовой базы с эмбеддингами
"""
import psycopg2
from urllib.parse import unquote
import requests
import json
import time
# API настройки
BGE_API_URL = "http://147.45.146.17:8002/embed"
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
class ChukotkaAnalyzer:
def __init__(self):
self.conn = None
self.cur = None
self.connect_db()
def connect_db(self):
"""Подключение к базе данных"""
try:
self.conn = psycopg2.connect(
host='147.45.189.234',
port=5432,
database='default_db',
user='gen_user',
password=unquote('2~~9_%5EkVsU%3F2%5CS')
)
self.conn.autocommit = True
self.cur = self.conn.cursor()
print("✅ Подключение к БД установлено")
except Exception as e:
print(f"❌ Ошибка подключения к БД: {e}")
raise
def get_chukotka_stats(self):
"""Получение статистики по Чукотке"""
self.cur.execute("""
SELECT
COUNT(DISTINCT metadata->>'hotel_id') as hotels_count,
COUNT(*) as total_chunks,
AVG(LENGTH(text)) as avg_chunk_length
FROM hotel_website_chunks
WHERE metadata->>'region_name' = 'Чукотский автономный округ';
""")
result = self.cur.fetchone()
return {
'hotels_count': result[0],
'total_chunks': result[1],
'avg_chunk_length': result[2]
}
def get_chukotka_hotels(self):
"""Получение списка отелей Чукотки"""
self.cur.execute("""
SELECT DISTINCT
metadata->>'hotel_id' as hotel_id,
metadata->>'hotel_name' as hotel_name,
COUNT(*) as chunks_count
FROM hotel_website_chunks
WHERE metadata->>'region_name' = 'Чукотский автономный округ'
GROUP BY metadata->>'hotel_id', metadata->>'hotel_name'
ORDER BY chunks_count DESC;
""")
return self.cur.fetchall()
def generate_query_embedding(self, query: str):
"""Генерация эмбеддинга для поискового запроса"""
try:
headers = {
"X-API-Key": BGE_API_KEY,
"Content-Type": "application/json"
}
payload = {"text": query}
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=30)
if response.status_code == 200:
result = response.json()
return result.get('embeddings', [[]])[0]
else:
print(f"❌ Ошибка API: {response.status_code}")
return None
except Exception as e:
print(f"❌ Ошибка генерации эмбеддинга: {e}")
return None
def search_chukotka(self, query: str, limit: int = 5):
"""Семантический поиск по Чукотке"""
query_embedding = self.generate_query_embedding(query)
if not query_embedding:
return []
embedding_str = json.dumps(query_embedding)
self.cur.execute("""
SELECT
metadata->>'hotel_name' as hotel_name,
metadata->>'url' as url,
LEFT(text, 150) as sample_text,
LENGTH(text) as text_length,
embedding <-> %s::vector as distance
FROM hotel_website_chunks
WHERE metadata->>'region_name' = 'Чукотский автономный округ'
AND embedding IS NOT NULL
ORDER BY embedding <-> %s::vector
LIMIT %s;
""", (embedding_str, embedding_str, limit))
return self.cur.fetchall()
def analyze_hotel_criteria(self, hotel_id: str):
"""Анализ отеля по критериям аудита"""
criteria_queries = {
'Юридическая идентификация': 'инн огрн егрюл организация',
'Контактная информация': 'телефон адрес email контакты',
'Политика конфиденциальности': 'политика конфиденциальности персональные данные',
'Условия бронирования': 'бронирование условия отмена возврат',
'Услуги отеля': 'услуги сервис завтрак wi-fi парковка',
'Доступность': 'доступность инвалиды коляска лифт'
}
results = {}
for criteria, query in criteria_queries.items():
self.cur.execute("""
SELECT
embedding <-> %s::vector as distance,
LEFT(text, 200) as sample_text
FROM hotel_website_chunks
WHERE metadata->>'hotel_id' = %s
AND embedding IS NOT NULL
ORDER BY embedding <-> %s::vector
LIMIT 1;
""", (json.dumps(self.generate_query_embedding(query)), hotel_id, json.dumps(self.generate_query_embedding(query))))
result = self.cur.fetchone()
if result:
distance, sample_text = result
relevance = "🟢 Высокая" if distance < 0.9 else "🟡 Средняя" if distance < 1.0 else "🔴 Низкая"
results[criteria] = {
'distance': distance,
'relevance': relevance,
'sample_text': sample_text
}
return results
def close(self):
"""Закрытие соединения с БД"""
if self.cur:
self.cur.close()
if self.conn:
self.conn.close()
def main():
print("="*70)
print("🏔️ АНАЛИЗ ЧУКОТСКОГО АВТОНОМНОГО ОКРУГА")
print("="*70)
analyzer = ChukotkaAnalyzer()
try:
# Статистика по региону
stats = analyzer.get_chukotka_stats()
print(f"\n📊 СТАТИСТИКА ПО ЧУКОТКЕ:")
print(f" Отелей: {stats['hotels_count']}")
print(f" Chunks: {stats['total_chunks']}")
print(f" Средняя длина chunk: {stats['avg_chunk_length']:.0f} символов")
# Список отелей
hotels = analyzer.get_chukotka_hotels()
print(f"\n🏨 ОТЕЛИ ЧУКОТКИ:")
print("-" * 70)
for hotel_id, hotel_name, chunks_count in hotels:
print(f" 🏨 {hotel_name}")
print(f" ID: {hotel_id}")
print(f" Chunks: {chunks_count}")
print()
# Тестовые поисковые запросы
test_queries = [
"телефон отеля",
"услуги и сервисы",
"бронирование номеров",
"адрес и контакты",
"политика конфиденциальности",
"завтрак и питание"
]
print("🔍 ТЕСТИРОВАНИЕ СЕМАНТИЧЕСКОГО ПОИСКА:")
print("-" * 70)
for query in test_queries:
print(f"\n🔍 Запрос: '{query}'")
results = analyzer.search_chukotka(query, 3)
for i, (hotel_name, url, sample_text, text_length, distance) in enumerate(results, 1):
if distance < 0.9:
relevance = "🟢 Отлично"
elif distance < 1.0:
relevance = "🟡 Хорошо"
else:
relevance = "🔴 Слабо"
print(f" {i}. Distance: {distance:.4f} {relevance}")
print(f" Отель: {hotel_name[:50]}...")
print(f" Текст: {sample_text}...")
print()
# Анализ одного отеля по критериям
if hotels:
test_hotel_id, test_hotel_name, _ = hotels[0]
print(f"\n📋 АНАЛИЗ ОТЕЛЯ ПО КРИТЕРИЯМ:")
print(f"🏨 {test_hotel_name}")
print("-" * 70)
criteria_results = analyzer.analyze_hotel_criteria(test_hotel_id)
for criteria, data in criteria_results.items():
print(f"{data['relevance']} {criteria}")
print(f" Distance: {data['distance']:.4f}")
print(f" Найденный текст: {data['sample_text'][:100]}...")
print()
print("="*70)
print("✅ АНАЛИЗ ЗАВЕРШЁН!")
print("="*70)
except Exception as e:
print(f"❌ Ошибка анализа: {e}")
finally:
analyzer.close()
if __name__ == "__main__":
main()

527
universal_crawler.py Normal file
View 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())

201
update_website_status.py Normal file
View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
Обновление статусов доступности сайтов на основе результатов краулинга
"""
import psycopg2
from urllib.parse import unquote
import re
# Конфигурация БД
DB_CONFIG = {
'host': "147.45.189.234",
'port': 5432,
'database': "default_db",
'user': "gen_user",
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
}
def update_website_statuses(log_file_path):
"""Обновление статусов на основе лог-файла краулера"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Паттерны для определения типов ошибок
error_patterns = {
'timeout': r'Timeout \d+ms exceeded',
'connection_refused': r'ERR_CONNECTION_REFUSED',
'dns_error': r'ERR_NAME_NOT_RESOLVED',
'ssl_error': r'ERR_SSL_PROTOCOL_ERROR|ERR_CERT_DATE_INVALID',
'http_error': r'Ошибка загрузки: (403|404|500)',
'invalid_url': r'Cannot navigate to invalid URL'
}
# Читаем лог-файл
with open(log_file_path, 'r', encoding='utf-8') as f:
log_content = f.read()
# Находим все отели и их ошибки
hotel_pattern = r'🏨 «(.+?)»\n.*?🌐 (.+?)\n'
hotels = re.findall(hotel_pattern, log_content, re.DOTALL)
stats = {
'accessible': 0,
'timeout': 0,
'connection_refused': 0,
'dns_error': 0,
'ssl_error': 0,
'http_error': 0,
'invalid_url': 0,
'not_checked': 0
}
for hotel_name, website in hotels:
# Нормализуем URL
website = website.strip()
if not website.startswith(('http://', 'https://')):
website = 'https://' + website
# Ищем секцию этого отеля в логе
hotel_section_pattern = f'🏨 «{re.escape(hotel_name)}».*?(?=🏨 «|======================================================================\\n📊 ИТОГИ:)'
hotel_section_match = re.search(hotel_section_pattern, log_content, re.DOTALL)
if not hotel_section_match:
continue
hotel_section = hotel_section_match.group(0)
# Определяем статус
status = 'not_checked'
# Проверяем на успешность
if '✓ Спарсено' in hotel_section and '✓ Сохранено' in hotel_section:
status = 'accessible'
stats['accessible'] += 1
else:
# Проверяем типы ошибок
for error_type, pattern in error_patterns.items():
if re.search(pattern, hotel_section):
status = error_type
stats[error_type] += 1
break
if status == 'not_checked':
stats['not_checked'] += 1
# Обновляем статус в БД
try:
cur.execute('''
UPDATE hotel_main
SET website_status = %s
WHERE website_address LIKE %s
OR website_address LIKE %s
OR website_address LIKE %s
''', (status, f'%{website.replace("https://", "").replace("http://", "").split("/")[0]}%',
f'%{website}%',
website.replace("https://", "").replace("http://", "").split("/")[0]))
except Exception as e:
print(f"Ошибка обновления для {hotel_name}: {e}")
conn.commit()
cur.close()
conn.close()
return stats
def generate_report(region_name='Камчатский край'):
"""Генерация отчета по доступности сайтов"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# Получаем статистику
cur.execute('''
SELECT
website_status,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) as percentage
FROM hotel_main
WHERE region_name ILIKE %s
GROUP BY website_status
ORDER BY count DESC
''', (f'%{region_name}%',))
stats = cur.fetchall()
print(f"\n{'='*80}")
print(f"📊 ОТЧЕТ О ДОСТУПНОСТИ САЙТОВ: {region_name}")
print(f"{'='*80}\n")
status_labels = {
'accessible': '✅ Сайт доступен',
'no_website': '❌ Сайт отсутствует',
'timeout': '⏱️ Таймаут (сайт медленный)',
'connection_refused': '🚫 Соединение отклонено',
'dns_error': '🔍 DNS ошибка (домен не найден)',
'ssl_error': '🔒 SSL ошибка (проблема с сертификатом)',
'http_error': '⚠️ HTTP ошибка (403/404/500)',
'invalid_url': '❓ Неверный URL',
'not_checked': 'Не проверено'
}
for status, count, percentage in stats:
label = status_labels.get(status, status)
print(f"{label:45} {count:5} ({percentage:5.2f}%)")
# Получаем список недоступных отелей
print(f"\n{'='*80}")
print("🔴 ОТЕЛИ С НЕДОСТУПНЫМИ САЙТАМИ:")
print(f"{'='*80}\n")
cur.execute('''
SELECT full_name, website_address, website_status
FROM hotel_main
WHERE region_name ILIKE %s
AND website_status NOT IN ('accessible', 'no_website', 'not_checked')
ORDER BY website_status, full_name
LIMIT 20
''', (f'%{region_name}%',))
problematic = cur.fetchall()
for name, website, status in problematic:
status_icon = {
'timeout': '⏱️',
'connection_refused': '🚫',
'dns_error': '🔍',
'ssl_error': '🔒',
'http_error': '⚠️',
'invalid_url': ''
}.get(status, '')
print(f"{status_icon} {name}")
print(f" 🌐 {website}")
print(f" 📋 Статус: {status}")
print()
cur.close()
conn.close()
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
log_file = sys.argv[1]
print(f"📖 Обработка лог-файла: {log_file}")
stats = update_website_statuses(log_file)
print("\n📊 СТАТИСТИКА ОБНОВЛЕНИЯ:")
for status, count in stats.items():
print(f" {status}: {count}")
# Генерируем отчет
generate_report('Камчатский край')

194
upload_to_graphiti.py Normal file
View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Загрузка данных отелей в Graphiti для векторизации
"""
import asyncio
import httpx
import psycopg2
from psycopg2.extras import RealDictCursor
from urllib.parse import unquote
from datetime import datetime
import logging
import os
from bs4 import BeautifulSoup
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(f'graphiti_upload_{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")
}
GRAPHITI_API = "http://185.197.75.249:9200/upload"
PROXY = os.getenv('HTTP_PROXY', 'http://185.197.75.249:3128')
RATE_LIMIT_DELAY = 1 # Задержка между загрузками
async def upload_to_graphiti(hotel_data: dict, pages_data: list, group_id: str) -> dict:
"""Загрузка данных отеля в Graphiti"""
try:
# Формируем текст для загрузки
text_parts = []
# Заголовок с информацией об отеле
header = f"""
ОТЕЛЬ: {hotel_data['full_name']}
РЕГИОН: {hotel_data['region_name']}
САЙТ: {hotel_data['website_address']}
ИНН: {hotel_data['owner_inn'] or 'не указан'}
ТЕЛЕФОН: {hotel_data['phone'] or 'не указан'}
EMAIL: {hotel_data['email'] or 'не указан'}
"""
if hotel_data.get('rkn_registry_status') == 'found':
header += f"РЕЕСТР РКН: ✅ Зарегистрирован ({hotel_data['rkn_registry_number']}, {hotel_data['rkn_registry_date']})\n"
else:
header += f"РЕЕСТР РКН: ❌ Не найден или неясен\n"
text_parts.append(header)
# Добавляем контент страниц (ограничиваем размер)
total_chars = 0
max_total_chars = 50000 # Максимум 50К символов на отель
for page in pages_data:
if total_chars >= max_total_chars:
break
# Очищаем HTML
soup = BeautifulSoup(page['html'], 'html.parser')
for tag in soup.find_all(['script', 'style']):
tag.decompose()
clean_text = soup.get_text()
clean_text = ' '.join(clean_text.split()) # Убираем лишние пробелы
if len(clean_text) > 100: # Только если есть содержимое
# Ограничиваем размер каждой страницы
page_text = clean_text[:3000]
text_parts.append(f"\n--- СТРАНИЦА: {page['url']} ---\n{page_text}")
total_chars += len(page_text)
full_text = '\n\n'.join(text_parts)
# Финальное ограничение
if len(full_text) > max_total_chars:
full_text = full_text[:max_total_chars]
# Формируем запрос
payload = {
"group_id": group_id,
"title": f"Отель: {hotel_data['full_name']} ({hotel_data['region_name']})",
"content": full_text
}
# Отправляем в Graphiti (без прокси, т.к. локальный API)
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(GRAPHITI_API, json=payload)
if response.status_code == 200:
result = response.json()
logger.info(f" ✅ Загружено в Graphiti: {len(pages_data)} страниц")
return {'success': True, 'result': result}
else:
logger.error(f" ✗ Ошибка Graphiti: {response.status_code}")
return {'success': False, 'error': response.text}
except Exception as e:
logger.error(f" ✗ Ошибка загрузки: {e}")
return {'success': False, 'error': str(e)}
async def main():
"""Основная функция"""
import sys
region = sys.argv[1] if len(sys.argv) > 1 else 'Камчатский край'
group_id = f"hotel_{region.lower().replace(' ', '_').replace('ский', '').replace('край', '').strip()}"
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
try:
cur = conn.cursor()
# Получаем отели с данными
cur.execute('''
SELECT DISTINCT h.id, h.full_name, h.region_name, h.website_address,
h.owner_inn, h.phone, h.email, h.rkn_registry_status,
h.rkn_registry_number, h.rkn_registry_date
FROM hotel_main h
JOIN hotel_website_raw w ON h.id = w.hotel_id
WHERE h.region_name ILIKE %s
ORDER BY h.full_name
''', (f'%{region}%',))
hotels = cur.fetchall()
logger.info(f"\n{'='*70}")
logger.info(f"🚀 ЗАГРУЗКА В GRAPHITI: {region}")
logger.info(f"📊 Отелей: {len(hotels)}")
logger.info(f"🏷️ Group ID: {group_id}")
logger.info(f"⏱️ Примерное время: {len(hotels) * RATE_LIMIT_DELAY / 60:.1f} минут")
logger.info(f"{'='*70}\n")
successful = 0
failed = 0
for i, hotel in enumerate(hotels, 1):
logger.info(f"\n[{i}/{len(hotels)}] {'='*40}")
logger.info(f"🏨 {hotel['full_name']}")
logger.info(f"🌐 {hotel['website_address']}")
# Получаем страницы отеля
cur.execute('''
SELECT url, html, page_title
FROM hotel_website_raw
WHERE hotel_id = %s
ORDER BY depth, crawled_at
''', (hotel['id'],))
pages = cur.fetchall()
logger.info(f" 📄 Страниц: {len(pages)}")
# Загружаем в Graphiti
result = await upload_to_graphiti(hotel, pages, group_id)
if result['success']:
successful += 1
else:
failed += 1
# Задержка
await asyncio.sleep(RATE_LIMIT_DELAY)
# Итоги
logger.info(f"\n{'='*70}")
logger.info("📊 ИТОГИ ЗАГРУЗКИ:")
logger.info(f" ✅ Успешно: {successful}/{len(hotels)}")
logger.info(f" ✗ Ошибки: {failed}/{len(hotels)}")
logger.info(f"{'='*70}")
finally:
cur.close()
conn.close()
if __name__ == "__main__":
asyncio.run(main())

Some files were not shown because too many files have changed in this diff Show More