#!/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()