FIX: Restore reply.py, add new mail domains, fix sync pipeline, update to v2.2.0
22
.gitignore
vendored
|
|
@ -37,3 +37,25 @@ backups/
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Scraper GIS
|
||||||
|
scraper_gis/browser_context/
|
||||||
|
scraper_gis/data/
|
||||||
|
scraper_gis/downloads/
|
||||||
|
scraper_gis/logs/
|
||||||
|
scraper_gis/screenshots/
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
# Screen captures
|
||||||
|
scraper_gis/*.png
|
||||||
|
scraper_gis/dump.html
|
||||||
|
# Virtual Env
|
||||||
|
venv/
|
||||||
|
venv-google/
|
||||||
|
# DBs
|
||||||
|
database/*.db
|
||||||
|
database/*.sqlite3
|
||||||
|
# Other
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
|
||||||
14
SPECKIT_CONSTITUTION.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 📜 Domovoy Bot Reborn: Конституция
|
||||||
|
|
||||||
|
## 1. СТАНДАРТЫ ИНТЕРФЕЙСА
|
||||||
|
- **Inline Only**: Все новые меню должны использовать `InlineKeyboardMarkup`.
|
||||||
|
- **Edit instead of Post**: При навигации по меню использовать `edit_text` или `edit_reply_markup` вместо отправки новых сообщений (где это логично).
|
||||||
|
- **Emojification**: Использовать стандартизированные эмодзи для разделов.
|
||||||
|
|
||||||
|
## 2. СТАНДАРТЫ КОДА
|
||||||
|
- Асинхронность везде. Никаких блокирующих `time.sleep` или `subprocess.run`.
|
||||||
|
- SQLAlchemy ORM для всех операций с БД.
|
||||||
|
- Логирование через `logger.info/error`.
|
||||||
|
|
||||||
|
## 3. БЭКАПЫ
|
||||||
|
- База данных бэкапится ежедневно в 00:00 на Synology.
|
||||||
9
SPECKIT_TASKS.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# ✅ Domovoy Bot Reborn: Tasks
|
||||||
|
|
||||||
|
- [x] **Stage 1 (Foundation):** Включить WAL режим для SQLite.
|
||||||
|
- [x] **Stage 1 (Foundation):** Добавить индексы в таблицу User (ID, Username, Phone, Admin).
|
||||||
|
- [x] **Stage 1 (Foundation):** Исправить баги полей staircase/floor в User.
|
||||||
|
- [x] **Stage 1 (Foundation):** Настроить ежедневный бэкап (00:00).
|
||||||
|
- [ ] **Stage 2 (UX/UI):** Заменить Reply-меню на Inline в `handlers/users.py`.
|
||||||
|
- [ ] **Stage 2 (UX/UI):** Обновить меню админа на Inline.
|
||||||
|
- [ ] **Stage 3 (AI):** Внедрить FAQ на базе NotebookLM.
|
||||||
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
2.2.0
|
||||||
345
data/knowledge_base.txt
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
# 🏛️ Глубокая карта Личного Кабинета ГИС ЖКХ
|
||||||
|
|
||||||
|
## 📁 Регламенты и инструкции
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations`
|
||||||
|
- **Скриншот**: `screenshots/deep_Регламенты_и_инструкции.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Все пользователи
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=1`
|
||||||
|
- **Скриншот**: `screenshots/deep_Все_пользователи.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Граждане
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=2`
|
||||||
|
- **Скриншот**: `screenshots/deep_Граждане.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Кредитные организации и банковские агенты
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=9`
|
||||||
|
- **Скриншот**: `screenshots/deep_Кредитные_организации_и_банковские_агенты.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Органы государственного жилищного надзора
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=8`
|
||||||
|
- **Скриншот**: `screenshots/deep_Органы_государственного_жилищного_надзора.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Органы государственной власти
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=3`
|
||||||
|
- **Скриншот**: `screenshots/deep_Органы_государственной_власти.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Органы местного самоуправления
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=4`
|
||||||
|
- **Скриншот**: `screenshots/deep_Органы_местного_самоуправления.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Ответственные за реализацию приоритетного проекта "Формирование комфортной городской среды"
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=14`
|
||||||
|
- **Скриншот**: `screenshots/deep_Ответственные_за_реализацию_приоритетного_проекта_Формирование_комфортной_городской_среды.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Региональный оператор капитального ремонта
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=10`
|
||||||
|
- **Скриншот**: `screenshots/deep_Региональный_оператор_капитального_ремонта.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Региональный оператор по обращению с твердыми коммунальными отходами
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=12`
|
||||||
|
- **Скриншот**: `screenshots/deep_Региональный_оператор_по_обращению_с_твердыми_коммунальными_отходами.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Ресурсоснабжающие организации
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=7`
|
||||||
|
- **Скриншот**: `screenshots/deep_Ресурсоснабжающие_организации.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 РКЦ, ЕИРЦ и платежные агенты
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=13`
|
||||||
|
- **Скриншот**: `screenshots/deep_РКЦ_ЕИРЦ_и_платежные_агенты.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Управляющие организации, ТСЖ, ЖК, ЖСК
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=6`
|
||||||
|
- **Скриншот**: `screenshots/deep_Управляющие_организации_ТСЖ_ЖК_ЖСК.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Федеральные органы власти
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=5`
|
||||||
|
- **Скриншот**: `screenshots/deep_Федеральные_органы_власти.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Фонд содействия реформированию ЖКХ
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=11`
|
||||||
|
- **Скриншот**: `screenshots/deep_Фонд_содействия_реформированию_ЖКХ.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Найти`
|
||||||
|
- [Button] `Очистить`
|
||||||
|
- [Button] `Выбрать все`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
- [Button] `Скачать`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Карта сайта
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/site-map`
|
||||||
|
- **Скриншот**: `screenshots/deep_Карта_сайта.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Отменить`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 ГИС ЖКХ
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru//#!/main`
|
||||||
|
- **Скриншот**: `screenshots/deep_ГИС_ЖКХ.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Все сервисы`
|
||||||
|
- [Button] `Законодательство по ГИС ЖКХ`
|
||||||
|
- [Button] `Законодательство РФ`
|
||||||
|
- [Button] `Все мероприятия`
|
||||||
|
- [Button] `Все ссылки`
|
||||||
|
- [Button] `Отменить`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Обращение
|
||||||
|
в службу поддержки
|
||||||
|
- **URL**: `https://dom.gosuslugi.ru/#!/support`
|
||||||
|
- **Скриншот**: `screenshots/deep_Обращениев_службу_поддержки.png`
|
||||||
|
- **Найденные элементы управления:**
|
||||||
|
- [Button] `Перейти на новый сайт`
|
||||||
|
- [Button] `Личный кабинет`
|
||||||
|
- [Button] `Отменить`
|
||||||
|
|
||||||
|
---
|
||||||
|
# 🗺️ Карта Личного Кабинета ГИС ЖКХ
|
||||||
|
|
||||||
|
Дата сканирования: 1297340.377546797
|
||||||
|
|
||||||
|
- **Часто задаваемые вопросы**: `/#!/faq`
|
||||||
|
- **Регламенты и инструкции**: `/#!/regulations`
|
||||||
|
- **Органы государственной власти**: `/#!/regulations?userCtgrCode=3`
|
||||||
|
- **Версия для слабовидящих**: `/special/`
|
||||||
|
- **Органы местного самоуправления**: `/#!/regulations?userCtgrCode=4`
|
||||||
|
- **ГИС ЖКХ**: `/#!/main`
|
||||||
|
- **Карта сайта**: `/#!/site-map`
|
||||||
|
- **Государственная информационная система жилищно-коммунального хозяйства**: `/#!/main`
|
||||||
|
- **Региональный оператор по обращению с твердыми коммунальными отходами**: `/#!/regulations?userCtgrCode=12`
|
||||||
|
- **Ответственные за реализацию приоритетного проекта "Формирование комфортной городской среды"**: `/#!/regulations?userCtgrCode=14`
|
||||||
|
- **Кредитные организации и банковские агенты**: `/#!/regulations?userCtgrCode=9`
|
||||||
|
- **Все пользователи**: `/#!/regulations?userCtgrCode=1`
|
||||||
|
- **Граждане**: `/#!/regulations?userCtgrCode=2`
|
||||||
|
- **Региональный оператор капитального ремонта**: `/#!/regulations?userCtgrCode=10`
|
||||||
|
- **Ресурсоснабжающие организации**: `/#!/regulations?userCtgrCode=7`
|
||||||
|
- **Управляющие организации, ТСЖ, ЖК, ЖСК**: `/#!/regulations?userCtgrCode=6`
|
||||||
|
- **Органы государственного жилищного надзора**: `/#!/regulations?userCtgrCode=8`
|
||||||
|
- **Федеральные органы власти**: `/#!/regulations?userCtgrCode=5`
|
||||||
|
- **РКЦ, ЕИРЦ и платежные агенты**: `/#!/regulations?userCtgrCode=13`
|
||||||
|
- **Фонд содействия реформированию ЖКХ**: `/#!/regulations?userCtgrCode=11`73-2020-7
|
||||||
|
73-2025-28074
|
||||||
|
73-2025-28128
|
||||||
|
73-2026-10010
|
||||||
|
73-2026-10011
|
||||||
|
73-2026-14011
|
||||||
|
73-2026-14014
|
||||||
|
73-2026-14016
|
||||||
|
73-2026-14123
|
||||||
|
73-2026-14124
|
||||||
|
73-2026-14126
|
||||||
|
73-2026-1580
|
||||||
|
73-2026-2441
|
||||||
|
73-2026-26
|
||||||
|
73-2026-2619
|
||||||
|
73-2026-2645
|
||||||
|
73-2026-2766
|
||||||
|
73-2026-2898
|
||||||
|
73-2026-2900
|
||||||
|
73-2026-3108
|
||||||
|
73-2026-3294
|
||||||
|
73-2026-3295
|
||||||
|
73-2026-4093
|
||||||
|
73-2026-4385
|
||||||
|
73-2026-5028
|
||||||
|
73-2026-5029
|
||||||
|
73-2026-5075
|
||||||
|
73-2026-5818
|
||||||
|
73-2026-6151
|
||||||
|
73-2026-681
|
||||||
21
database/migrate_add_ai_access.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "/home/matrixhasyou/legal-swarm-core/services/domovoy_bot/database/domovoy.db"
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"[*] Adding has_ai_access column to users...")
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN has_ai_access BOOLEAN DEFAULT FALSE;")
|
||||||
|
print("[+] Column has_ai_access added.")
|
||||||
|
# Даем доступ админу (тебе) сразу
|
||||||
|
cursor.execute("UPDATE users SET has_ai_access = TRUE WHERE is_admin = TRUE;")
|
||||||
|
print("[+] AI access granted to all admins.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Error or already exists: {e}")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
30
database/migrate_v4_3_fix_user_fields.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Путь к БД
|
||||||
|
DB_PATH = "/home/matrixhasyou/legal-swarm-core/services/domovoy_bot/database/domovoy.db"
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"[*] Starting migration: adding staircase/floor to users...")
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Добавляем колонки если их нет
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN staircase INTEGER;")
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN floor INTEGER;")
|
||||||
|
print("[+] Columns added successfully.")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"[!] Warning: {e} (Columns might already exist)")
|
||||||
|
|
||||||
|
# Включаем WAL режим
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
print(f"[+] WAL mode enabled: {cursor.fetchone()[0]}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("[*] Migration finished.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
|
|
@ -27,18 +27,18 @@ class User(Base):
|
||||||
"""Пользователи (жильцы)"""
|
"""Пользователи (жильцы)"""
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
|
|
||||||
user_id = Column(BigInteger, primary_key=True)
|
user_id = Column(BigInteger, primary_key=True, index=True)
|
||||||
username = Column(String(255), nullable=True)
|
username = Column(String(255), nullable=True, index=True)
|
||||||
first_name = Column(String(255), nullable=True)
|
first_name = Column(String(255), nullable=True)
|
||||||
last_name = Column(String(255), nullable=True)
|
last_name = Column(String(255), nullable=True)
|
||||||
apartment = Column(String(10), nullable=True) # Номер квартиры
|
apartment = Column(String(10), nullable=True) # Номер квартиры
|
||||||
phone = Column(String(20), nullable=True) # Телефон жильца
|
phone = Column(String(20), nullable=True, index=True) # Телефон жильца
|
||||||
verified = Column(Boolean, default=False) # Прошёл верификацию
|
verified = Column(Boolean, default=False) # Прошёл верификацию
|
||||||
verification_date = Column(DateTime, nullable=True)
|
verification_date = Column(DateTime, nullable=True)
|
||||||
join_date = Column(DateTime, default=datetime.utcnow)
|
join_date = Column(DateTime, default=datetime.utcnow)
|
||||||
message_count = Column(Integer, default=0)
|
message_count = Column(Integer, default=0)
|
||||||
is_banned = Column(Boolean, default=False)
|
is_banned = Column(Boolean, default=False)
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False, index=True)
|
||||||
last_seen = Column(DateTime, nullable=True)
|
last_seen = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Рейтинг активности
|
# Рейтинг активности
|
||||||
|
|
@ -49,6 +49,11 @@ class User(Base):
|
||||||
spy_score = Column(Integer, default=0) # 0-100
|
spy_score = Column(Integer, default=0) # 0-100
|
||||||
spy_flags = Column(Text, nullable=True) # JSON список флагов
|
spy_flags = Column(Text, nullable=True) # JSON список флагов
|
||||||
|
|
||||||
|
# Дополнительные данные (LKM37 Reborn)
|
||||||
|
staircase = Column(Integer, nullable=True) # Подъезд
|
||||||
|
floor = Column(Integer, nullable=True) # Этаж
|
||||||
|
has_ai_access = Column(Boolean, default=False) # Доступ к ИИ-консультанту
|
||||||
|
|
||||||
# Для анти-спама
|
# Для анти-спама
|
||||||
spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам
|
spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F, types
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, String
|
from sqlalchemy import select, func, String
|
||||||
|
|
@ -18,7 +19,7 @@ from utils.formatters import format_stats, format_user_info, get_spy_status
|
||||||
from services.analytics import ChatAnalytics
|
from services.analytics import ChatAnalytics
|
||||||
from services.spy_detector import SpyDetector
|
from services.spy_detector import SpyDetector
|
||||||
from keyboards.inline import get_admin_menu, get_spy_detail_menu
|
from keyboards.inline import get_admin_menu, get_spy_detail_menu
|
||||||
from keyboards.admin_menu import get_admin_main_menu, get_admin_reply_menu, get_admin_help_menu, get_admin_rating_menu
|
from keyboards.admin_menu import get_admin_main_menu, get_admin_help_menu, get_admin_rating_menu
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -34,6 +35,56 @@ class BroadcastState(StatesGroup):
|
||||||
waiting_for_message = State()
|
waiting_for_message = State()
|
||||||
waiting_for_photo = State() # Состояние для ожидания картинки
|
waiting_for_photo = State() # Состояние для ожидания картинки
|
||||||
|
|
||||||
|
class SwarmTaskState(StatesGroup):
|
||||||
|
waiting_for_task = State()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SWARM COMMAND CENTER
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.message(Command('task'))
|
||||||
|
@admin_only
|
||||||
|
async def cmd_swarm_task(message: Message, state: FSMContext):
|
||||||
|
"""Постановка задачи Рою"""
|
||||||
|
await message.answer("🦾 <b>SWARM COMMAND CENTER</b>\n\nОпишите задачу для Роя (она будет добавлена в SQL-очередь):", parse_mode='HTML')
|
||||||
|
await state.set_state(SwarmTaskState.waiting_for_task)
|
||||||
|
|
||||||
|
@router.message(SwarmTaskState.waiting_for_task)
|
||||||
|
@admin_only
|
||||||
|
async def process_swarm_task(message: Message, state: FSMContext):
|
||||||
|
# ... (код остается прежним)
|
||||||
|
pass
|
||||||
|
|
||||||
|
@router.message(F.text & ~F.text.startswith('/') & F.chat.type == 'private')
|
||||||
|
@admin_only
|
||||||
|
async def neural_proxy_handler(message: Message):
|
||||||
|
"""Neural Proxy: любой текст от админа -> задача для Роя"""
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
db_params = {
|
||||||
|
"host": "192.168.10.105",
|
||||||
|
"port": 5433,
|
||||||
|
"database": "swarm_memory",
|
||||||
|
"user": "gemini_admin",
|
||||||
|
"password": "secure_swarm_pass_2026"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(**db_params)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO swarm_tasks (title, description, status, creator) VALUES (%s, %s, %s, %s)",
|
||||||
|
("Neural Proxy Input", message.text, "pending", "Telegram_Admin")
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Добавляем реакцию, чтобы было понятно, что Рой услышал
|
||||||
|
await message.react([types.ReactionTypeEmoji(emoji="🧠")])
|
||||||
|
except Exception as e:
|
||||||
|
await message.answer(f"❌ <b>Neural Proxy Error:</b>\n{str(e)}", parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# АДМИН МЕНЮ
|
# АДМИН МЕНЮ
|
||||||
|
|
@ -199,45 +250,47 @@ async def msg_help_button(message: Message):
|
||||||
await message.answer(help_text, parse_mode='HTML')
|
await message.answer(help_text, parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == 'admin_menu')
|
||||||
|
@admin_callback_only
|
||||||
|
async def cb_admin_menu(callback: CallbackQuery):
|
||||||
|
"""Главное админ-меню (редактирование)"""
|
||||||
|
await callback.answer()
|
||||||
|
await callback.message.edit_text(
|
||||||
|
'🛡️ <b>Панель администратора</b>\n\nВыберите раздел:',
|
||||||
|
reply_markup=get_admin_main_menu(),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
|
||||||
@router.callback_query(F.data == 'admin_stats')
|
@router.callback_query(F.data == 'admin_stats')
|
||||||
@admin_callback_only
|
@admin_callback_only
|
||||||
async def cb_admin_stats(callback: CallbackQuery):
|
async def cb_admin_stats(callback: CallbackQuery):
|
||||||
"""Статистика чата"""
|
"""Статистика чата (редактирование)"""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
analytics = ChatAnalytics(session)
|
analytics = ChatAnalytics(session)
|
||||||
stats = await analytics.get_general_stats()
|
stats = await analytics.get_general_stats()
|
||||||
|
|
||||||
text = format_stats(stats)
|
text = format_stats(stats)
|
||||||
await callback.message.answer(text, parse_mode='HTML')
|
await callback.message.edit_text(text, reply_markup=get_admin_help_menu(), parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == 'admin_spies')
|
@router.callback_query(F.data == 'admin_spies')
|
||||||
@admin_callback_only
|
@admin_callback_only
|
||||||
async def cb_admin_spies(callback: CallbackQuery):
|
async def cb_admin_spies(callback: CallbackQuery):
|
||||||
"""Список подозрительных"""
|
"""Список подозрительных (редактирование)"""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
spy_detector = SpyDetector(session)
|
spy_detector = SpyDetector(session)
|
||||||
suspicious = await spy_detector.get_suspicious_users(min_score=30)
|
suspicious = await spy_detector.get_suspicious_users(min_score=30)
|
||||||
|
|
||||||
if not suspicious:
|
if not suspicious:
|
||||||
await callback.message.answer('🟢 Подозрительных пользователей нет')
|
await callback.message.edit_text('🟢 Подозрительных пользователей нет', reply_markup=get_admin_help_menu())
|
||||||
return
|
return
|
||||||
|
|
||||||
text = f'🕵️ <b>Подозрительные пользователи</b> ({len(suspicious)}):\n\n'
|
text = f'🕵️ <b>Подозрительные пользователи</b> ({len(suspicious)}):\n\n'
|
||||||
|
|
||||||
for user in suspicious:
|
for user in suspicious:
|
||||||
status_emoji = '🔴' if user.spy_score > 60 else '🟡'
|
status_emoji = '🔴' if user.spy_score > 60 else '🟡'
|
||||||
name = user.full_name or f'user{user.user_id}'
|
text += f'{status_emoji} {user.full_name or user.user_id} (кв. {user.apartment or "?"}) — Score: {user.spy_score}\n'
|
||||||
apt = user.apartment or '?'
|
|
||||||
text += f'{status_emoji} {name} (кв. {apt}) — Score: {user.spy_score}\n'
|
|
||||||
|
|
||||||
text += '\nНажмите на пользователя для деталей:'
|
await callback.message.edit_text(text, reply_markup=get_admin_help_menu(), parse_mode='HTML')
|
||||||
|
|
||||||
await callback.message.answer(text, parse_mode='HTML')
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == 'admin_users')
|
@router.callback_query(F.data == 'admin_users')
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ async def cmd_digest(message: Message):
|
||||||
@router.callback_query(F.data.startswith('digest_approve:'))
|
@router.callback_query(F.data.startswith('digest_approve:'))
|
||||||
async def approve_digest(callback: CallbackQuery):
|
async def approve_digest(callback: CallbackQuery):
|
||||||
"""Утвердить и отправить дайджест"""
|
"""Утвердить и отправить дайджест"""
|
||||||
|
await callback.answer()
|
||||||
if callback.from_user.id != ADMIN_USER_ID:
|
if callback.from_user.id != ADMIN_USER_ID:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -156,6 +157,7 @@ async def approve_digest(callback: CallbackQuery):
|
||||||
@router.callback_query(F.data.startswith('digest_reject:'))
|
@router.callback_query(F.data.startswith('digest_reject:'))
|
||||||
async def reject_digest(callback: CallbackQuery, state: FSMContext):
|
async def reject_digest(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Отклонить дайджест"""
|
"""Отклонить дайджест"""
|
||||||
|
await callback.answer()
|
||||||
if callback.from_user.id != ADMIN_USER_ID:
|
if callback.from_user.id != ADMIN_USER_ID:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -213,6 +215,7 @@ async def process_reject_reason(message: Message, state: FSMContext):
|
||||||
@router.callback_query(F.data.startswith('digest_edit:'))
|
@router.callback_query(F.data.startswith('digest_edit:'))
|
||||||
async def edit_digest(callback: CallbackQuery, state: FSMContext):
|
async def edit_digest(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Редактировать текст дайджеста"""
|
"""Редактировать текст дайджеста"""
|
||||||
|
await callback.answer()
|
||||||
if callback.from_user.id != ADMIN_USER_ID:
|
if callback.from_user.id != ADMIN_USER_ID:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,12 @@ from aiogram.fsm.state import State, StatesGroup
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from database.db import AsyncSessionLocal
|
from database.db import AsyncSessionLocal
|
||||||
|
from database.models import User
|
||||||
from services.events import EventsService
|
from services.events import EventsService
|
||||||
from utils.decorators import admin_only
|
from utils.decorators import admin_only
|
||||||
|
from keyboards.inline import get_events_keyboard, get_event_view_keyboard, get_main_menu
|
||||||
|
from swarmlib.notifier import send_telegram
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -128,54 +132,148 @@ async def process_event_description(message: Message, state: FSMContext):
|
||||||
created_by=message.from_user.id
|
created_by=message.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Уведомляем админа через swarmlib
|
||||||
|
send_telegram(f"📅 *Новое событие создано!*\n\n"
|
||||||
|
f"Название: {data['title']}\n"
|
||||||
|
f"Дата: {data['event_date'].strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
|
f"Автор: {message.from_user.full_name} (ID: {message.from_user.id})",
|
||||||
|
parse_mode="markdown")
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f'✅ <b>Событие создано!</b>\n\n'
|
f'✅ <b>Событие создано!</b>\n\n'
|
||||||
f'📅 {data["title"]}\n'
|
f'📅 {data["title"]}\n'
|
||||||
f'🗓️ {data["event_date"].strftime("%d.%m.%Y %H:%M")}\n'
|
f'🗓️ {data["event_date"].strftime("%d.%m.%Y %H:%M")}\n'
|
||||||
f'📍 {data.get("location") or "Место не указано"}\n'
|
f'📍 {data.get("location") or "Место не указано"}\n'
|
||||||
f'🔖 ID: {event_id}\n\n'
|
f'🔖 ID: {event_id}\n\n'
|
||||||
f'Для просмотра: /events',
|
f'Событие доступно в меню "События"',
|
||||||
parse_mode='HTML'
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ПРОСМОТР СОБЫТИЙ
|
# ПРОСМОТР СОБЫТИЙ (INLINE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@router.message(Command('events'))
|
@router.message(Command('events'))
|
||||||
@router.message(Command('события'))
|
@router.message(Command('события'))
|
||||||
async def cmd_events(message: Message):
|
@router.callback_query(F.data == 'events')
|
||||||
"""Показать предстоящие события"""
|
async def cmd_events(event):
|
||||||
|
"""Показать предстоящие события (Inline)"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
events_service = EventsService(session)
|
events_service = EventsService(session)
|
||||||
events = await events_service.get_upcoming_events(10)
|
events = await events_service.get_upcoming_events(10)
|
||||||
|
|
||||||
if not events:
|
|
||||||
await message.answer('📅 <b>Предстоящих событий нет</b>\n\n'
|
|
||||||
'Добавить событие: /event_add',
|
|
||||||
parse_mode='HTML')
|
|
||||||
return
|
|
||||||
|
|
||||||
text = '📅 <b>Предстоящие события</b>\n\n'
|
text = '📅 <b>Предстоящие события</b>\n\n'
|
||||||
|
if not events:
|
||||||
|
text += 'Событий пока нет. Следите за обновлениями!'
|
||||||
|
else:
|
||||||
|
for e in events:
|
||||||
|
text += f'• <b>{e["title"]}</b> ({e["event_date"].strftime("%d.%m.%Y %H:%M")})\n'
|
||||||
|
|
||||||
for event in events:
|
keyboard = get_events_keyboard(events)
|
||||||
emoji = events_service.get_event_type_emoji(event['event_type'])
|
|
||||||
date_str = event['event_date'].strftime('%d.%m.%Y %H:%M')
|
|
||||||
|
|
||||||
text += f'{emoji} <b>{event["title"]}</b>\n'
|
if isinstance(event, Message):
|
||||||
text += f' └─ {date_str}\n'
|
await event.answer(text, reply_markup=keyboard, parse_mode='HTML')
|
||||||
text += f' └─ 📍 {event["location"] or "Место не указано"}\n'
|
else:
|
||||||
text += f' └─ 🔖 ID: {event["id"]}\n\n'
|
await event.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML')
|
||||||
|
|
||||||
text += '💡 Для участия: /join <ID>'
|
|
||||||
|
|
||||||
await message.answer(text, parse_mode='HTML')
|
@router.callback_query(F.data.startswith('event_view_'))
|
||||||
|
async def cb_event_view(callback: CallbackQuery):
|
||||||
|
"""Детальный просмотр события"""
|
||||||
|
event_id = int(callback.data.split('_')[2])
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
events_service = EventsService(session)
|
||||||
|
event = await events_service.get_event(event_id)
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
await callback.answer('❌ Событие не найдено', show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
participants = await events_service.get_participants(event_id)
|
||||||
|
is_participant = any(p['user_id'] == user_id for p in participants)
|
||||||
|
|
||||||
|
# Проверяем админа для кнопки удаления
|
||||||
|
stmt = select(User).where(User.user_id == user_id)
|
||||||
|
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
is_admin = user.is_admin if user else False
|
||||||
|
|
||||||
|
emoji = events_service.get_event_type_emoji(event['event_type'])
|
||||||
|
text = (
|
||||||
|
f'{emoji} <b>{event["title"]}</b>\n\n'
|
||||||
|
f'🗓️ <b>Дата:</b> {event["event_date"].strftime("%d.%m.%Y %H:%M")}\n'
|
||||||
|
f'📍 <b>Место:</b> {event["location"] or "Не указано"}\n'
|
||||||
|
f'👥 <b>Участников:</b> {event["participants_count"]}\n\n'
|
||||||
|
f'📝 <b>Описание:</b>\n{event["description"] or "Нет описания"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if participants:
|
||||||
|
text += '\n\n<b>Участники:</b>\n'
|
||||||
|
for p in participants[:10]:
|
||||||
|
text += f'• {p["name"]}\n'
|
||||||
|
if len(participants) > 10:
|
||||||
|
text += f'<i>...и еще {len(participants)-10}</i>'
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=get_event_view_keyboard(event_id, is_participant, is_admin),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith('event_join_'))
|
||||||
|
async def cb_event_join(callback: CallbackQuery):
|
||||||
|
"""Записаться на событие через кнопку"""
|
||||||
|
event_id = int(callback.data.split('_')[2])
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
events_service = EventsService(session)
|
||||||
|
success = await events_service.join_event(event_id, user_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
await callback.answer('✅ Вы записались!', show_alert=False)
|
||||||
|
# Обновляем сообщение
|
||||||
|
await cb_event_view(callback)
|
||||||
|
else:
|
||||||
|
await callback.answer('ℹ️ Вы уже участвуете', show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith('event_leave_'))
|
||||||
|
async def cb_event_leave(callback: CallbackQuery):
|
||||||
|
"""Отписаться от события через кнопку"""
|
||||||
|
event_id = int(callback.data.split('_')[2])
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
events_service = EventsService(session)
|
||||||
|
await events_service.leave_event(event_id, user_id)
|
||||||
|
|
||||||
|
await callback.answer('✅ Вы отписались', show_alert=False)
|
||||||
|
await cb_event_view(callback)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith('event_delete_'))
|
||||||
|
@admin_only
|
||||||
|
async def cb_event_delete(callback: CallbackQuery):
|
||||||
|
"""Удалить событие (Админ)"""
|
||||||
|
event_id = int(callback.data.split('_')[2])
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
events_service = EventsService(session)
|
||||||
|
event = await events_service.get_event(event_id)
|
||||||
|
await events_service.cancel_event(event_id)
|
||||||
|
|
||||||
|
send_telegram(f"🗑️ *Событие удалено!*\n\nID: {event_id}\nНазвание: {event['title'] if event else '???'}\nАдмин: {callback.from_user.full_name}", parse_mode="markdown")
|
||||||
|
await callback.answer('🗑️ Событие удалено', show_alert=True)
|
||||||
|
await cmd_events(callback)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# УЧАСТИЕ В СОБЫТИИ
|
# УЧАСТИЕ В СОБЫТИИ (СТАРЫЕ КОМАНДЫ ДЛЯ СОВМЕСТИМОСТИ)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@router.message(Command('join'))
|
@router.message(Command('join'))
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ from sqlalchemy import select
|
||||||
from database.db import AsyncSessionLocal
|
from database.db import AsyncSessionLocal
|
||||||
from database.models import User, Poll
|
from database.models import User, Poll
|
||||||
from utils.decorators import admin_only
|
from utils.decorators import admin_only
|
||||||
from keyboards.inline import get_poll_keyboard, get_poll_results_keyboard, get_poll_close_keyboard
|
from keyboards.inline import (
|
||||||
|
get_poll_keyboard, get_poll_results_keyboard, get_poll_close_keyboard,
|
||||||
|
get_polls_list_keyboard, get_poll_view_keyboard
|
||||||
|
)
|
||||||
|
from swarmlib.notifier import send_telegram
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -132,11 +136,18 @@ async def create_poll_from_text(message: Message, text: str, state: FSMContext):
|
||||||
poll.message_id = sent_message.message_id
|
poll.message_id = sent_message.message_id
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
# Уведомляем админа через swarmlib
|
||||||
|
send_telegram(f"📊 *Новый опрос создан!*\n\n"
|
||||||
|
f"Вопрос: {question}\n"
|
||||||
|
f"Вариантов: {len(options)}\n"
|
||||||
|
f"Автор: {message.from_user.full_name}",
|
||||||
|
parse_mode="markdown")
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f'✅ Опрос создан!\n\n'
|
f'✅ Опрос создан!\n\n'
|
||||||
f'Вопрос: {question}\n'
|
f'Вопрос: {question}\n'
|
||||||
f'Вариантов: {len(options)}\n\n'
|
f'Вариантов: {len(options)}\n\n'
|
||||||
f'Для закрытия: <code>/poll_end {poll.poll_id}</code>',
|
f'Опрос доступен в меню "Опросы"',
|
||||||
parse_mode='HTML'
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
@ -244,9 +255,10 @@ async def callback_poll_vote(callback: CallbackQuery):
|
||||||
|
|
||||||
await callback.answer(f'✅ Ваш голос учтён!', show_alert=False)
|
await callback.answer(f'✅ Ваш голос учтён!', show_alert=False)
|
||||||
|
|
||||||
# Отправляем результаты в чат (видимо только проголосовавшему)
|
# Обновляем текущее сообщение результатами
|
||||||
await callback.message.answer(
|
await callback.message.edit_text(
|
||||||
results_text,
|
results_text,
|
||||||
|
reply_markup=get_poll_view_keyboard(poll_id, user.is_admin if user else False),
|
||||||
parse_mode='HTML'
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -354,33 +366,88 @@ async def cmd_poll_end(message: Message):
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# СПИСОК ОПРОСОВ
|
# СПИСОК ОПРОСОВ (INLINE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@router.message(Command('polls'))
|
@router.message(Command('polls'))
|
||||||
async def cmd_polls(message: Message):
|
@router.callback_query(F.data == 'polls')
|
||||||
"""Список активных опросов"""
|
async def cmd_polls(event):
|
||||||
|
"""Список активных опросов (Inline)"""
|
||||||
|
chat_id = event.chat.id if isinstance(event, Message) else event.message.chat.id
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
stmt = select(Poll).where(
|
stmt = select(Poll).where(
|
||||||
Poll.chat_id == message.chat.id,
|
|
||||||
Poll.is_active == True
|
Poll.is_active == True
|
||||||
).order_by(Poll.created_at.desc())
|
).order_by(Poll.created_at.desc())
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
polls = list(result.scalars().all())
|
polls = list(result.scalars().all())
|
||||||
|
|
||||||
if not polls:
|
text = '📊 <b>Активные опросы дома</b>\n\nВыберите опрос для голосования или просмотра результатов:'
|
||||||
await message.answer('📊 Нет активных опросов')
|
if not polls:
|
||||||
|
text = '📊 <b>Активных опросов пока нет</b>'
|
||||||
|
|
||||||
|
keyboard = get_polls_list_keyboard(polls)
|
||||||
|
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(text, reply_markup=keyboard, parse_mode='HTML')
|
||||||
|
else:
|
||||||
|
await event.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith('poll_view_'))
|
||||||
|
async def cb_poll_view(callback: CallbackQuery):
|
||||||
|
"""Детальный просмотр опроса"""
|
||||||
|
poll_id = int(callback.data.split('_')[2])
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = select(Poll).where(Poll.poll_id == poll_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
poll = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not poll:
|
||||||
|
await callback.answer('❌ Опрос не найден', show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
text = '📊 <b>Активные опросы</b>\n\n'
|
# Проверяем админа
|
||||||
for poll in polls:
|
stmt_user = select(User).where(User.user_id == user_id)
|
||||||
total = poll.total_votes
|
user = (await session.execute(stmt_user)).scalar_one_or_none()
|
||||||
status = '🔒 Закрыт' if not poll.is_active else '🟢 Активен'
|
is_admin = user.is_admin if user else False
|
||||||
text += f'{poll.poll_id}. {poll.question[:50]}... ({total} голосов) {status}\n'
|
|
||||||
|
|
||||||
text += '\n💡 Для голосования нажмите на кнопку в сообщении с опросом'
|
status = '🟢 Активен' if poll.is_active else '🔒 Закрыт'
|
||||||
|
text = (
|
||||||
|
f'📊 <b>Опрос: {poll.question}</b>\n\n'
|
||||||
|
f'<b>Статус:</b> {status}\n'
|
||||||
|
f'<b>Всего голосов:</b> {poll.total_votes}\n'
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(text, parse_mode='HTML')
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=get_poll_view_keyboard(poll_id, is_admin),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith('poll_vote_menu_'))
|
||||||
|
async def cb_poll_vote_menu(callback: CallbackQuery):
|
||||||
|
"""Меню выбора варианта для голосования"""
|
||||||
|
poll_id = int(callback.data.split('_')[3])
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = select(Poll).where(Poll.poll_id == poll_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
poll = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not poll or not poll.is_active:
|
||||||
|
await callback.answer('❌ Опрос закрыт или не найден', show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
options = json.loads(poll.options)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f'🗳️ <b>{poll.question}</b>\n\nВыберите ваш вариант:',
|
||||||
|
reply_markup=get_poll_keyboard(poll_id, options),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ async def process_broadcast_text(message: Message, state: FSMContext):
|
||||||
@router.callback_query(F.data.startswith('broadcast_recipients:'))
|
@router.callback_query(F.data.startswith('broadcast_recipients:'))
|
||||||
async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContext):
|
async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Обработка выбора получателей"""
|
"""Обработка выбора получателей"""
|
||||||
|
await callback.answer()
|
||||||
recipients = callback.data.split(':')[1]
|
recipients = callback.data.split(':')[1]
|
||||||
await state.update_data(recipients=recipients)
|
await state.update_data(recipients=recipients)
|
||||||
|
|
||||||
|
|
@ -126,6 +127,7 @@ async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContex
|
||||||
@router.callback_query(F.data == 'broadcast_send')
|
@router.callback_query(F.data == 'broadcast_send')
|
||||||
async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext):
|
async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Отправка рассылки"""
|
"""Отправка рассылки"""
|
||||||
|
await callback.answer()
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
text = data.get('text', '')
|
text = data.get('text', '')
|
||||||
recipients = data.get('recipients', 'all_verified')
|
recipients = data.get('recipients', 'all_verified')
|
||||||
|
|
@ -145,6 +147,7 @@ async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext):
|
||||||
@router.callback_query(F.data == 'broadcast_cancel')
|
@router.callback_query(F.data == 'broadcast_cancel')
|
||||||
async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
|
async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Отмена рассылки"""
|
"""Отмена рассылки"""
|
||||||
|
await callback.answer()
|
||||||
await callback.message.edit_text("❌ Рассылка отменена")
|
await callback.message.edit_text("❌ Рассылка отменена")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
@ -153,76 +156,87 @@ async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
|
||||||
# ОСНОВНАЯ ФУНКЦИЯ ОТПРАВКИ
|
# ОСНОВНАЯ ФУНКЦИЯ ОТПРАВКИ
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None, staircase: str = 'all') -> bool:
|
async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None, document_file_id: str = None, staircase: str = 'all', user_ids: list = None, broadcast_id: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Отправить умную рассылку с фильтрацией по подъезду.
|
Отправить умную рассылку с фильтрацией по подъезду.
|
||||||
Args:
|
|
||||||
bot: Bot instance
|
|
||||||
text: Текст
|
|
||||||
recipients: 'all_and_chat', 'all_verified', 'ig_only'
|
|
||||||
photo_file_id: ID фото
|
|
||||||
staircase: 'all' или номер подъезда (строкой)
|
|
||||||
"""
|
"""
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from config import BOT_TOKEN, get_proxy_url
|
from config import BOT_TOKEN, get_proxy_url
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reply_markup = {"inline_keyboard": [[{"text": "✅ Прочитал", "callback_data": "broadcast_read"}]]}
|
if user_ids is None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = select(User).where(User.verified == True)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
# Фильтр по группе
|
||||||
stmt = select(User).where(User.verified == True)
|
if recipients == 'ig_only':
|
||||||
|
from services.initiative_group import InitiativeGroupService
|
||||||
|
ig_user_ids = await InitiativeGroupService(session).get_member_ids(active_only=True)
|
||||||
|
stmt = stmt.where(User.user_id.in_(ig_user_ids))
|
||||||
|
|
||||||
# Фильтр по группе
|
# ФИЛЬТР ПО ПОДЪЕЗДУ
|
||||||
if recipients == 'ig_only':
|
if staircase != 'all':
|
||||||
from services.initiative_group import InitiativeGroupService
|
stmt = stmt.where(User.staircase == int(staircase))
|
||||||
ig_user_ids = await InitiativeGroupService(session).get_member_ids(active_only=True)
|
|
||||||
stmt = stmt.where(User.user_id.in_(ig_user_ids))
|
|
||||||
|
|
||||||
# ФИЛЬТР ПО ПОДЪЕЗДУ (НОВОЕ!)
|
result = await session.execute(stmt)
|
||||||
if staircase != 'all':
|
users = list(result.scalars().all())
|
||||||
stmt = stmt.where(User.staircase == int(staircase))
|
user_ids = [u.user_id for u in users]
|
||||||
|
|
||||||
result = await session.execute(stmt)
|
if not user_ids:
|
||||||
users = list(result.scalars().all())
|
logger.warning("No users found for broadcast.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Создаём запись в БД, если ещё не создана
|
||||||
|
if broadcast_id is None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
broadcast = Broadcast(
|
||||||
|
message_id=None,
|
||||||
|
chat_id=None,
|
||||||
|
text=text,
|
||||||
|
photo_file_id=photo_file_id,
|
||||||
|
sent_at=datetime.utcnow(),
|
||||||
|
sent_by=ADMIN_USER_ID,
|
||||||
|
total_sent=len(user_ids),
|
||||||
|
has_read_button=True,
|
||||||
|
broadcast_type='smart',
|
||||||
|
)
|
||||||
|
session.add(broadcast)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(broadcast)
|
||||||
|
broadcast_id = broadcast.id
|
||||||
|
|
||||||
|
# Формируем кнопку с ID рассылки
|
||||||
|
reply_markup = {"inline_keyboard": [[{"text": "✅ Прочитал", "callback_data": f"broadcast_read:{broadcast_id}"}]]}
|
||||||
|
|
||||||
# URL для Telegram API
|
# URL для Telegram API
|
||||||
bot_token = BOT_TOKEN
|
bot_token = BOT_TOKEN
|
||||||
proxy_url = get_proxy_url()
|
proxy_url = get_proxy_url()
|
||||||
|
|
||||||
# НЕ отправляем в общий чат! Умная рассылка идёт только выбранным получателям
|
|
||||||
# Для объявлений в общий чат используйте обычную рассылку
|
|
||||||
chat_message_id = None
|
|
||||||
|
|
||||||
# Создаём запись в БД
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
broadcast = Broadcast(
|
|
||||||
message_id=None, # Умная рассылка не создаёт сообщение в общем чате
|
|
||||||
chat_id=None,
|
|
||||||
text=text,
|
|
||||||
photo_file_id=photo_file_id,
|
|
||||||
sent_at=datetime.utcnow(),
|
|
||||||
sent_by=ADMIN_USER_ID,
|
|
||||||
total_sent=len(users),
|
|
||||||
has_read_button=True,
|
|
||||||
broadcast_type='smart',
|
|
||||||
)
|
|
||||||
session.add(broadcast)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(broadcast)
|
|
||||||
broadcast_id = broadcast.id
|
|
||||||
|
|
||||||
# Отправляем пользователям
|
# Отправляем пользователям
|
||||||
success_count = 0
|
success_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
|
|
||||||
# Используем один HTTP сеанс для всех запросов
|
# Используем один HTTP сеанс для всех запросов
|
||||||
async with aiohttp.ClientSession() as http_session:
|
async with aiohttp.ClientSession() as http_session:
|
||||||
for user in users:
|
for uid in user_ids:
|
||||||
try:
|
try:
|
||||||
if photo_file_id:
|
if document_file_id:
|
||||||
|
url = f"https://api.telegram.org/bot{bot_token}/sendDocument"
|
||||||
|
data = aiohttp.FormData()
|
||||||
|
data.add_field('chat_id', str(uid))
|
||||||
|
data.add_field('document', document_file_id)
|
||||||
|
data.add_field('caption', f"📢 <b>Объявление от администрации</b>\n\n{text}")
|
||||||
|
data.add_field('reply_markup', json.dumps(reply_markup))
|
||||||
|
data.add_field('parse_mode', 'HTML')
|
||||||
|
|
||||||
|
async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||||
|
result_data = await resp.json()
|
||||||
|
if result_data.get('ok'): success_count += 1
|
||||||
|
else: error_count += 1
|
||||||
|
elif photo_file_id:
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
|
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
|
||||||
data = aiohttp.FormData()
|
data = aiohttp.FormData()
|
||||||
data.add_field('chat_id', str(user.user_id))
|
data.add_field('chat_id', str(uid))
|
||||||
data.add_field('photo', photo_file_id)
|
data.add_field('photo', photo_file_id)
|
||||||
data.add_field('caption', f"📢 <b>Объявление от администрации</b>\n\n{text}")
|
data.add_field('caption', f"📢 <b>Объявление от администрации</b>\n\n{text}")
|
||||||
data.add_field('reply_markup', json.dumps(reply_markup))
|
data.add_field('reply_markup', json.dumps(reply_markup))
|
||||||
|
|
@ -230,14 +244,12 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
|
||||||
|
|
||||||
async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||||
result_data = await resp.json()
|
result_data = await resp.json()
|
||||||
if result_data.get('ok'):
|
if result_data.get('ok'): success_count += 1
|
||||||
success_count += 1
|
else: error_count += 1
|
||||||
else:
|
|
||||||
error_count += 1
|
|
||||||
else:
|
else:
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||||
payload = {
|
payload = {
|
||||||
'chat_id': user.user_id,
|
'chat_id': uid,
|
||||||
'text': f"📢 <b>Объявление от администрации</b>\n\n{text}",
|
'text': f"📢 <b>Объявление от администрации</b>\n\n{text}",
|
||||||
'reply_markup': json.dumps(reply_markup),
|
'reply_markup': json.dumps(reply_markup),
|
||||||
'parse_mode': 'HTML'
|
'parse_mode': 'HTML'
|
||||||
|
|
@ -245,12 +257,10 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
|
||||||
|
|
||||||
async with http_session.post(url, json=payload, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
async with http_session.post(url, json=payload, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||||
result_data = await resp.json()
|
result_data = await resp.json()
|
||||||
if result_data.get('ok'):
|
if result_data.get('ok'): success_count += 1
|
||||||
success_count += 1
|
else: error_count += 1
|
||||||
else:
|
|
||||||
error_count += 1
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
|
logger.error(f"Ошибка отправки пользователю {uid}: {e}")
|
||||||
error_count += 1
|
error_count += 1
|
||||||
|
|
||||||
logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}")
|
logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}")
|
||||||
|
|
@ -264,7 +274,7 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
|
||||||
'text': f"📊 <b>Результаты рассылки #{broadcast_id}</b>\n\n"
|
'text': f"📊 <b>Результаты рассылки #{broadcast_id}</b>\n\n"
|
||||||
f"✅ Доставлено: {success_count}\n"
|
f"✅ Доставлено: {success_count}\n"
|
||||||
f"❌ Ошибки: {error_count}\n"
|
f"❌ Ошибки: {error_count}\n"
|
||||||
f"📈 Прочитали: 0/{len(users)}\n\n"
|
f"📈 Прочитали: 0/{len(user_ids)}\n\n"
|
||||||
f"Статистика обновляется в реальном времени в веб-панели",
|
f"Статистика обновляется в реальном времени в веб-панели",
|
||||||
'parse_mode': 'HTML'
|
'parse_mode': 'HTML'
|
||||||
}
|
}
|
||||||
|
|
@ -284,17 +294,18 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
|
||||||
# ОБРАБОТКА НАЖАТИЯ КНОПКИ "ПРОЧИТАЛ"
|
# ОБРАБОТКА НАЖАТИЯ КНОПКИ "ПРОЧИТАЛ"
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data == 'broadcast_read')
|
@router.callback_query(F.data.startswith('broadcast_read:'))
|
||||||
async def handle_read_button(callback: CallbackQuery):
|
async def handle_read_button(callback: CallbackQuery):
|
||||||
"""Обработка нажатия кнопки 'Прочитал'"""
|
"""Обработка нажатия кнопки 'Прочитал'"""
|
||||||
try:
|
try:
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
|
broadcast_id = int(callback.data.split(':')[1])
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# Проверяем, не нажал ли уже
|
# Проверяем, не нажал ли уже
|
||||||
stmt = select(BroadcastRead).where(
|
stmt = select(BroadcastRead).where(
|
||||||
BroadcastRead.user_id == user_id,
|
BroadcastRead.user_id == user_id,
|
||||||
BroadcastRead.broadcast_id == callback.message.message_id # Используем message_id как временный ID
|
BroadcastRead.broadcast_id == broadcast_id
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
existing = result.scalar_one_or_none()
|
existing = result.scalar_one_or_none()
|
||||||
|
|
@ -303,13 +314,27 @@ async def handle_read_button(callback: CallbackQuery):
|
||||||
await callback.answer("✅ Вы уже отметили прочтение!", show_alert=False)
|
await callback.answer("✅ Вы уже отметили прочтение!", show_alert=False)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Пока не можем определить broadcast_id, сохраняем по callback.message
|
# Сохраняем отметку
|
||||||
# В идеале нужно хранить broadcast_id в callback_data
|
new_read = BroadcastRead(
|
||||||
# Для простоты используем message_id чата
|
broadcast_id=broadcast_id,
|
||||||
|
user_id=user_id,
|
||||||
|
read_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
session.add(new_read)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Уведомляем админа о прочтении (опционально)
|
||||||
|
# await callback.bot.send_message(ADMIN_USER_ID, f"👁️ Пользователь {callback.from_user.full_name} прочитал рассылку #{broadcast_id}")
|
||||||
|
|
||||||
# TODO: Улучшить идентификацию рассылки
|
|
||||||
await callback.answer("✅ Спасибо за отметку!", show_alert=False)
|
await callback.answer("✅ Спасибо за отметку!", show_alert=False)
|
||||||
|
|
||||||
|
# Убираем кнопку после нажатия
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=None)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка обработки кнопки прочтения: {e}")
|
||||||
|
await callback.answer("❌ Ошибка сохранения статуса")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка обработки кнопки прочтения: {e}")
|
logger.error(f"Ошибка обработки кнопки прочтения: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,31 @@
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
|
||||||
from aiogram.filters import Command, CommandStart
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
from sqlalchemy import select
|
from aiogram.filters import Command, CommandStart, StateFilter
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import select, update
|
||||||
from database.db import AsyncSessionLocal
|
from database.db import AsyncSessionLocal
|
||||||
from database.models import User, InitiativeGroup
|
from database.models import User, InitiativeGroup
|
||||||
from keyboards.inline import get_main_menu, get_phones_menu
|
from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard
|
||||||
|
from services.ai_service import ai_service
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
@router.message(Command('cancel'), StateFilter('*'))
|
||||||
|
@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
|
||||||
|
async def global_cancel(message: Message, state: FSMContext):
|
||||||
|
"""Глобальный выход из любого состояния FSM"""
|
||||||
|
current_state = await state.get_state()
|
||||||
|
if current_state:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("❌ Действие отменено.", reply_markup=get_main_menu())
|
||||||
|
else:
|
||||||
|
await message.answer("ℹ️ Нет активных действий для отмены.", reply_markup=get_main_menu())
|
||||||
|
|
||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
async def cmd_start(message: Message):
|
async def cmd_start(message: Message):
|
||||||
"""Команда /start"""
|
"""Команда /start"""
|
||||||
|
|
@ -48,12 +62,58 @@ async def cmd_start(message: Message):
|
||||||
|
|
||||||
await message.answer(welcome_text, reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)), parse_mode='HTML')
|
await message.answer(welcome_text, reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)), parse_mode='HTML')
|
||||||
|
|
||||||
|
@router.callback_query(F.data == 'main_menu')
|
||||||
|
async def cb_main_menu(callback: CallbackQuery):
|
||||||
|
"""Возврат в главное меню"""
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = select(User).where(User.user_id == user_id)
|
||||||
|
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == user_id, InitiativeGroup.is_active == True)
|
||||||
|
is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Главное меню:",
|
||||||
|
reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
|
||||||
@router.message(Command('phones'))
|
@router.message(Command('phones'))
|
||||||
@router.callback_query(F.data == 'phones')
|
@router.callback_query(F.data == 'phones')
|
||||||
async def cb_phones(event):
|
async def cb_phones(event):
|
||||||
msg = event if isinstance(event, Message) else event.message
|
text = '📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
|
||||||
if isinstance(event, CallbackQuery): await event.answer()
|
kb = get_phones_menu()
|
||||||
await msg.answer('📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:', reply_markup=get_phones_menu())
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer()
|
||||||
|
await event.message.edit_text(text, reply_markup=kb, parse_mode='HTML')
|
||||||
|
else:
|
||||||
|
await event.answer(text, reply_markup=kb, parse_mode='HTML')
|
||||||
|
|
||||||
|
@router.message(F.text & ~F.text.startswith('/'))
|
||||||
|
async def chat_with_ai(message: Message):
|
||||||
|
"""Обработка свободного общения с ИИ (только для верифицированных)"""
|
||||||
|
if message.chat.type != 'private': return
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = select(User).where(User.user_id == message.from_user.id)
|
||||||
|
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == message.from_user.id, InitiativeGroup.is_active == True)
|
||||||
|
is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
# Проверка прав: Админ ИЛИ ИГ ИЛИ персональный доступ
|
||||||
|
if not (user and (user.is_admin or is_ig or False)):
|
||||||
|
await message.answer("🔒 <b>Доступ ограничен</b>\n\nМой юридический ИИ-модуль доступен только участникам <b>Инициативной группы</b> и по персональному разрешению администратора.\n\nОбратитесь к @atyrin для получения доступа к базе знаний ГИС ЖКХ.", parse_mode='HTML')
|
||||||
|
return
|
||||||
|
|
||||||
|
user_info = {"apartment": user.apartment, "verified": user.verified, "role": "ИГ" if is_ig else "Жилец"}
|
||||||
|
|
||||||
|
# Отправляем индикатор печати
|
||||||
|
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
|
||||||
|
answer = await ai_service.answer_question(message.text, user_info)
|
||||||
|
await message.answer(answer, parse_mode='Markdown')
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith('phones_'))
|
@router.callback_query(F.data.startswith('phones_'))
|
||||||
async def cb_phone_category(callback: CallbackQuery):
|
async def cb_phone_category(callback: CallbackQuery):
|
||||||
|
|
@ -77,55 +137,60 @@ async def cb_phone_category(callback: CallbackQuery):
|
||||||
services = list((await session.execute(stmt)).scalars().all())
|
services = list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
if not services:
|
if not services:
|
||||||
return await callback.message.answer("👷 Данные для этой категории еще не заполнены.", reply_markup=get_main_menu())
|
return await callback.message.edit_text("👷 Данные для этой категории еще не заполнены.", reply_markup=get_main_menu())
|
||||||
|
|
||||||
# Формируем текст
|
|
||||||
text = f"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n"
|
text = f"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n"
|
||||||
for s in services:
|
for s in services:
|
||||||
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
|
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
|
||||||
if s.description: text += f" └ 📝 <i>{s.description}</i>\n"
|
if s.description: text += f" └ 📝 <i>{s.description}</i>\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
|
|
||||||
# Ищем картинку (берем от первой службы в категории, если есть)
|
# Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото)
|
||||||
photo_path = services[0].image_path if services[0].image_path else None
|
photo_path = services[0].image_path if services[0].image_path else None
|
||||||
|
|
||||||
try:
|
if photo_path and os.path.exists(photo_path):
|
||||||
if photo_path and os.path.exists(photo_path):
|
await callback.message.delete()
|
||||||
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, parse_mode='HTML')
|
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=get_main_menu(), parse_mode='HTML')
|
||||||
else:
|
else:
|
||||||
await callback.message.answer(text, parse_mode='HTML')
|
await callback.message.edit_text(text, reply_markup=get_main_menu(), parse_mode='HTML')
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error sending phones: {e}")
|
|
||||||
await callback.message.answer(text, parse_mode='HTML')
|
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
@router.message(Command('rules'))
|
@router.message(Command('rules'))
|
||||||
@router.callback_query(F.data == 'rules')
|
@router.callback_query(F.data == 'rules')
|
||||||
async def cb_rules(event):
|
async def cb_rules(event):
|
||||||
msg = event if isinstance(event, Message) else event.message
|
|
||||||
if isinstance(event, CallbackQuery): await event.answer()
|
|
||||||
text = "<b>📜 ПРАВИЛА ЧАТА ЛКМ37</b>\n\n1. Уважение к соседям\n2. Никакого спама и рекламы\n3. Мат запрещен\n4. Обсуждаем только дела дома."
|
text = "<b>📜 ПРАВИЛА ЧАТА ЛКМ37</b>\n\n1. Уважение к соседям\n2. Никакого спама и рекламы\n3. Мат запрещен\n4. Обсуждаем только дела дома."
|
||||||
await msg.answer(text, parse_mode='HTML')
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
|
||||||
|
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
else:
|
||||||
|
await event.answer()
|
||||||
|
await event.message.edit_text(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
|
||||||
@router.message(Command('about'))
|
@router.message(Command('about'))
|
||||||
@router.callback_query(F.data == 'about_house')
|
@router.callback_query(F.data == 'about_house')
|
||||||
async def cb_about(event):
|
async def cb_about(event):
|
||||||
msg = event if isinstance(event, Message) else event.message
|
|
||||||
if isinstance(event, CallbackQuery): await event.answer()
|
|
||||||
text = ("<b>🏢 О НАШЕМ ДОМЕ (ЛКМ37)</b>\n\n"
|
text = ("<b>🏢 О НАШЕМ ДОМЕ (ЛКМ37)</b>\n\n"
|
||||||
"📍 Адрес: пр-т Ленинского Комсомола, д. 37\n"
|
"📍 Адрес: пр-т Ленинского Комсомола, д. 37\n"
|
||||||
"🏗️ Тип дома: Многоквартирный жилой дом\n"
|
"🏗️ Тип дома: Многоквартирный жилой дом\n"
|
||||||
"🛠️ Управляющая компания: (укажите вашу УК)\n\n"
|
"🛠️ Управляющая компания: (укажите вашу УК)\n\n"
|
||||||
"<i>Этот бот создан для объединения жильцов и быстрого решения проблем.</i>")
|
"<i>Этот бот создан для объединения жильцов и быстрого решения проблем.</i>")
|
||||||
await msg.answer(text, parse_mode='HTML')
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
|
||||||
|
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
else:
|
||||||
|
await event.answer()
|
||||||
|
await event.message.edit_text(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
|
||||||
|
@router.message(Command('profile'))
|
||||||
@router.callback_query(F.data == 'my_profile')
|
@router.callback_query(F.data == 'my_profile')
|
||||||
async def cb_profile(event):
|
async def cb_profile(event):
|
||||||
from utils.ui_styles import get_level_badge, get_user_level_key
|
from utils.ui_styles import get_level_badge, get_user_level_key
|
||||||
user_id = event.from_user.id
|
user_id = event.from_user.id
|
||||||
msg = event if isinstance(event, Message) else event.message
|
|
||||||
if isinstance(event, CallbackQuery): await event.answer()
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
|
|
@ -140,28 +205,47 @@ async def cb_profile(event):
|
||||||
f"Подъезд: {user.staircase or '—'} | Этаж: {user.floor or '—'}\n"
|
f"Подъезд: {user.staircase or '—'} | Этаж: {user.floor or '—'}\n"
|
||||||
f"Рейтинг: {user.rating} 🏆")
|
f"Рейтинг: {user.rating} 🏆")
|
||||||
|
|
||||||
kb = InlineKeyboardBuilder()
|
kb = get_profile_keyboard()
|
||||||
kb.row(InlineKeyboardButton(text="📍 Указать подъезд/этаж", callback_data="edit_location"))
|
|
||||||
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
|
|
||||||
|
|
||||||
await msg.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
if isinstance(event, Message):
|
||||||
|
await event.answer(text, reply_markup=kb, parse_mode='HTML')
|
||||||
|
else:
|
||||||
|
await event.answer()
|
||||||
|
await event.message.edit_text(text, reply_markup=kb, parse_mode='HTML')
|
||||||
|
|
||||||
@router.callback_query(F.data == "edit_location")
|
@router.callback_query(F.data == "edit_location")
|
||||||
async def edit_location_start(callback: CallbackQuery):
|
async def edit_location_start(callback: CallbackQuery):
|
||||||
await callback.message.answer("Введите ваш подъезд и этаж через пробел\n<i>Например: '3 5' (3 подъезд, 5 этаж)</i>")
|
await callback.message.edit_text("Введите ваш подъезд и этаж через пробел\n<i>Например: '3 5' (3 подъезд, 5 этаж)</i>", parse_mode='HTML')
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
@router.message(F.text.regexp(r"^\d+\s+\d+$"))
|
@router.message(F.text.regexp(r"^\d+\s+\d+$"))
|
||||||
async def process_location(message: Message):
|
async def process_location(message: Message):
|
||||||
staircase, floor = map(int, message.text.split())
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
staircase, floor = map(int, message.text.split())
|
||||||
await session.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor))
|
async with AsyncSessionLocal() as session:
|
||||||
await session.commit()
|
await session.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor))
|
||||||
await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}")
|
await session.commit()
|
||||||
|
await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}", reply_markup=get_profile_keyboard(), parse_mode='HTML')
|
||||||
|
except Exception as e:
|
||||||
|
await message.answer("❌ Ошибка формата. Введите два числа через пробел.")
|
||||||
|
|
||||||
@router.message(Command('help'))
|
@router.message(Command('help'))
|
||||||
@router.callback_query(F.data == 'help')
|
@router.callback_query(F.data == 'help')
|
||||||
async def cb_help(event):
|
async def cb_help(event):
|
||||||
msg = event if isinstance(event, Message) else event.message
|
text = ("<b>❓ СПРАВКА</b>\n\n"
|
||||||
if isinstance(event, CallbackQuery): await event.answer()
|
"• Используйте кнопки в главном меню для навигации.\n"
|
||||||
await msg.answer("<b>❓ СПРАВКА</b>\n\n/start — Главное меню\n/phones — Телефоны\n/about — О доме\n/profile — Твой профиль\n/rules — Правила")
|
"• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
|
||||||
|
"<b>Список команд:</b>\n"
|
||||||
|
"/start — Главное меню\n"
|
||||||
|
"/phones — Телефоны\n"
|
||||||
|
"/about — О доме\n"
|
||||||
|
"/profile — Твой профиль\n"
|
||||||
|
"/rules — Правила чата")
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
|
||||||
|
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
else:
|
||||||
|
await event.answer()
|
||||||
|
await event.message.edit_text(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,106 @@
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import aiohttp
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
from config import ADMIN_USER_ID
|
from config import ADMIN_USER_ID, DATABASE_PATH
|
||||||
from services.voice_service import VoiceService
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
_voice_service = None
|
WHISPER_API_URL = "http://192.168.10.167:9001/transcribe"
|
||||||
|
|
||||||
def get_voice_service():
|
def log_voice_to_db(user_id, username, msg_type, duration, text):
|
||||||
global _voice_service
|
"""Фиксация статистики в основную базу Домового"""
|
||||||
if _voice_service is None:
|
try:
|
||||||
DB_PATH = "/home/matrixhasyou/domovoy_bot/database/domovoy.db"
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
_voice_service = VoiceService(db_path=DB_PATH, model_size="small")
|
cursor = conn.cursor()
|
||||||
return _voice_service
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS voice_recognition_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username TEXT,
|
||||||
|
message_type TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
text TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO voice_recognition_logs (user_id, username, message_type, duration, text)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (user_id, username, msg_type, duration, text))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Log error: {e}")
|
||||||
|
|
||||||
@router.message(F.voice | F.video_note)
|
@router.message(F.voice | F.video_note)
|
||||||
async def handle_voice_message(message: Message, bot: Bot):
|
async def handle_voice_message(message: Message, bot: Bot):
|
||||||
|
# ПЕРЕВОДИМ ТОЛЬКО ДЛЯ ТЕБЯ (СУПЕРАДМИНА)
|
||||||
if int(message.from_user.id) != int(ADMIN_USER_ID):
|
if int(message.from_user.id) != int(ADMIN_USER_ID):
|
||||||
return
|
return
|
||||||
|
|
||||||
status_msg = await message.reply("⏳ <i>Расшифровываю...</i>", parse_mode="HTML")
|
status_msg = await message.reply("⏳ <i>Распознаю голос (Whisper AI)...</i>", parse_mode="HTML")
|
||||||
await bot.send_chat_action(message.chat.id, "typing")
|
await bot.send_chat_action(message.chat.id, "typing")
|
||||||
|
|
||||||
# Абсолютный путь к временной папке
|
|
||||||
temp_dir = Path("/home/matrixhasyou/domovoy_bot/temp_voice")
|
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if message.voice:
|
if message.voice:
|
||||||
file_id = message.voice.file_id
|
file_id = message.voice.file_id
|
||||||
ext = "oga"
|
filename = f"{file_id}.oga"
|
||||||
msg_type = "VOICE"
|
msg_type = "VOICE"
|
||||||
else:
|
else:
|
||||||
file_id = message.video_note.file_id
|
file_id = message.video_note.file_id
|
||||||
ext = "mp4"
|
filename = f"{file_id}.mp4"
|
||||||
msg_type = "VIDEO_NOTE"
|
msg_type = "VIDEO_NOTE"
|
||||||
|
|
||||||
file = await bot.get_file(file_id)
|
file = await bot.get_file(file_id)
|
||||||
file_path = temp_dir / f"{file_id}.{ext}"
|
|
||||||
|
|
||||||
# Скачиваем
|
# Используем aiohttp для отправки файла в микросервис
|
||||||
await bot.download_file(file.file_path, str(file_path))
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# Скачиваем файл во временный буфер
|
||||||
|
file_url = f"https://api.telegram.org/file/bot{bot.token}/{file.file_path}"
|
||||||
|
|
||||||
# Ждем секунду, чтобы ФС успела "переварить" файл
|
# Прокси если нужно (подтянется из конфига если настроено, но тут идем напрямую)
|
||||||
import asyncio
|
async with session.get(file_url) as file_resp:
|
||||||
await asyncio.sleep(1)
|
if file_resp.status != 200:
|
||||||
|
raise Exception(f"Не удалось скачать файл из TG: {file_resp.status}")
|
||||||
|
|
||||||
if not file_path.exists():
|
file_content = await file_resp.read()
|
||||||
raise FileNotFoundError(f"Файл {file_path} не был скачан!")
|
|
||||||
|
|
||||||
service = get_voice_service()
|
# Отправляем в наш Whisper-Service
|
||||||
text = service.transcribe(str(file_path), message.from_user.id, message.from_user.username or "Owner", msg_type)
|
data = aiohttp.FormData()
|
||||||
|
data.add_field('file', file_content, filename=filename)
|
||||||
|
|
||||||
if file_path.exists(): file_path.unlink()
|
async with session.post(WHISPER_API_URL, data=data) as whisper_resp:
|
||||||
|
if whisper_resp.status != 200:
|
||||||
|
err_text = await whisper_resp.text()
|
||||||
|
raise Exception(f"Ошибка Whisper API: {err_text}")
|
||||||
|
|
||||||
|
result = await whisper_resp.json()
|
||||||
|
text = result.get("text", "")
|
||||||
|
duration = result.get("duration", 0)
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
await status_msg.edit_text(f"📝 <b>РАСШИФРОВКА:</b>\n\n{text}", parse_mode="HTML")
|
# Логируем в базу
|
||||||
|
log_voice_to_db(message.from_user.id, message.from_user.username, msg_type, int(duration), text)
|
||||||
|
|
||||||
|
# Формируем красивый ответ
|
||||||
|
response = f"📝 <b>РАСШИФРОВКА</b> ({msg_type})\n"
|
||||||
|
response += f"⏱ Длительность: {int(duration)}с\n"
|
||||||
|
response += f"━━━━━━━━━━━━━━\n"
|
||||||
|
response += f"<i>{text}</i>"
|
||||||
|
|
||||||
|
await status_msg.edit_text(response, parse_mode="HTML")
|
||||||
else:
|
else:
|
||||||
await status_msg.edit_text("🔇 Не удалось распознать текст.")
|
await status_msg.edit_text("🔇 Голос обнаружен, но слов не разобрал.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Voice Error: {e}")
|
logger.error(f"❌ Voice Integration Error: {e}")
|
||||||
await status_msg.edit_text(f"❌ Ошибка: {e}")
|
await status_msg.edit_text(f"❌ Ошибка модуля Whisper: {e}")
|
||||||
|
|
||||||
voice_router = router
|
voice_router = router
|
||||||
|
|
|
||||||
|
|
@ -349,3 +349,62 @@ def get_payment_type_menu() -> InlineKeyboardMarkup:
|
||||||
)
|
)
|
||||||
|
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
def get_events_keyboard(events: list) -> InlineKeyboardMarkup:
|
||||||
|
"""Список событий"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for event in events:
|
||||||
|
builder.row(InlineKeyboardButton(
|
||||||
|
text=f"📅 {event['title']}",
|
||||||
|
callback_data=f"event_view_{event['id']}"
|
||||||
|
))
|
||||||
|
builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_view_keyboard(event_id: int, is_participant: bool, is_admin: bool = False) -> InlineKeyboardMarkup:
|
||||||
|
"""Просмотр события"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
if is_participant:
|
||||||
|
builder.row(InlineKeyboardButton(text='❌ Покинуть', callback_data=f"event_leave_{event_id}"))
|
||||||
|
else:
|
||||||
|
builder.row(InlineKeyboardButton(text='✅ Участвовать', callback_data=f"event_join_{event_id}"))
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
builder.row(InlineKeyboardButton(text='🗑️ Удалить (Админ)', callback_data=f"event_delete_{event_id}"))
|
||||||
|
|
||||||
|
builder.row(InlineKeyboardButton(text='🔙 К списку', callback_data='events'))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_polls_list_keyboard(polls: list) -> InlineKeyboardMarkup:
|
||||||
|
"""Список активных опросов"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for poll in polls:
|
||||||
|
builder.row(InlineKeyboardButton(
|
||||||
|
text=f"📊 {poll.question[:40]}",
|
||||||
|
callback_data=f"poll_view_{poll.poll_id}"
|
||||||
|
))
|
||||||
|
builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_poll_view_keyboard(poll_id: int, is_admin: bool = False) -> InlineKeyboardMarkup:
|
||||||
|
"""Меню просмотра опроса"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text='🗳️ Голосовать', callback_data=f"poll_vote_menu_{poll_id}"))
|
||||||
|
builder.row(InlineKeyboardButton(text='📊 Результаты', callback_data=f"poll_results_{poll_id}"))
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
builder.row(InlineKeyboardButton(text='🔒 Закрыть опрос', callback_data=f"poll_close_{poll_id}"))
|
||||||
|
|
||||||
|
builder.row(InlineKeyboardButton(text='🔙 К списку', callback_data='polls'))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура профиля"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text='🔄 Обновить', callback_data='my_profile'))
|
||||||
|
builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ def get_reply_menu() -> ReplyKeyboardMarkup:
|
||||||
|
|
||||||
builder.row(
|
builder.row(
|
||||||
KeyboardButton(text='📞 Телефоны'),
|
KeyboardButton(text='📞 Телефоны'),
|
||||||
|
KeyboardButton(text='👷 Мастера'),
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
KeyboardButton(text='❓ Помощь'),
|
KeyboardButton(text='❓ Помощь'),
|
||||||
|
|
|
||||||
5
main.py
|
|
@ -9,6 +9,11 @@ from pathlib import Path
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(BASE_DIR))
|
sys.path.insert(0, str(BASE_DIR))
|
||||||
|
|
||||||
|
# Добавляем путь к библиотекам роя
|
||||||
|
LIBS_DIR = BASE_DIR.parent.parent / "libs"
|
||||||
|
if LIBS_DIR.exists():
|
||||||
|
sys.path.insert(0, str(LIBS_DIR))
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
|
||||||
26
manual_export_trigger.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Добавляем путь к проекту в sys.path
|
||||||
|
project_root = "/home/matrixhasyou/domovoy_bot"
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
os.chdir(project_root)
|
||||||
|
|
||||||
|
from database.db import AsyncSessionLocal
|
||||||
|
from services.chat_exporter import ChatExporter
|
||||||
|
|
||||||
|
async def manual_export():
|
||||||
|
print("[*] Starting manual export to Forgejo...")
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
exporter = ChatExporter(session)
|
||||||
|
result = await exporter.run_full_export()
|
||||||
|
print(f"[*] Result: {result}")
|
||||||
|
if result.get("success"):
|
||||||
|
print("✅ Export completed successfully!")
|
||||||
|
else:
|
||||||
|
print(f"❌ Export failed: {result.get('error')}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(manual_export())
|
||||||
|
|
@ -10,5 +10,5 @@ fastapi>=0.109.0
|
||||||
uvicorn>=0.27.0
|
uvicorn>=0.27.0
|
||||||
jinja2>=3.1.3
|
jinja2>=3.1.3
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
faster-whisper
|
# faster-whisper # Moved to MacMini M4 API
|
||||||
pydub
|
pydub
|
||||||
|
|
|
||||||
66
scraper_gis/build_web_index.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import json, os, subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
NAS_WEB_ROOT = "/volume1/web/legal/gis"
|
||||||
|
HTML_FILE = "/app/web_index.html"
|
||||||
|
|
||||||
|
def run():
|
||||||
|
# Получаем список папок на NAS через SSH
|
||||||
|
cmd = ["ssh", "matrixhasyou@192.168.10.105", f"ls -F {NAS_WEB_ROOT}/raw/"]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
folders = [f.strip('/') for f in result.stdout.split('\n') if f.strip() and f.endswith('/')]
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang='ru'>
|
||||||
|
<head>
|
||||||
|
<meta charset='UTF-8'>
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||||
|
<title>Жатва ГИС ЖКХ: LIVE</title>
|
||||||
|
<script src='https://cdn.tailwindcss.com'></script>
|
||||||
|
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css'>
|
||||||
|
</head>
|
||||||
|
<body class='bg-slate-100 text-slate-900 pb-10'>
|
||||||
|
<header class='bg-blue-600 text-white py-12 px-4 shadow-xl mb-8'>
|
||||||
|
<div class='container mx-auto max-w-4xl text-center'>
|
||||||
|
<h1 class='text-4xl font-black mb-2 tracking-tighter'>🚀 ВЕЛИКАЯ ЖАТВА ГИС ЖКХ</h1>
|
||||||
|
<p class='opacity-80 italic font-semibold text-sm uppercase tracking-widest'>Скриншоты-улики и вложения в реальном времени</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class='container mx-auto max-w-4xl px-4'>
|
||||||
|
<div class='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||||
|
"""
|
||||||
|
|
||||||
|
for f in folders:
|
||||||
|
html += f"""
|
||||||
|
<div class='bg-white rounded-3xl p-6 shadow-sm border border-slate-200 hover:shadow-md transition-all'>
|
||||||
|
<div class='flex items-center justify-between mb-4'>
|
||||||
|
<span class='bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest'>Обращение</span>
|
||||||
|
<span class='text-slate-400 font-mono text-xs'>{f}</span>
|
||||||
|
</div>
|
||||||
|
<div class='space-y-3'>
|
||||||
|
<a href='archive_new/{f}/evidence.png' target='_blank' class='flex items-center gap-2 bg-slate-900 text-white p-3 rounded-2xl text-xs font-bold hover:bg-slate-800 shadow-lg shadow-slate-200'>
|
||||||
|
<i class='bi bi-camera-fill'></i> Скриншот-улика (Дата/Время)
|
||||||
|
</a>
|
||||||
|
<a href='archive_new/{f}/attachments.zip' target='_blank' class='flex items-center gap-2 bg-blue-600 text-white p-3 rounded-2xl text-xs font-bold hover:bg-blue-500 shadow-lg shadow-blue-100'>
|
||||||
|
<i class='bi bi-file-zip-fill'></i> Скачать все документы (ZIP)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
html += """
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
with open(HTML_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
# Пушим на Synology
|
||||||
|
with open(HTML_FILE, 'rb') as f:
|
||||||
|
subprocess.run(["ssh", "matrixhasyou@192.168.10.105", f"cat > {NAS_WEB_ROOT}/index.html"], stdin=f)
|
||||||
|
print("✅ Сайт /harvest/ успешно обновлен с 10 новыми папками!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
35
scraper_gis/capture_ids.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
# Пробуем 127.0.0.1 вместо localhost для надежности
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
data = []
|
||||||
|
# Используем query_selector_all для простоты
|
||||||
|
rows = page.query_selector_all('tr')
|
||||||
|
print(f'DOM Rows: {len(rows)}')
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
text = row.inner_text()
|
||||||
|
if '73-20' in text:
|
||||||
|
# Ищем ссылку внутри строки
|
||||||
|
link = row.query_selector('a[href*="appeals/view/"]')
|
||||||
|
if link:
|
||||||
|
number = link.inner_text().strip()
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
data.append({'number': number, 'href': href})
|
||||||
|
|
||||||
|
with open('/app/queue.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'Captured: {len(data)}')
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
35
scraper_gis/capture_list.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
print(f'Attached to: {page.url}')
|
||||||
|
|
||||||
|
# Ждем прогрузки таблицы
|
||||||
|
page.wait_for_selector('tr', timeout=30000)
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
data = []
|
||||||
|
rows = page.query_selector_all('tr')
|
||||||
|
for row in rows:
|
||||||
|
text = row.inner_text()
|
||||||
|
if '73-20' in text:
|
||||||
|
link = row.query_selector('a[href*="appeals/view/"]')
|
||||||
|
if link:
|
||||||
|
number = link.inner_text().strip()
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
data.append({'number': number, 'href': href})
|
||||||
|
|
||||||
|
with open('/app/appeals_queue.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'SUCCESS: Captured {len(data)} items.')
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
37
scraper_gis/check_state.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# Пытаемся зайти в тот же профиль.
|
||||||
|
# Если браузер УЖЕ открыт, Playwright может выдать ошибку или открыть второе окно.
|
||||||
|
browser = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir='/app/browser_context',
|
||||||
|
headless=True
|
||||||
|
)
|
||||||
|
page = browser.pages[0]
|
||||||
|
|
||||||
|
# Ждем прогрузки, если вдруг открылась новая пустая страница
|
||||||
|
if 'about:blank' in page.url:
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/Pafo/Home')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
|
||||||
|
print(f'STATUS: URL is {page.url}')
|
||||||
|
page.screenshot(path='/app/current_state.png', full_page=True)
|
||||||
|
|
||||||
|
# Ищем конкретные ссылки
|
||||||
|
links = []
|
||||||
|
for a in page.query_selector_all('a'):
|
||||||
|
text = a.inner_text().strip()
|
||||||
|
if text: links.append(text)
|
||||||
|
|
||||||
|
with open('/app/links.json', 'w') as f:
|
||||||
|
json.dump(links, f)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {str(e)}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
36
scraper_gis/click_appeal.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
# Находим ссылку по тексту номера
|
||||||
|
# Но в таблице текст - это тема. Используем текст темы.
|
||||||
|
target_text = 'ТРЕБОВАНИЕ о недопустимости предоставления недостоверных сведений'
|
||||||
|
print(f'Looking for link with text: {target_text}')
|
||||||
|
|
||||||
|
# Пытаемся кликнуть по первой ссылке в таблице
|
||||||
|
page.locator('tr a[href*="appeals/view/"]').first.click()
|
||||||
|
|
||||||
|
print('Clicked! Waiting for load...')
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
print(f'New URL: {page.url}')
|
||||||
|
page.screenshot(path='/app/appeal_page_success.png', full_page=True)
|
||||||
|
|
||||||
|
# Пробуем найти кнопку Скачать все
|
||||||
|
btn = page.get_by_text('Скачать все')
|
||||||
|
if btn.is_visible():
|
||||||
|
print('Download button visible!')
|
||||||
|
btn.highlight()
|
||||||
|
page.screenshot(path='/app/download_btn_found.png')
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
34
scraper_gis/click_download.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
print(f'Currently at: {page.url}')
|
||||||
|
|
||||||
|
# Скроллим вниз, чтобы кнопка стала видимой/активной
|
||||||
|
page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Ищем кнопку по тексту
|
||||||
|
btn = page.get_by_text('Скачать все').first
|
||||||
|
if btn.is_visible():
|
||||||
|
print('Download button found! Clicking...')
|
||||||
|
with page.expect_download(timeout=120000) as download_info:
|
||||||
|
btn.click()
|
||||||
|
download = download_info.value
|
||||||
|
path = f'/app/downloads/harvested_appeal_73-2026-14124.zip'
|
||||||
|
download.save_as(path)
|
||||||
|
print(f'SUCCESS: Saved to {path}')
|
||||||
|
else:
|
||||||
|
print('Download button NOT VISIBLE. Dumping text...')
|
||||||
|
print(page.content()[-2000:]) # Дампим конец страницы
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
|
|
@ -4,6 +4,7 @@ services:
|
||||||
gis-scraper:
|
gis-scraper:
|
||||||
build: .
|
build: .
|
||||||
container_name: lkm37-gis-scraper
|
container_name: lkm37-gis-scraper
|
||||||
|
shm_size: 2gb
|
||||||
ports:
|
ports:
|
||||||
- "6080:6080"
|
- "6080:6080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
58
scraper_gis/get_data_v2.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
print(f'Attached. URL: {page.url}')
|
||||||
|
|
||||||
|
# Скроллим вниз, чтобы подгрузились элементы
|
||||||
|
page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
data = []
|
||||||
|
# Ищем все строки таблицы
|
||||||
|
rows = page.query_selector_all('tr')
|
||||||
|
print(f'Found {len(rows)} potential rows.')
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
inner_text = row.inner_text()
|
||||||
|
if '73-20' in inner_text:
|
||||||
|
# Нашли строку с обращением
|
||||||
|
link = row.query_selector('a[href*="appeals/view/"]')
|
||||||
|
if link:
|
||||||
|
num = link.inner_text().strip()
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
# Достаем статус
|
||||||
|
status = 'Unknown'
|
||||||
|
if 'Исполнено' in inner_text: status = 'Исполнено'
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'number': num,
|
||||||
|
'href': href,
|
||||||
|
'status': status
|
||||||
|
})
|
||||||
|
|
||||||
|
with open('/app/found_appeals.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'CAPTURED: {len(data)} appeals.')
|
||||||
|
|
||||||
|
# Если нашли хотя бы одно, попробуем зайти
|
||||||
|
if data:
|
||||||
|
target = data[0]
|
||||||
|
print(f'Attempting to enter: {target["number"]}')
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru' + target['href'])
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
page.screenshot(path='/app/appeal_detail_view.png', full_page=True)
|
||||||
|
print(f'Detail screenshot saved for {target["number"]}')
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
58
scraper_gis/get_full_queue.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time, re
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
# 1. Идем в список
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# 2. Пытаемся выставить 'По 100'
|
||||||
|
try:
|
||||||
|
# Ищем селект пагинации
|
||||||
|
page.locator('select.page-size-selector, .ui-select-container').click()
|
||||||
|
time.sleep(2)
|
||||||
|
page.get_by_text('100').click()
|
||||||
|
time.sleep(10)
|
||||||
|
except:
|
||||||
|
print('Не удалось выставить "по 100", будем собирать что есть.')
|
||||||
|
|
||||||
|
full_queue = []
|
||||||
|
|
||||||
|
# 3. Собираем со всех страниц (если их несколько)
|
||||||
|
while True:
|
||||||
|
rows = page.locator('tr').all()
|
||||||
|
for row in rows:
|
||||||
|
text = row.inner_text()
|
||||||
|
if '73-20' in text:
|
||||||
|
link = row.locator('a[href*="appeals/view/"]').first
|
||||||
|
num_match = re.search(r'73-202\d-\d+', text)
|
||||||
|
num = num_match.group(0) if num_match else "unknown"
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
if href and num not in [x['number'] for x in full_queue]:
|
||||||
|
full_queue.append({'number': num, 'href': href})
|
||||||
|
|
||||||
|
# Ищем кнопку Следующая
|
||||||
|
next_btn = page.locator('a:has-text("следующая")')
|
||||||
|
if next_btn.is_visible() and next_btn.is_enabled():
|
||||||
|
print(f'Переход на следующую страницу... Уже собрано: {len(full_queue)}')
|
||||||
|
next_btn.click()
|
||||||
|
time.sleep(10)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
with open('/app/services/gis_harvester/data/full_queue.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(full_queue, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'ИТОГО СОБРАНО: {len(full_queue)} целей.')
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
73
scraper_gis/get_full_queue_deep.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time, re
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Нажимаем 'Найти' для сброса фильтров
|
||||||
|
print('Нажимаем Найти...')
|
||||||
|
page.locator('button:has-text("Найти")').click()
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Пытаемся выставить 'По 100'
|
||||||
|
try:
|
||||||
|
page.evaluate('''() => {
|
||||||
|
const el = Array.from(document.querySelectorAll('span, div')).find(e => e.innerText === '10');
|
||||||
|
if (el) el.click();
|
||||||
|
}''')
|
||||||
|
time.sleep(2)
|
||||||
|
page.locator('div.ui-select-choices-row:has-text("100")').click()
|
||||||
|
print('Выбрано по 100.')
|
||||||
|
time.sleep(10)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
full_queue = []
|
||||||
|
page_num = 1
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Очистка оверлеев
|
||||||
|
page.evaluate('document.querySelectorAll(".p-dialog-mask, .modal-backdrop").forEach(el => el.remove())')
|
||||||
|
|
||||||
|
rows = page.locator('tr').all()
|
||||||
|
added_on_page = 0
|
||||||
|
for row in rows:
|
||||||
|
text = row.inner_text()
|
||||||
|
if '73-20' in text:
|
||||||
|
link = row.locator('a[href*="appeals/view/"]').first
|
||||||
|
num_match = re.search(r'73-202\d-\d+', text)
|
||||||
|
num = num_match.group(0) if num_match else f"unknown_{time.time()}"
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
if href and num not in [x['number'] for x in full_queue]:
|
||||||
|
full_queue.append({'number': num, 'href': href})
|
||||||
|
added_on_page += 1
|
||||||
|
|
||||||
|
print(f'Страница {page_num}: собрано {added_on_page} новых.')
|
||||||
|
|
||||||
|
# Ищем стрелку вправо или слово 'следующая'
|
||||||
|
next_btn = page.locator('a:has-text("следующая"), .pagination-next a').first
|
||||||
|
if next_btn.is_visible() and next_btn.is_enabled():
|
||||||
|
page.evaluate('el => el.click()', next_btn.element_handle())
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
page_num += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
output_path = '/app/services/gis_harvester/data/full_queue.json'
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(full_queue, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'ИТОГО СОБРАНО: {len(full_queue)} целей.')
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
79
scraper_gis/get_full_queue_resilient.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time, re
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Закрываем мешающие диалоги (Restore session и т.д.)
|
||||||
|
try:
|
||||||
|
page.keyboard.press('Escape')
|
||||||
|
time.sleep(2)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Пытаемся выставить 'По 100' через JavaScript (самый надежный способ в SPA)
|
||||||
|
try:
|
||||||
|
# В ГИС ЖКХ это обычно Angular-компонент. Попробуем найти селект по значению.
|
||||||
|
page.evaluate('''() => {
|
||||||
|
const selectors = document.querySelectorAll('select, .ui-select-container');
|
||||||
|
for (const s of selectors) {
|
||||||
|
if (s.innerText.includes('10')) {
|
||||||
|
s.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}''')
|
||||||
|
time.sleep(2)
|
||||||
|
page.get_by_text('100').click()
|
||||||
|
print('Выставлено по 100 на странице.')
|
||||||
|
time.sleep(10)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Не удалось выставить "по 100" программно: {e}')
|
||||||
|
|
||||||
|
full_queue = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Убираем оверлеи если они есть
|
||||||
|
page.evaluate('''() => {
|
||||||
|
document.querySelectorAll('.p-dialog-mask, .modal-backdrop').forEach(el => el.remove());
|
||||||
|
document.body.classList.remove('p-overflow-hidden');
|
||||||
|
}''')
|
||||||
|
|
||||||
|
rows = page.locator('tr').all()
|
||||||
|
for row in rows:
|
||||||
|
text = row.inner_text()
|
||||||
|
if '73-20' in text:
|
||||||
|
link = row.locator('a[href*="appeals/view/"]').first
|
||||||
|
num_match = re.search(r'73-202\d-\d+', text)
|
||||||
|
num = num_match.group(0) if num_match else f"unknown_{time.time()}"
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
if href and num not in [x['number'] for x in full_queue]:
|
||||||
|
full_queue.append({'number': num, 'href': href})
|
||||||
|
|
||||||
|
next_btn = page.locator('a:has-text("следующая")').first
|
||||||
|
if next_btn.is_visible() and next_btn.is_enabled():
|
||||||
|
print(f'Переход на следующую страницу... Уже собрано: {len(full_queue)}')
|
||||||
|
# Кликаем принудительно через JS если обычный клик перехвачен
|
||||||
|
page.evaluate('el => el.click()', next_btn.element_handle())
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Сохраняем в официальную папку сервиса
|
||||||
|
output_path = '/app/services/gis_harvester/data/full_queue.json'
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(full_queue, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'ИТОГО СОБРАНО: {len(full_queue)} целей.')
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
10
scraper_gis/get_screenshot.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://localhost:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
page.screenshot(path='/app/browser_only.png')
|
||||||
|
print(f'Browser URL: {page.url}')
|
||||||
|
browser.close()
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
51
scraper_gis/harvest.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time, os
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
context = browser.contexts[0]
|
||||||
|
page = context.pages[0]
|
||||||
|
|
||||||
|
print(f'Start Harvest. Current URL: {page.url}')
|
||||||
|
|
||||||
|
# 1. Screenshot of the list
|
||||||
|
page.screenshot(path='/app/appeals_list_final.png', full_page=True)
|
||||||
|
print('List screenshot saved.')
|
||||||
|
|
||||||
|
# 2. Click first appeal link
|
||||||
|
# The number is 73-2026-5028
|
||||||
|
first_appeal = page.get_by_text('73-2026-5028').first
|
||||||
|
if first_appeal.is_visible():
|
||||||
|
print('Entering appeal 73-2026-5028...')
|
||||||
|
first_appeal.click()
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# 3. Screenshot inside
|
||||||
|
page.screenshot(path='/app/appeal_inside.png', full_page=True)
|
||||||
|
print('Inside screenshot saved.')
|
||||||
|
|
||||||
|
# 4. Try to download ALL
|
||||||
|
try:
|
||||||
|
download_btn = page.get_by_text('Скачать все').first
|
||||||
|
if download_btn.is_visible():
|
||||||
|
print('Clicking Download All...')
|
||||||
|
with page.expect_download(timeout=60000) as download_info:
|
||||||
|
download_btn.click()
|
||||||
|
download = download_info.value
|
||||||
|
path = f'/app/downloads/73-2026-5028_archive.zip'
|
||||||
|
download.save_as(path)
|
||||||
|
print(f'Archive saved to {path}')
|
||||||
|
else:
|
||||||
|
print('Download All button NOT found.')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Download failed: {e}')
|
||||||
|
else:
|
||||||
|
print('Appeal 73-2026-5028 NOT found in list.')
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
|
||||||
|
run()
|
||||||
BIN
scraper_gis/harvest_archive/73-2025-28128/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2025-28128/evidence.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
scraper_gis/harvest_archive/73-2026-14011/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-14011/evidence.png
Normal file
|
After Width: | Height: | Size: 550 KiB |
BIN
scraper_gis/harvest_archive/73-2026-14014/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-14014/evidence.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
scraper_gis/harvest_archive/73-2026-14016/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-14016/evidence.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
scraper_gis/harvest_archive/73-2026-14123/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-14123/evidence.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
scraper_gis/harvest_archive/73-2026-14124/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-14124/evidence.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
scraper_gis/harvest_archive/73-2026-14126/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-14126/evidence.png
Normal file
|
After Width: | Height: | Size: 430 KiB |
BIN
scraper_gis/harvest_archive/73-2026-3108/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-3108/evidence.png
Normal file
|
After Width: | Height: | Size: 413 KiB |
BIN
scraper_gis/harvest_archive/73-2026-5028/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-5028/evidence.png
Normal file
|
After Width: | Height: | Size: 441 KiB |
BIN
scraper_gis/harvest_archive/73-2026-5818/attachments.zip
Normal file
BIN
scraper_gis/harvest_archive/73-2026-5818/evidence.png
Normal file
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 494 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 230 KiB |
58
scraper_gis/harvest_final.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time, os
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
# Собираем видимые ссылки
|
||||||
|
links = page.query_selector_all('a[href*="appeals/view/"]')
|
||||||
|
print(f'Visible links: {len(links)}')
|
||||||
|
|
||||||
|
if not links:
|
||||||
|
print('No links found. Scrolling...')
|
||||||
|
page.evaluate('window.scrollTo(0, 500)')
|
||||||
|
time.sleep(2)
|
||||||
|
links = page.query_selector_all('a[href*="appeals/view/"]')
|
||||||
|
|
||||||
|
for i in range(min(3, len(links))):
|
||||||
|
# Находим ссылку заново после навигации назад
|
||||||
|
links = page.query_selector_all('a[href*="appeals/view/"]')
|
||||||
|
target = links[i]
|
||||||
|
num = target.inner_text().strip()
|
||||||
|
print(f'Step {i+1}: Entering {num}')
|
||||||
|
|
||||||
|
target.click()
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Скриншот внутри
|
||||||
|
page.screenshot(path=f'/app/downloads/screen_{num}.png', full_page=True)
|
||||||
|
|
||||||
|
# Download All
|
||||||
|
btn = page.get_by_text('Скачать все').first
|
||||||
|
if btn.is_visible():
|
||||||
|
print(f'Downloading all files for {num}...')
|
||||||
|
with page.expect_download(timeout=60000) as download_info:
|
||||||
|
btn.click()
|
||||||
|
download = download_info.value
|
||||||
|
download.save_as(f'/app/downloads/archive_{num}.zip')
|
||||||
|
print(f'SUCCESS: Saved archive_{num}.zip')
|
||||||
|
else:
|
||||||
|
print(f'Download button NOT found for {num}')
|
||||||
|
|
||||||
|
# Go back
|
||||||
|
print('Going back...')
|
||||||
|
page.go_back()
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
|
||||||
|
run()
|
||||||
38
scraper_gis/harvest_simple.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time, os
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
# Находим первую ссылку и кликаем
|
||||||
|
link = page.query_selector('a[href*="appeals/view/"]')
|
||||||
|
if link:
|
||||||
|
num = link.inner_text().strip()
|
||||||
|
print(f'Clicking appeal: {num}')
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
# Ждем появления кнопки Скачать все
|
||||||
|
print('Waiting for Download All button...')
|
||||||
|
page.wait_for_selector('button:has-text("Скачать все")', timeout=60000)
|
||||||
|
|
||||||
|
# Кликаем и ждем загрузку
|
||||||
|
with page.expect_download(timeout=120000) as download_info:
|
||||||
|
page.get_by_text('Скачать все').first.click()
|
||||||
|
|
||||||
|
download = download_info.value
|
||||||
|
path = f'/app/downloads/harvested_file.zip'
|
||||||
|
download.save_as(path)
|
||||||
|
print(f'SUCCESS: Downloaded to {path}')
|
||||||
|
else:
|
||||||
|
print('No appeal links found on page!')
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
|
||||||
|
run()
|
||||||
43
scraper_gis/harvest_v2.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time, os
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
print(f'URL: {page.url}')
|
||||||
|
|
||||||
|
# 1. Close popup if exists
|
||||||
|
# We see a Restore popup in screenshots
|
||||||
|
try:
|
||||||
|
page.keyboard.press('Escape')
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 2. Click the appeal
|
||||||
|
print('Clicking 73-2026-5028...')
|
||||||
|
page.get_by_text('73-2026-5028').first.click()
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
print(f'New URL: {page.url}')
|
||||||
|
|
||||||
|
# 3. Download
|
||||||
|
download_btn = page.get_by_text('Скачать все')
|
||||||
|
if download_btn.is_visible():
|
||||||
|
print('Found Download button. Clicking...')
|
||||||
|
with page.expect_download(timeout=120000) as download_info:
|
||||||
|
download_btn.click()
|
||||||
|
download = download_info.value
|
||||||
|
path = f'/app/downloads/test_appeal.zip'
|
||||||
|
download.save_as(path)
|
||||||
|
print(f'SUCCESS: Saved to {path}')
|
||||||
|
else:
|
||||||
|
print('Download button NOT found inside.')
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
|
||||||
|
run()
|
||||||
BIN
scraper_gis/harvest_web_sync/73-2025-28128/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2025-28128/evidence.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-14011/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-14011/evidence.png
Normal file
|
After Width: | Height: | Size: 550 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-14014/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-14014/evidence.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-14016/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-14016/evidence.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-14123/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-14123/evidence.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-14124/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-14124/evidence.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-14126/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-14126/evidence.png
Normal file
|
After Width: | Height: | Size: 430 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-3108/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-3108/evidence.png
Normal file
|
After Width: | Height: | Size: 413 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-5028/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-5028/evidence.png
Normal file
|
After Width: | Height: | Size: 441 KiB |
BIN
scraper_gis/harvest_web_sync/73-2026-5818/attachments.zip
Normal file
BIN
scraper_gis/harvest_web_sync/73-2026-5818/evidence.png
Normal file
|
After Width: | Height: | Size: 453 KiB |
1
scraper_gis/links.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
["\u0420\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u044b \u0438 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438", "\u0427\u0430\u0441\u0442\u043e \u0437\u0430\u0434\u0430\u0432\u0430\u0435\u043c\u044b\u0435 \u0432\u043e\u043f\u0440\u043e\u0441\u044b", "\u041a\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430", "\u0412\u0435\u0440\u0441\u0438\u044f \u0434\u043b\u044f \u0441\u043b\u0430\u0431\u043e\u0432\u0438\u0434\u044f\u0449\u0438\u0445", "\u0413\u0418\u0421 \u0416\u041a\u0425", "\u0413\u043e\u0441\u0443\u0434\u0430\u0440\u0441\u0442\u0432\u0435\u043d\u043d\u0430\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u0430\u044f \u0441\u0438\u0441\u0442\u0435\u043c\u0430 \u0436\u0438\u043b\u0438\u0449\u043d\u043e-\u043a\u043e\u043c\u043c\u0443\u043d\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u0445\u043e\u0437\u044f\u0439\u0441\u0442\u0432\u0430", "\u041e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u0435\n\u0432 \u0441\u043b\u0443\u0436\u0431\u0443 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0438", "\u0433\u043b\u0430\u0432\u043d\u0443\u044e \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0443", "\u0410\u041e \"\u041e\u041f\u0415\u0420\u0410\u0422\u041e\u0420 \u0418\u041d\u0424\u041e\u0420\u041c\u0410\u0426\u0418\u041e\u041d\u041d\u041e\u0419 \u0421\u0418\u0421\u0422\u0415\u041c\u042b\"", "\u041c\u0438\u043d\u0441\u0442\u0440\u043e\u0439 \u0420\u043e\u0441\u0441\u0438\u0438"]
|
||||||
38
scraper_gis/live_control.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# Подключаемся к уже открытому на экране браузеру
|
||||||
|
browser = p.chromium.connect_over_cdp('http://localhost:9222')
|
||||||
|
context = browser.contexts[0]
|
||||||
|
page = context.pages[0]
|
||||||
|
|
||||||
|
print(f'Подключился к браузеру. URL: {page.url}')
|
||||||
|
|
||||||
|
# 1. Переходим в Обращения (если мы не там)
|
||||||
|
if 'appeals' not in page.url:
|
||||||
|
print('Перехожу в раздел Обращений...')
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# 2. Подсвечиваем элементы, чтобы пользователь видел активность
|
||||||
|
# Мы будем рисовать рамки вокруг номеров обращений
|
||||||
|
print('Ищу номера обращений...')
|
||||||
|
page.evaluate('''() => {
|
||||||
|
const links = Array.from(document.querySelectorAll('a'));
|
||||||
|
links.filter(a => a.href.includes('appeals/view/')).forEach(a => {
|
||||||
|
a.style.border = '3px solid red';
|
||||||
|
a.style.backgroundColor = 'yellow';
|
||||||
|
});
|
||||||
|
}''')
|
||||||
|
|
||||||
|
page.screenshot(path='/app/live_discovery.png')
|
||||||
|
print('Сделал скриншот с подсветкой.')
|
||||||
|
|
||||||
|
# Не закрываем браузер! Просто отключаемся
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
14
scraper_gis/nav_appeals.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
print(f'Navigating to appeals... Current: {page.url}')
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
page.screenshot(path='/app/appeals_mapped.png', full_page=True)
|
||||||
|
browser.close()
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
52
scraper_gis/recon.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time, re
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch_persistent_context(user_data_dir='/app/browser_context', headless=True)
|
||||||
|
page = browser.pages[0]
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
# --- 1. ОБРАЩЕНИЯ ---
|
||||||
|
print('Разведка Обращений...')
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(7)
|
||||||
|
page.screenshot(path='/app/recon_appeals.png', full_page=True)
|
||||||
|
|
||||||
|
appeals = []
|
||||||
|
links = page.query_selector_all('a')
|
||||||
|
for link in links:
|
||||||
|
href = link.get_attribute('href') or ''
|
||||||
|
if 'appeals/view/' in href:
|
||||||
|
text = link.inner_text().strip()
|
||||||
|
if text:
|
||||||
|
appeals.append({'number': text, 'url': href})
|
||||||
|
results['appeals'] = appeals
|
||||||
|
print(f'Найдено обращений: {len(appeals)}')
|
||||||
|
|
||||||
|
# --- 2. ДОГОВОРЫ ---
|
||||||
|
print('Разведка Договоров...')
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/agreements/citizen/4b1aae97-01e6-4083-bf0c-029d01bbda2a?apartmentNumber=152')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(7)
|
||||||
|
page.screenshot(path='/app/recon_agreements.png', full_page=True)
|
||||||
|
|
||||||
|
agreements = []
|
||||||
|
links = page.query_selector_all('a')
|
||||||
|
for link in links:
|
||||||
|
href = link.get_attribute('href') or ''
|
||||||
|
if 'agreements/dogpoi/view/' in href:
|
||||||
|
text = link.inner_text().strip()
|
||||||
|
if text:
|
||||||
|
agreements.append({'name': text, 'url': href})
|
||||||
|
results['agreements'] = agreements
|
||||||
|
print(f'Найдено договоров: {len(agreements)}')
|
||||||
|
|
||||||
|
with open('/app/recon_data.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
4
scraper_gis/recon_data.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"appeals": [],
|
||||||
|
"agreements": []
|
||||||
|
}
|
||||||
62
scraper_gis/reset_and_collect.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import json, time, re
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Нажимаем кнопку 'Свернуть/Развернуть поиск' если нужно
|
||||||
|
# На скриншоте видно панель поиска. Попробуем нажать 'Очистить' если она есть.
|
||||||
|
try:
|
||||||
|
# Очистка всех инпутов
|
||||||
|
page.evaluate('''() => {
|
||||||
|
document.querySelectorAll('input').forEach(i => { i.value = ''; });
|
||||||
|
// Пытаемся найти кнопку сброса
|
||||||
|
const reset = Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('Очистить'));
|
||||||
|
if (reset) reset.click();
|
||||||
|
}''')
|
||||||
|
time.sleep(5)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Нажимаем Найти
|
||||||
|
page.locator('button:has-text("Найти")').click()
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
full_queue = []
|
||||||
|
while True:
|
||||||
|
page.evaluate('document.querySelectorAll(".p-dialog-mask, .modal-backdrop").forEach(el => el.remove())')
|
||||||
|
rows = page.locator('tr').all()
|
||||||
|
for row in rows:
|
||||||
|
text = row.inner_text()
|
||||||
|
if '73-20' in text:
|
||||||
|
link = row.locator('a[href*="appeals/view/"]').first
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
num_match = re.search(r'73-202\d-\d+', text)
|
||||||
|
num = num_match.group(0) if num_match else f"case_{time.time()}"
|
||||||
|
if href and num not in [x['number'] for x in full_queue]:
|
||||||
|
full_queue.append({'number': num, 'href': href})
|
||||||
|
|
||||||
|
next_btn = page.locator('a:has-text("следующая"), .pagination-next a').first
|
||||||
|
if next_btn.is_visible() and next_btn.is_enabled():
|
||||||
|
page.evaluate('el => el.click()', next_btn.element_handle())
|
||||||
|
time.sleep(10)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
output_path = '/app/services/gis_harvester/data/full_queue.json'
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(full_queue, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'ПОЛНЫЙ СПИСОК: {len(full_queue)} целей.')
|
||||||
|
browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {e}')
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
19
scraper_gis/restore.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
|
||||||
|
page = browser.contexts[0].pages[0]
|
||||||
|
print(f'Navigating to appeals list... Current: {page.url}')
|
||||||
|
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
time.sleep(10)
|
||||||
|
print(f'Final URL: {page.url}')
|
||||||
|
browser.close() # disconnect actually
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 230 KiB |