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

- Краулеры: smart_crawler.py, regional_crawler.py
- Аудит: audit_orel_to_excel.py, audit_chukotka_to_excel.py
- РКН проверка: check_rkn_registry.py, recheck_unclear_rkn.py
- Отчёты: create_orel_horizontal_report.py
- Обработка: process_all_hotels_embeddings.py
- Документация: README.md, DB_SCHEMA_REFERENCE.md
This commit is contained in:
Фёдор
2025-10-16 10:52:09 +03:00
parent 545e199389
commit 0cf3297290
105 changed files with 28743 additions and 0 deletions

630
audit_chukotka_to_excel.py Executable file
View File

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