Проект аудита отелей: основные скрипты и документация
- Краулеры: 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:
141
CRAWLER_FIX_REPORT.md
Normal file
141
CRAWLER_FIX_REPORT.md
Normal 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
141
CRAWLER_STATUS.md
Normal 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
226
CRAWLER_WORKFLOW.md
Normal 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
182
DB_SCHEMA_REFERENCE.md
Normal 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
26
Dockerfile
Normal 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
299
LLM_GUIDE.md
Normal 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
130
N8N_FILES_SUMMARY.md
Normal 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
200
N8N_HTTP_REQUEST_NATASHA.md
Normal 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
265
N8N_MERGE_INSTRUCTIONS.md
Normal 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
338
N8N_NATASHA_CURL_IMPORT.md
Normal 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
218
N8N_SETUP.md
Normal 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
226
NATASHA_API_USAGE.md
Normal 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
137
PROGRESS_STATUS.md
Normal 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
180
QUICK_START.md
Normal 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
153
README.md
Normal 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
495
SESSION_HISTORY.md
Normal 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
152
SMART_CRAWLER_STATUS.md
Normal 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
244
add_model.py
Normal 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
630
audit_chukotka_to_excel.py
Executable 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
219
audit_orel_to_excel.py
Normal 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
1085
audit_system.py
Normal file
File diff suppressed because it is too large
Load Diff
1085
audit_system_backup.py
Normal file
1085
audit_system_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
930
audit_system_new.py
Normal file
930
audit_system_new.py
Normal 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
83
check_audit_readiness.py
Executable 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
56
check_audit_records.py
Normal 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
47
check_crawler.py
Normal 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
86
check_crawler_status.sh
Executable 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
100
check_db.py
Normal 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
77
check_graphiti_data.py
Normal 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
117
check_progress.py
Normal 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
50
check_progress.sh
Executable 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
320
check_rkn_registry.py
Normal 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
110
check_rkn_status.sh
Executable 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
443
chukotka_crawler.py
Normal 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
68
crawler_stats.py
Normal 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()
|
||||
454
create_chukotka_horizontal_report.py
Normal file
454
create_chukotka_horizontal_report.py
Normal 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
427
create_chukotka_report.py
Normal 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
276
create_dashboard_excel.py
Normal 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()
|
||||
470
create_orel_horizontal_report.py
Normal file
470
create_orel_horizontal_report.py
Normal 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
64
create_tables.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
|
||||
68
create_user_settings_tables.py
Normal file
68
create_user_settings_tables.py
Normal 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
11
direct_check.py
Normal 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
32
docker-compose.yml
Normal 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
161
embedding_service.py
Normal 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
129
estimate_time.py
Normal 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()
|
||||
|
||||
|
||||
|
||||
|
||||
149
export_website_status_report.py
Normal file
149
export_website_status_report.py
Normal 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
83
find_api.py
Normal 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
216
generate_csv_debug.py
Normal 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
223
generate_csv_fixed.py
Normal 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
281
generate_excel_correct.py
Normal 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
425
generate_excel_final.py
Normal 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()
|
||||
|
||||
261
generate_excel_final_correct.py
Normal file
261
generate_excel_final_correct.py
Normal 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()
|
||||
267
generate_excel_final_fixed.py
Normal file
267
generate_excel_final_fixed.py
Normal 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
425
generate_excel_fixed.py
Normal 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
408
generate_excel_from_db.py
Normal 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()
|
||||
615
generate_excel_from_db_final.py
Normal file
615
generate_excel_from_db_final.py
Normal 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
277
generate_excel_from_json.py
Normal 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
422
generate_excel_working.py
Executable 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
159
generate_simple_csv.py
Normal 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
579
hybrid_audit_chukotka.py
Normal 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
530
kamchatka_crawler.py
Normal file
@@ -0,0 +1,530 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crawler для парсинга сайтов отелей Камчатского края с сохранением в PostgreSQL
|
||||
- Сохраняет сырой HTML (для будущей переобработки)
|
||||
- Сохраняет очищенный текст
|
||||
- Извлекает структурированные данные
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Set, Optional
|
||||
from urllib.parse import urljoin, urlparse, unquote
|
||||
from playwright.async_api import async_playwright, Page
|
||||
from bs4 import BeautifulSoup, Comment
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(f'kamchatka_crawler_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Конфигурация БД
|
||||
DB_CONFIG = {
|
||||
'host': "147.45.189.234",
|
||||
'port': 5432,
|
||||
'database': "default_db",
|
||||
'user': "gen_user",
|
||||
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
|
||||
}
|
||||
|
||||
# Конфигурация краулинга
|
||||
MAX_PAGES_PER_SITE = 20
|
||||
PAGE_TIMEOUT = 45000
|
||||
NAVIGATION_TIMEOUT = 40000
|
||||
GROUP_ID = "hotel_kamchatka" # Для Graphiti
|
||||
RKN_CHECK_DELAY = 2 # Задержка перед проверкой РКН (секунды)
|
||||
|
||||
|
||||
class TextCleaner:
|
||||
"""Продвинутая очистка HTML с сохранением важных данных"""
|
||||
|
||||
# Теги для удаления (только мусор!)
|
||||
REMOVE_TAGS = {
|
||||
'script', 'style', 'meta', 'link', 'noscript', 'iframe', 'embed', 'object',
|
||||
'form', 'input', 'button', 'select', 'textarea', 'label',
|
||||
'canvas', 'svg', 'img', 'video', 'audio', 'source', 'track',
|
||||
'map', 'area', 'base', 'head', 'title'
|
||||
}
|
||||
|
||||
# Теги для сохранения контента (но удаления тега)
|
||||
PRESERVE_CONTENT_TAGS = {
|
||||
'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'a', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'del',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'tr', 'td', 'th',
|
||||
'blockquote', 'pre', 'code', 'br', 'hr'
|
||||
}
|
||||
|
||||
# Ключевые слова для сохранения контента
|
||||
CONTACT_KEYWORDS = {
|
||||
'телефон', 'phone', 'тел', 'контакт', 'contact', 'адрес', 'address',
|
||||
'email', 'почта', 'mail', 'факс', 'fax', 'инн', 'огрн', 'inn', 'ogrn',
|
||||
'режим работы', 'часы работы', 'working hours', 'время работы'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def clean_html(cls, html: str) -> str:
|
||||
"""Простая очистка HTML"""
|
||||
if not html:
|
||||
return ""
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Удаляем скрипты и стили
|
||||
for tag in soup.find_all(['script', 'style']):
|
||||
tag.decompose()
|
||||
|
||||
# Получаем чистый текст
|
||||
text = soup.get_text()
|
||||
|
||||
# Очистка текста
|
||||
text = cls._clean_text(text)
|
||||
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _clean_text(cls, text: str) -> str:
|
||||
"""Дополнительная очистка текста"""
|
||||
# Удаляем лишние пробелы и переносы
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
text = re.sub(r'\n\s*\n', '\n', text)
|
||||
|
||||
# Удаляем пустые строки
|
||||
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
@classmethod
|
||||
def extract_structured_data(cls, text: str) -> Dict[str, List[str]]:
|
||||
"""Извлечение структурированных данных из текста"""
|
||||
data = {
|
||||
'phones': [],
|
||||
'emails': [],
|
||||
'inns': [],
|
||||
'ogrn': []
|
||||
}
|
||||
|
||||
# Телефоны
|
||||
phone_patterns = [
|
||||
r'\+?[78][\s\-\(\)]?\d{3}[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{2}[\s\-\(\)]?\d{2}',
|
||||
r'\+?7[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{2}[\s\-\(\)]?\d{2}',
|
||||
r'\d{3}[\s\-\(\)]?\d{3}[\s\-\(\)]?\d{2}[\s\-\(\)]?\d{2}'
|
||||
]
|
||||
|
||||
for pattern in phone_patterns:
|
||||
matches = re.findall(pattern, text)
|
||||
data['phones'].extend(matches)
|
||||
|
||||
# Email
|
||||
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
|
||||
data['emails'] = re.findall(email_pattern, text)
|
||||
|
||||
# ИНН
|
||||
inn_pattern = r'\b\d{10,12}\b'
|
||||
inns = re.findall(inn_pattern, text)
|
||||
data['inns'] = [inn for inn in inns if len(inn) in [10, 12]]
|
||||
|
||||
# ОГРН
|
||||
ogrn_pattern = r'\b\d{13,15}\b'
|
||||
ogrns = re.findall(ogrn_pattern, text)
|
||||
data['ogrn'] = [ogrn for ogrn in ogrns if len(ogrn) in [13, 15]]
|
||||
|
||||
# Удаляем дубликаты
|
||||
for key in data:
|
||||
data[key] = list(set(data[key]))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class WebsiteCrawler:
|
||||
"""Краулер для сайтов отелей"""
|
||||
|
||||
def __init__(self):
|
||||
self.visited_urls: Set[str] = set()
|
||||
self.db_conn = None
|
||||
self.rkn_page = None # Отдельная страница для проверки РКН
|
||||
|
||||
async def connect_db(self):
|
||||
"""Подключение к БД"""
|
||||
try:
|
||||
self.db_conn = psycopg2.connect(**DB_CONFIG)
|
||||
logger.info(" ✓ Подключено к PostgreSQL")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка подключения к БД: {e}")
|
||||
raise
|
||||
|
||||
def close_db(self):
|
||||
"""Закрытие соединения с БД"""
|
||||
if self.db_conn:
|
||||
self.db_conn.close()
|
||||
|
||||
async def check_rkn_registry(self, inn: str, browser) -> Dict:
|
||||
"""Проверка ИНН в реестре РКН"""
|
||||
if not inn or inn == '-':
|
||||
return {
|
||||
'found': False,
|
||||
'status': 'no_inn',
|
||||
'message': 'ИНН не указан'
|
||||
}
|
||||
|
||||
try:
|
||||
# Создаем отдельную страницу для РКН
|
||||
if not self.rkn_page:
|
||||
self.rkn_page = await browser.new_page()
|
||||
await self.rkn_page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
await self.rkn_page.set_extra_http_headers({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
url = f'https://pd.rkn.gov.ru/operators-registry/operators-list/?act=search&inn={inn}'
|
||||
|
||||
logger.info(f" 🔍 РКН: проверка ИНН {inn}")
|
||||
|
||||
# Задержка перед запросом
|
||||
await asyncio.sleep(RKN_CHECK_DELAY)
|
||||
|
||||
# Загружаем страницу
|
||||
response = await self.rkn_page.goto(url, timeout=30000, wait_until='networkidle')
|
||||
|
||||
if response.status != 200:
|
||||
return {'found': False, 'status': 'error', 'message': f'HTTP {response.status}'}
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Получаем текст
|
||||
text = await self.rkn_page.evaluate('() => document.body.innerText')
|
||||
|
||||
# Проверяем результаты
|
||||
if 'Не найдено' in text or 'не найдено' in text.lower():
|
||||
logger.info(f" ❌ РКН: не найден")
|
||||
return {'found': False, 'status': 'not_found', 'message': 'Не найден в реестре'}
|
||||
|
||||
# Извлекаем данные
|
||||
reg_number_match = re.search(r'(\d{2}-\d{2,4}-\d{6,7})', text)
|
||||
reg_number = reg_number_match.group(1) if reg_number_match else None
|
||||
|
||||
date_match = re.search(r'Приказ.*?(\d{2}\.\d{2}\.\d{4})', text)
|
||||
reg_date = date_match.group(1) if date_match else None
|
||||
|
||||
if reg_number:
|
||||
logger.info(f" ✅ РКН: найден {reg_number} ({reg_date})")
|
||||
return {
|
||||
'found': True,
|
||||
'status': 'found',
|
||||
'reg_number': reg_number,
|
||||
'reg_date': reg_date
|
||||
}
|
||||
else:
|
||||
logger.info(f" ⚠️ РКН: результат неясен")
|
||||
return {'found': None, 'status': 'unclear', 'message': 'Результат неясен'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ РКН: ошибка {e}")
|
||||
return {'found': False, 'status': 'error', 'message': str(e)}
|
||||
|
||||
def save_rkn_result(self, hotel_id: str, result: Dict):
|
||||
"""Сохранение результата проверки РКН в БД"""
|
||||
try:
|
||||
cur = self.db_conn.cursor()
|
||||
|
||||
cur.execute('''
|
||||
UPDATE hotel_main
|
||||
SET
|
||||
rkn_registry_status = %s,
|
||||
rkn_registry_number = %s,
|
||||
rkn_registry_date = %s,
|
||||
rkn_checked_at = %s
|
||||
WHERE id = %s
|
||||
''', (
|
||||
result['status'],
|
||||
result.get('reg_number'),
|
||||
result.get('reg_date'),
|
||||
datetime.now(),
|
||||
hotel_id
|
||||
))
|
||||
|
||||
self.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка сохранения РКН: {e}")
|
||||
self.db_conn.rollback()
|
||||
|
||||
async def crawl_page(self, page: Page, url: str, hotel_id: str, depth: int = 0) -> Dict:
|
||||
"""Краулинг одной страницы"""
|
||||
try:
|
||||
logger.info(f" Парсинг (depth={depth}): {url} ...")
|
||||
|
||||
# Переходим на страницу
|
||||
response = await page.goto(url, timeout=PAGE_TIMEOUT, wait_until='networkidle')
|
||||
|
||||
if not response or response.status >= 400:
|
||||
logger.warning(f" ✗ Ошибка загрузки: {response.status if response else 'No response'}")
|
||||
return {'success': False, 'status_code': response.status if response else 0}
|
||||
|
||||
# Получаем HTML
|
||||
html = await page.content()
|
||||
|
||||
# Очищаем HTML
|
||||
clean_text = TextCleaner.clean_html(html)
|
||||
|
||||
# Извлекаем структурированные данные
|
||||
structured_data = TextCleaner.extract_structured_data(clean_text)
|
||||
|
||||
# Получаем заголовок
|
||||
title = await page.title()
|
||||
|
||||
# Сохраняем в БД
|
||||
await self.save_to_db(
|
||||
hotel_id=hotel_id,
|
||||
url=url,
|
||||
title=title,
|
||||
html=html,
|
||||
clean_text=clean_text,
|
||||
structured_data=structured_data,
|
||||
status_code=response.status,
|
||||
depth=depth
|
||||
)
|
||||
|
||||
logger.info(f" ✓ Сохранено {len(clean_text)} символов в БД")
|
||||
|
||||
# Ищем внутренние ссылки
|
||||
internal_links = await self.find_internal_links(page, url)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'status_code': response.status,
|
||||
'internal_links': internal_links,
|
||||
'text_length': len(clean_text)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка парсинга {url}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def find_internal_links(self, page: Page, base_url: str) -> List[str]:
|
||||
"""Поиск внутренних ссылок"""
|
||||
try:
|
||||
# Получаем все ссылки
|
||||
links = await page.evaluate('''
|
||||
() => {
|
||||
const links = Array.from(document.querySelectorAll('a[href]'));
|
||||
return links.map(link => link.href);
|
||||
}
|
||||
''')
|
||||
|
||||
# Фильтруем внутренние ссылки
|
||||
base_domain = urlparse(base_url).netloc
|
||||
internal_links = []
|
||||
|
||||
for link in links:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
if parsed.netloc == base_domain and link not in self.visited_urls:
|
||||
internal_links.append(link)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Ограничиваем количество ссылок
|
||||
internal_links = internal_links[:MAX_PAGES_PER_SITE]
|
||||
|
||||
logger.info(f" Найдено {len(internal_links)} внутренних ссылок")
|
||||
return internal_links
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка поиска ссылок: {e}")
|
||||
return []
|
||||
|
||||
async def save_to_db(self, hotel_id: str, url: str, title: str, html: str,
|
||||
clean_text: str, structured_data: Dict, status_code: int, depth: int):
|
||||
"""Сохранение данных в БД"""
|
||||
try:
|
||||
cur = self.db_conn.cursor()
|
||||
|
||||
# Сохраняем сырые данные
|
||||
cur.execute('''
|
||||
INSERT INTO hotel_website_raw
|
||||
(hotel_id, url, page_title, html, status_code, response_time_ms, depth, crawled_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
''', (
|
||||
hotel_id, url, title, html, status_code, 0, depth, datetime.now()
|
||||
))
|
||||
|
||||
# Сохраняем метаданные (используем правильную структуру таблицы)
|
||||
cur.execute('''
|
||||
INSERT INTO hotel_website_meta
|
||||
(hotel_id, domain, main_url, pages_crawled, pages_failed, total_size_bytes,
|
||||
internal_links_found, crawl_status, crawl_started_at, crawl_finished_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||
pages_crawled = hotel_website_meta.pages_crawled + 1,
|
||||
total_size_bytes = hotel_website_meta.total_size_bytes + %s,
|
||||
crawl_finished_at = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
''', (
|
||||
hotel_id,
|
||||
urlparse(url).netloc, # domain
|
||||
url, # main_url
|
||||
1, # pages_crawled
|
||||
0, # pages_failed
|
||||
len(clean_text), # total_size_bytes
|
||||
0, # internal_links_found (будет обновлено позже)
|
||||
'completed', # crawl_status
|
||||
datetime.now(), # crawl_started_at
|
||||
datetime.now(), # crawl_finished_at
|
||||
len(clean_text), # для ON CONFLICT
|
||||
datetime.now() # для ON CONFLICT
|
||||
))
|
||||
|
||||
self.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Ошибка сохранения в БД: {e}")
|
||||
self.db_conn.rollback()
|
||||
|
||||
async def crawl_hotel(self, hotel_data: Dict) -> Dict:
|
||||
"""Краулинг одного отеля"""
|
||||
hotel_id = hotel_data['id']
|
||||
hotel_name = hotel_data['full_name']
|
||||
website_url = hotel_data.get('website_address')
|
||||
owner_inn = hotel_data.get('owner_inn')
|
||||
|
||||
logger.info(f"\n{'='*70}")
|
||||
logger.info(f"🏨 «{hotel_name}»")
|
||||
logger.info(f"🌐 {website_url or 'Нет сайта'}")
|
||||
if owner_inn:
|
||||
logger.info(f"🔢 ИНН: {owner_inn}")
|
||||
logger.info(f"{'='*70}")
|
||||
|
||||
if not website_url or website_url in ['-', 'Нет сайта', '']:
|
||||
logger.info(" ⏭️ Пропуск - нет сайта")
|
||||
return {'success': False, 'reason': 'no_website'}
|
||||
|
||||
# Нормализуем URL
|
||||
if not website_url.startswith(('http://', 'https://')):
|
||||
website_url = 'https://' + website_url
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
# Настройки страницы
|
||||
await page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
await page.set_extra_http_headers({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
# Краулинг главной страницы
|
||||
result = await self.crawl_page(page, website_url, hotel_id, depth=0)
|
||||
|
||||
# Проверяем в реестре РКН (если есть ИНН)
|
||||
if owner_inn and result['success']:
|
||||
rkn_result = await self.check_rkn_registry(owner_inn, browser)
|
||||
self.save_rkn_result(hotel_id, rkn_result)
|
||||
|
||||
if not result['success']:
|
||||
await browser.close()
|
||||
return result
|
||||
|
||||
# Краулинг внутренних страниц
|
||||
internal_links = result.get('internal_links', [])
|
||||
pages_crawled = 1
|
||||
|
||||
for link in internal_links[:MAX_PAGES_PER_SITE]:
|
||||
if link not in self.visited_urls:
|
||||
self.visited_urls.add(link)
|
||||
await self.crawl_page(page, link, hotel_id, depth=1)
|
||||
pages_crawled += 1
|
||||
|
||||
await browser.close()
|
||||
|
||||
logger.info(f"✓ Спарсено {pages_crawled} страниц")
|
||||
return {'success': True, 'pages_crawled': pages_crawled}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Ошибка краулинга: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Основная функция"""
|
||||
logger.info("")
|
||||
logger.info("="*70)
|
||||
logger.info("🚀 ЗАПУСК КРАУЛИНГА КАМЧАТСКИХ ОТЕЛЕЙ С СОХРАНЕНИЕМ В POSTGRESQL")
|
||||
logger.info("="*70)
|
||||
|
||||
crawler = WebsiteCrawler()
|
||||
|
||||
try:
|
||||
# Подключаемся к БД
|
||||
await crawler.connect_db()
|
||||
|
||||
# Получаем камчатские отели с сайтами
|
||||
cur = crawler.db_conn.cursor()
|
||||
cur.execute('''
|
||||
SELECT id, full_name, website_address, owner_inn
|
||||
FROM hotel_main
|
||||
WHERE region_name ILIKE '%камчат%'
|
||||
AND website_address IS NOT NULL
|
||||
AND website_address != '-'
|
||||
AND website_address != ''
|
||||
ORDER BY full_name
|
||||
''')
|
||||
|
||||
hotels = [{'id': row[0], 'full_name': row[1], 'website_address': row[2], 'owner_inn': row[3]} for row in cur.fetchall()]
|
||||
cur.close()
|
||||
|
||||
# Добавляем колонки для РКН (если их нет)
|
||||
cur = crawler.db_conn.cursor()
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_status VARCHAR(50);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_number VARCHAR(50);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_date VARCHAR(20);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_checked_at TIMESTAMP;')
|
||||
crawler.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
logger.info(f"📊 Отелей: {len(hotels)}")
|
||||
logger.info(f"💾 Таблицы: hotel_website_raw, hotel_website_meta")
|
||||
logger.info("="*70)
|
||||
|
||||
# Краулинг отелей
|
||||
successful = 0
|
||||
failed = 0
|
||||
|
||||
for i, hotel in enumerate(hotels, 1):
|
||||
logger.info(f"\n[{i}/{len(hotels)}] {'='*35}")
|
||||
|
||||
result = await crawler.crawl_hotel(hotel)
|
||||
|
||||
if result['success']:
|
||||
successful += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Итоги
|
||||
logger.info(f"\n{'='*70}")
|
||||
logger.info("📊 ИТОГИ:")
|
||||
logger.info(f" ✅ Успешно: {successful}/{len(hotels)}")
|
||||
logger.info(f" ✗ Ошибки: {failed}/{len(hotels)}")
|
||||
logger.info("="*70)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка: {e}")
|
||||
finally:
|
||||
crawler.close_db()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
256
llm_client.py
Normal file
256
llm_client.py
Normal 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
191
llm_config.py
Normal 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
319
mass_crawler.py
Executable 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
203
memory_agent.py
Normal 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
289
merge_audit_results.py
Normal 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
277
model_providers.py
Normal 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
90
monitor_db_recovery.py
Normal 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
68
natasha_curl_example.sh
Normal 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
241
natasha_ner_api.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
396
process_all_hotels_embeddings.py
Normal file
396
process_all_hotels_embeddings.py
Normal 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
130
process_all_regions.py
Normal 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
76
process_kamchatka_only.py
Normal 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
237
process_orel_embeddings.py
Normal 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
76
process_orel_only.py
Normal 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
200
process_raw_to_cleaned.py
Normal 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)
|
||||
|
||||
185
process_spb_raw_to_processed.py
Normal file
185
process_spb_raw_to_processed.py
Normal 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
187
process_spb_robust.py
Normal 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
140
process_spb_simple.py
Normal 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
43
quick_check.py
Normal 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
240
recheck_unclear_rkn.py
Normal 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
273
regional_crawler.py
Executable 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
281
rescan_10_pages.py
Normal 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
12
run_check.py
Normal 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
481
scraper_detailed.py
Normal 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
196
scraper_missing.py
Normal 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
305
scraper_safe.py
Normal 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
107
search_hotel_content.py
Normal 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
389
semantic_audit_chukotka.py
Normal 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
294
semantic_search_api.py
Normal 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
173
simple_web.py
Normal 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
184
single_hotel_crawler.py
Normal 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
466
smart_crawler.py
Executable 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
364
test_comfort_hotel.py
Normal 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
72
test_data_processing.py
Normal 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
53
test_rkn_fix.py
Normal 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()
|
||||
|
||||
248
test_semantic_search_chukotka.py
Normal file
248
test_semantic_search_chukotka.py
Normal 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
527
universal_crawler.py
Normal file
@@ -0,0 +1,527 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Универсальный краулер для парсинга сайтов отелей с проверкой РКН
|
||||
- Парсит сайт отеля (главная + depth 1)
|
||||
- Сразу проверяет ИНН в реестре Роскомнадзора
|
||||
- Сохраняет все данные в PostgreSQL
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Set, Optional
|
||||
from urllib.parse import urljoin, urlparse, unquote
|
||||
from playwright.async_api import async_playwright, Page
|
||||
from bs4 import BeautifulSoup, Comment
|
||||
|
||||
# Конфигурация БД
|
||||
DB_CONFIG = {
|
||||
'host': "147.45.189.234",
|
||||
'port': 5432,
|
||||
'database': "default_db",
|
||||
'user': "gen_user",
|
||||
'password': unquote("2~~9_%5EkVsU%3F2%5CS")
|
||||
}
|
||||
|
||||
# Конфигурация краулинга
|
||||
MAX_PAGES_PER_SITE = 20
|
||||
PAGE_TIMEOUT = 45000
|
||||
RKN_CHECK_DELAY = 2 # Задержка перед проверкой РКН
|
||||
|
||||
# Типичные URL для проверки (важные страницы отелей)
|
||||
TYPICAL_URLS = [
|
||||
'/pravila', '/rules', '/terms', '/conditions',
|
||||
'/services', '/uslugi', '/price', '/prices', '/ceny',
|
||||
'/booking', '/book', '/bronirование', '/reserve',
|
||||
'/faq', '/contacts', '/kontakty', '/about', '/o-nas',
|
||||
'/policy', '/politika', '/privacy', '/oferta', '/offer',
|
||||
'/dogovor', '/contract', '/agreement', '/soglashenie',
|
||||
'/reviews', '/otzyvy', '/gallery', '/galereya',
|
||||
'/rooms', '/nomera', '/accommodation', '/razmeshenie'
|
||||
]
|
||||
|
||||
|
||||
class TextCleaner:
|
||||
"""Простая очистка HTML"""
|
||||
|
||||
@classmethod
|
||||
def clean_html(cls, html: str) -> str:
|
||||
"""Простая очистка HTML"""
|
||||
if not html:
|
||||
return ""
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Удаляем скрипты и стили
|
||||
for tag in soup.find_all(['script', 'style']):
|
||||
tag.decompose()
|
||||
|
||||
# Получаем чистый текст
|
||||
text = soup.get_text()
|
||||
|
||||
# Очистка текста
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
text = re.sub(r'\n\s*\n', '\n', text)
|
||||
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class UniversalCrawler:
|
||||
"""Универсальный краулер с проверкой РКН"""
|
||||
|
||||
def __init__(self, region_name: str):
|
||||
self.region_name = region_name
|
||||
self.visited_urls: Set[str] = set()
|
||||
self.db_conn = None
|
||||
self.rkn_page = None
|
||||
|
||||
# Настройка логирования
|
||||
log_filename = f'crawler_{region_name.replace(" ", "_")}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
|
||||
self.logger = logging.getLogger(f'crawler_{region_name}')
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
# Хендлеры
|
||||
fh = logging.FileHandler(log_filename)
|
||||
ch = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
fh.setFormatter(formatter)
|
||||
ch.setFormatter(formatter)
|
||||
self.logger.addHandler(fh)
|
||||
self.logger.addHandler(ch)
|
||||
|
||||
async def connect_db(self):
|
||||
"""Подключение к БД"""
|
||||
try:
|
||||
self.db_conn = psycopg2.connect(**DB_CONFIG)
|
||||
self.logger.info("✓ Подключено к PostgreSQL")
|
||||
|
||||
# Добавляем колонки для РКН (если их нет)
|
||||
cur = self.db_conn.cursor()
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_status VARCHAR(50);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_number VARCHAR(50);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_registry_date VARCHAR(20);')
|
||||
cur.execute('ALTER TABLE hotel_main ADD COLUMN IF NOT EXISTS rkn_checked_at TIMESTAMP;')
|
||||
self.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"✗ Ошибка подключения к БД: {e}")
|
||||
raise
|
||||
|
||||
def close_db(self):
|
||||
"""Закрытие соединения с БД"""
|
||||
if self.db_conn:
|
||||
self.db_conn.close()
|
||||
|
||||
async def check_rkn_registry(self, inn: str, browser) -> Dict:
|
||||
"""Проверка ИНН в реестре РКН"""
|
||||
if not inn or inn == '-':
|
||||
return {'found': False, 'status': 'no_inn'}
|
||||
|
||||
try:
|
||||
# Создаем отдельную страницу для РКН
|
||||
if not self.rkn_page:
|
||||
self.rkn_page = await browser.new_page()
|
||||
await self.rkn_page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
await self.rkn_page.set_extra_http_headers({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
url = f'https://pd.rkn.gov.ru/operators-registry/operators-list/?act=search&inn={inn}'
|
||||
|
||||
self.logger.info(f" 🔍 РКН: проверка ИНН {inn}")
|
||||
|
||||
# Задержка перед запросом
|
||||
await asyncio.sleep(RKN_CHECK_DELAY)
|
||||
|
||||
# Загружаем страницу
|
||||
response = await self.rkn_page.goto(url, timeout=30000, wait_until='networkidle')
|
||||
|
||||
if response.status != 200:
|
||||
return {'found': False, 'status': 'error'}
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Получаем текст
|
||||
text = await self.rkn_page.evaluate('() => document.body.innerText')
|
||||
|
||||
# Проверяем результаты
|
||||
if 'Не найдено' in text or 'не найдено' in text.lower():
|
||||
self.logger.info(f" ❌ РКН: не найден")
|
||||
return {'found': False, 'status': 'not_found'}
|
||||
|
||||
# Извлекаем данные (разные форматы: 41-14-000746 или 10-0107355)
|
||||
reg_number_match = re.search(r'(\d{2}-\d{2,4}-\d{6,7})', text)
|
||||
reg_number = reg_number_match.group(1) if reg_number_match else None
|
||||
|
||||
date_match = re.search(r'Приказ.*?(\d{2}\.\d{2}\.\d{4})', text)
|
||||
reg_date = date_match.group(1) if date_match else None
|
||||
|
||||
if reg_number:
|
||||
self.logger.info(f" ✅ РКН: найден {reg_number} ({reg_date})")
|
||||
return {
|
||||
'found': True,
|
||||
'status': 'found',
|
||||
'reg_number': reg_number,
|
||||
'reg_date': reg_date
|
||||
}
|
||||
else:
|
||||
self.logger.info(f" ⚠️ РКН: результат неясен")
|
||||
return {'found': None, 'status': 'unclear'}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f" ✗ РКН: ошибка {e}")
|
||||
return {'found': False, 'status': 'error'}
|
||||
|
||||
def save_rkn_result(self, hotel_id: str, result: Dict):
|
||||
"""Сохранение результата проверки РКН"""
|
||||
try:
|
||||
cur = self.db_conn.cursor()
|
||||
|
||||
cur.execute('''
|
||||
UPDATE hotel_main
|
||||
SET
|
||||
rkn_registry_status = %s,
|
||||
rkn_registry_number = %s,
|
||||
rkn_registry_date = %s,
|
||||
rkn_checked_at = %s
|
||||
WHERE id = %s
|
||||
''', (
|
||||
result['status'],
|
||||
result.get('reg_number'),
|
||||
result.get('reg_date'),
|
||||
datetime.now(),
|
||||
hotel_id
|
||||
))
|
||||
|
||||
self.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f" ✗ Ошибка сохранения РКН: {e}")
|
||||
self.db_conn.rollback()
|
||||
|
||||
async def crawl_page(self, page: Page, url: str, hotel_id: str, depth: int = 0) -> Dict:
|
||||
"""Краулинг одной страницы"""
|
||||
try:
|
||||
self.logger.info(f" Парсинг (depth={depth}): {url[:60]}...")
|
||||
|
||||
response = await page.goto(url, timeout=PAGE_TIMEOUT, wait_until='networkidle')
|
||||
|
||||
if not response or response.status >= 400:
|
||||
self.logger.warning(f" ✗ Ошибка загрузки: {response.status if response else 'No response'}")
|
||||
return {'success': False, 'status_code': response.status if response else 0}
|
||||
|
||||
# Получаем HTML
|
||||
html = await page.content()
|
||||
|
||||
# Очищаем HTML
|
||||
clean_text = TextCleaner.clean_html(html)
|
||||
|
||||
# Получаем заголовок
|
||||
title = await page.title()
|
||||
|
||||
# Получаем Last-Modified из заголовков
|
||||
last_modified = response.headers.get('last-modified', None)
|
||||
|
||||
# Сохраняем в БД
|
||||
await self.save_to_db(hotel_id, url, title, html, clean_text, response.status, depth, last_modified)
|
||||
|
||||
self.logger.info(f" ✓ Сохранено {len(clean_text)} символов")
|
||||
|
||||
# Ищем внутренние ссылки
|
||||
internal_links = await self.find_internal_links(page, url)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'status_code': response.status,
|
||||
'internal_links': internal_links
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f" ✗ Ошибка парсинга: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def check_typical_urls(self, page: Page, base_url: str) -> List[str]:
|
||||
"""Проверяет типичные URL и возвращает существующие"""
|
||||
found_urls = []
|
||||
parsed_base = urlparse(base_url)
|
||||
base_domain = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
||||
|
||||
self.logger.info(f" 🔍 Проверка типичных URL...")
|
||||
|
||||
for typical_path in TYPICAL_URLS:
|
||||
typical_url = base_domain + typical_path
|
||||
|
||||
# Пропускаем если уже посетили
|
||||
if typical_url in self.visited_urls:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Пробуем загрузить страницу (быстро, timeout=5сек)
|
||||
response = await page.goto(typical_url, timeout=5000, wait_until='domcontentloaded')
|
||||
|
||||
if response and response.status == 200:
|
||||
found_urls.append(typical_url)
|
||||
self.logger.info(f" ✓ Найден: {typical_path}")
|
||||
|
||||
except Exception:
|
||||
# Страница не существует или недоступна - это нормально
|
||||
pass
|
||||
|
||||
self.logger.info(f" Найдено {len(found_urls)} типичных страниц")
|
||||
return found_urls
|
||||
|
||||
async def find_internal_links(self, page: Page, base_url: str) -> List[str]:
|
||||
"""Поиск внутренних ссылок"""
|
||||
try:
|
||||
links = await page.evaluate('() => Array.from(document.querySelectorAll("a[href]")).map(link => link.href)')
|
||||
|
||||
base_domain = urlparse(base_url).netloc
|
||||
internal_links = []
|
||||
|
||||
for link in links:
|
||||
try:
|
||||
parsed = urlparse(link)
|
||||
if parsed.netloc == base_domain and link not in self.visited_urls:
|
||||
internal_links.append(link)
|
||||
except:
|
||||
continue
|
||||
|
||||
internal_links = internal_links[:MAX_PAGES_PER_SITE]
|
||||
self.logger.info(f" Найдено {len(internal_links)} внутренних ссылок")
|
||||
return internal_links
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f" ✗ Ошибка поиска ссылок: {e}")
|
||||
return []
|
||||
|
||||
async def save_to_db(self, hotel_id: str, url: str, title: str, html: str,
|
||||
clean_text: str, status_code: int, depth: int, last_modified: str = None):
|
||||
"""Сохранение данных в БД"""
|
||||
try:
|
||||
cur = self.db_conn.cursor()
|
||||
|
||||
# Проверяем есть ли уже эта страница
|
||||
cur.execute('''
|
||||
SELECT id FROM hotel_website_raw
|
||||
WHERE hotel_id = %s AND url = %s
|
||||
''', (hotel_id, url))
|
||||
|
||||
if cur.fetchone():
|
||||
# Страница уже есть - пропускаем
|
||||
cur.close()
|
||||
return
|
||||
|
||||
# Парсим last_modified в datetime если есть
|
||||
last_modified_dt = None
|
||||
if last_modified:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
last_modified_dt = parsedate_to_datetime(last_modified)
|
||||
except Exception as e:
|
||||
self.logger.warning(f" ⚠️ Не удалось распарсить Last-Modified: {e}")
|
||||
|
||||
# Сохраняем сырые данные
|
||||
cur.execute('''
|
||||
INSERT INTO hotel_website_raw
|
||||
(hotel_id, url, page_title, html, status_code, response_time_ms, depth, crawled_at, last_modified)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
''', (hotel_id, url, title, html, status_code, 0, depth, datetime.now(), last_modified_dt))
|
||||
|
||||
# Сохраняем метаданные
|
||||
cur.execute('''
|
||||
INSERT INTO hotel_website_meta
|
||||
(hotel_id, domain, main_url, pages_crawled, pages_failed, total_size_bytes,
|
||||
internal_links_found, crawl_status, crawl_started_at, crawl_finished_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||
pages_crawled = hotel_website_meta.pages_crawled + 1,
|
||||
total_size_bytes = hotel_website_meta.total_size_bytes + %s,
|
||||
crawl_finished_at = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
''', (
|
||||
hotel_id, urlparse(url).netloc, url, 1, 0, len(clean_text), 0,
|
||||
'completed', datetime.now(), datetime.now(),
|
||||
len(clean_text), datetime.now()
|
||||
))
|
||||
|
||||
self.db_conn.commit()
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f" ✗ Ошибка сохранения в БД: {e}")
|
||||
self.db_conn.rollback()
|
||||
|
||||
async def crawl_hotel(self, hotel_data: Dict, browser) -> Dict:
|
||||
"""Краулинг одного отеля + проверка РКН"""
|
||||
hotel_id = hotel_data['id']
|
||||
hotel_name = hotel_data['full_name']
|
||||
website_url = hotel_data.get('website_address')
|
||||
owner_inn = hotel_data.get('owner_inn')
|
||||
|
||||
self.logger.info(f"\n{'='*70}")
|
||||
self.logger.info(f"🏨 {hotel_name}")
|
||||
self.logger.info(f"🌐 {website_url or 'Нет сайта'}")
|
||||
if owner_inn:
|
||||
self.logger.info(f"🔢 ИНН: {owner_inn}")
|
||||
self.logger.info(f"{'='*70}")
|
||||
|
||||
if not website_url or website_url in ['-', 'Нет сайта', '']:
|
||||
self.logger.info(" ⏭️ Пропуск - нет сайта")
|
||||
return {'success': False, 'reason': 'no_website'}
|
||||
|
||||
# Нормализуем URL
|
||||
if not website_url.startswith(('http://', 'https://')):
|
||||
website_url = 'https://' + website_url
|
||||
|
||||
try:
|
||||
page = await browser.new_page()
|
||||
await page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
await page.set_extra_http_headers({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
# 1. Краулинг главной страницы
|
||||
result = await self.crawl_page(page, website_url, hotel_id, depth=0)
|
||||
|
||||
if not result['success']:
|
||||
await page.close()
|
||||
return result
|
||||
|
||||
# 2. Проверка типичных URL (правила, цены, контакты и т.д.)
|
||||
typical_urls = await self.check_typical_urls(page, website_url)
|
||||
|
||||
# 3. Проверка в реестре РКН (если есть ИНН и сайт доступен)
|
||||
if owner_inn:
|
||||
rkn_result = await self.check_rkn_registry(owner_inn, browser)
|
||||
self.save_rkn_result(hotel_id, rkn_result)
|
||||
|
||||
# 4. Краулинг типичных страниц
|
||||
pages_crawled = 1
|
||||
for typical_url in typical_urls:
|
||||
if typical_url not in self.visited_urls:
|
||||
self.visited_urls.add(typical_url)
|
||||
await self.crawl_page(page, typical_url, hotel_id, depth=1)
|
||||
pages_crawled += 1
|
||||
|
||||
# 5. Краулинг остальных внутренних страниц (если есть место)
|
||||
internal_links = result.get('internal_links', [])
|
||||
remaining_slots = MAX_PAGES_PER_SITE - pages_crawled
|
||||
|
||||
for link in internal_links[:remaining_slots]:
|
||||
if link not in self.visited_urls:
|
||||
self.visited_urls.add(link)
|
||||
await self.crawl_page(page, link, hotel_id, depth=1)
|
||||
pages_crawled += 1
|
||||
|
||||
await page.close()
|
||||
|
||||
self.logger.info(f"✓ Спарсено {pages_crawled} страниц")
|
||||
return {'success': True, 'pages_crawled': pages_crawled}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"✗ Ошибка краулинга: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Основная функция"""
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Использование: python universal_crawler.py <регион>")
|
||||
print("Пример: python universal_crawler.py 'Камчатский край'")
|
||||
sys.exit(1)
|
||||
|
||||
region_name = sys.argv[1]
|
||||
|
||||
crawler = UniversalCrawler(region_name)
|
||||
|
||||
try:
|
||||
# Подключаемся к БД
|
||||
await crawler.connect_db()
|
||||
|
||||
# Получаем отели региона с сайтами
|
||||
cur = crawler.db_conn.cursor()
|
||||
cur.execute('''
|
||||
SELECT id, full_name, website_address, owner_inn
|
||||
FROM hotel_main
|
||||
WHERE region_name ILIKE %s
|
||||
AND website_address IS NOT NULL
|
||||
AND website_address != '-'
|
||||
AND website_address != ''
|
||||
ORDER BY full_name
|
||||
''', (f'%{region_name}%',))
|
||||
|
||||
hotels = [{'id': row[0], 'full_name': row[1], 'website_address': row[2], 'owner_inn': row[3]}
|
||||
for row in cur.fetchall()]
|
||||
cur.close()
|
||||
|
||||
crawler.logger.info(f"\n{'='*70}")
|
||||
crawler.logger.info(f"🚀 ЗАПУСК КРАУЛИНГА: {region_name}")
|
||||
crawler.logger.info(f"📊 Отелей с сайтами: {len(hotels)}")
|
||||
crawler.logger.info(f"⏱️ Примерное время: {len(hotels) * (5 + RKN_CHECK_DELAY) / 60:.1f} минут")
|
||||
crawler.logger.info(f"{'='*70}")
|
||||
|
||||
# Открываем браузер один раз для всех отелей
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
|
||||
# Краулинг отелей
|
||||
successful = 0
|
||||
failed = 0
|
||||
rkn_found = 0
|
||||
rkn_not_found = 0
|
||||
|
||||
for i, hotel in enumerate(hotels, 1):
|
||||
crawler.logger.info(f"\n[{i}/{len(hotels)}] {'='*35}")
|
||||
|
||||
result = await crawler.crawl_hotel(hotel, browser)
|
||||
|
||||
if result['success']:
|
||||
successful += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
await browser.close()
|
||||
|
||||
# Подсчитываем результаты РКН
|
||||
cur = crawler.db_conn.cursor()
|
||||
cur.execute('''
|
||||
SELECT
|
||||
COUNT(CASE WHEN rkn_registry_status = 'found' THEN 1 END) as found,
|
||||
COUNT(CASE WHEN rkn_registry_status = 'not_found' THEN 1 END) as not_found,
|
||||
COUNT(CASE WHEN rkn_registry_status = 'unclear' THEN 1 END) as unclear
|
||||
FROM hotel_main
|
||||
WHERE region_name ILIKE %s
|
||||
''', (f'%{region_name}%',))
|
||||
|
||||
rkn_stats = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
# Итоги
|
||||
crawler.logger.info(f"\n{'='*70}")
|
||||
crawler.logger.info("📊 ИТОГИ КРАУЛИНГА:")
|
||||
crawler.logger.info(f" ✅ Успешно: {successful}/{len(hotels)}")
|
||||
crawler.logger.info(f" ✗ Ошибки: {failed}/{len(hotels)}")
|
||||
crawler.logger.info(f"\n📋 ИТОГИ ПРОВЕРКИ РКН:")
|
||||
crawler.logger.info(f" ✅ Найдено в реестре: {rkn_stats[0]}")
|
||||
crawler.logger.info(f" ❌ Не найдено: {rkn_stats[1]}")
|
||||
crawler.logger.info(f" ❓ Неясно: {rkn_stats[2]}")
|
||||
crawler.logger.info(f"{'='*70}")
|
||||
|
||||
except Exception as e:
|
||||
crawler.logger.error(f"❌ Критическая ошибка: {e}")
|
||||
finally:
|
||||
crawler.close_db()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
201
update_website_status.py
Normal file
201
update_website_status.py
Normal 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
194
upload_to_graphiti.py
Normal 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
Reference in New Issue
Block a user