Compare commits
6 commits
2be7aea46e
...
35ffe3442c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35ffe3442c | ||
|
|
a1ae78e5f8 | ||
|
|
f9116f14e9 | ||
|
|
02d793d178 | ||
|
|
71bf32cad8 | ||
|
|
6dc882fa19 |
26 changed files with 1187 additions and 160 deletions
25
.dockerignore
Normal file
25
.dockerignore
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
venv/
|
||||||
|
venv-google/
|
||||||
|
logs/
|
||||||
|
exports/
|
||||||
|
data/
|
||||||
|
database/*.db
|
||||||
|
database/*.db.bak
|
||||||
|
backups/
|
||||||
|
nohup.out
|
||||||
|
scraper_gis/browser_context/
|
||||||
|
scraper_gis/exports/
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.export_progress
|
||||||
|
.phase2_progress
|
||||||
|
export.log
|
||||||
|
auditor_run.log
|
||||||
|
pipeline.log
|
||||||
|
web.log
|
||||||
|
web_debug.log
|
||||||
|
systemd.log
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -1,12 +1,18 @@
|
||||||
# Использование легкого образа Python
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
# Установка рабочей директории
|
# Установка рабочей директории
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Установка системных зависимостей (если понадобятся для каких-то либ)
|
# Установка системных зависимостей для ffmpeg, weasyprint (pango/cairo) и LibreOffice
|
||||||
RUN apt-get update && apt-get install -y \ ffmpeg
|
RUN apt-get update && apt-get install -y \
|
||||||
sqlite3 \
|
ffmpeg \
|
||||||
|
libreoffice \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
shared-mime-info \
|
||||||
|
fonts-dejavu \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Копирование requirements и установка зависимостей
|
# Копирование requirements и установка зависимостей
|
||||||
|
|
|
||||||
|
|
@ -812,3 +812,20 @@ class Artifact(Base):
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Artifact {self.filename} ({self.file_type})>"
|
return f"<Artifact {self.filename} ({self.file_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Speedtest(Base):
|
||||||
|
"""Результаты замеров скорости интернета"""
|
||||||
|
__tablename__ = 'speedtests'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||||
|
download = Column(Float, nullable=True)
|
||||||
|
upload = Column(Float, nullable=True)
|
||||||
|
ping = Column(Float, nullable=True)
|
||||||
|
server_name = Column(String(255), nullable=True)
|
||||||
|
result_url = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Speedtest #{self.id} DL={self.download} UP={self.upload} Server={self.server_name}>"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,50 +3,39 @@ version: '3.8'
|
||||||
services:
|
services:
|
||||||
domovoy-bot:
|
domovoy-bot:
|
||||||
build: .
|
build: .
|
||||||
container_name: lkm37-core
|
container_name: domovoy-bot
|
||||||
restart: unless-stopped
|
network_mode: "host"
|
||||||
env_file:
|
restart: always
|
||||||
- .env
|
environment:
|
||||||
|
- PYTHONPATH=/app:/app/services
|
||||||
volumes:
|
volumes:
|
||||||
# Монтируем базу данных и важные данные для сохранения
|
|
||||||
- ./database:/app/database
|
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- ./data:/app/data
|
|
||||||
- ./exports:/app/exports
|
- ./exports:/app/exports
|
||||||
- ./backups:/app/backups
|
- ./data:/app/data
|
||||||
- ./web:/app/web
|
- ../libs/swarmlib:/app/swarmlib:ro
|
||||||
- ./services/email_auditor/exported_emails:/app/services/email_auditor/exported_emails
|
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
||||||
network_mode: host
|
|
||||||
environment:
|
|
||||||
- TZ=Europe/Moscow
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
email_auditor:
|
domovoy-web:
|
||||||
build:
|
build: .
|
||||||
context: ./services/email_auditor
|
container_name: domovoy-web
|
||||||
dockerfile: Dockerfile
|
command: python -m uvicorn web.app:app --host 0.0.0.0 --port 8000
|
||||||
container_name: email-auditor
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
env_file:
|
environment:
|
||||||
- .env
|
- PYTHONPATH=/app:/app/services
|
||||||
volumes:
|
volumes:
|
||||||
- ./database:/app/database
|
- ./logs:/app/logs
|
||||||
- ./services/email_auditor/exported_emails:/app/exported_emails
|
- ./exports:/app/exports
|
||||||
network_mode: host
|
- ./data:/app/data
|
||||||
environment:
|
- ../libs/swarmlib:/app/swarmlib:ro
|
||||||
- TZ=Europe/Moscow
|
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
name: lkm37-net
|
|
||||||
|
|
|
||||||
143
handlers/ads.py
143
handlers/ads.py
|
|
@ -5,7 +5,7 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InputMediaPhoto
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
@ -15,7 +15,7 @@ from database.db import AsyncSessionLocal
|
||||||
from database.models import Ad, User
|
from database.models import Ad, User
|
||||||
from utils.ui_styles import fmt_msg
|
from utils.ui_styles import fmt_msg
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import InlineKeyboardButton
|
||||||
import config
|
import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -37,17 +37,19 @@ def get_ads_main_menu():
|
||||||
builder.row(InlineKeyboardButton(text="🛒 Все товары", callback_data="ads_view_all"))
|
builder.row(InlineKeyboardButton(text="🛒 Все товары", callback_data="ads_view_all"))
|
||||||
builder.row(InlineKeyboardButton(text="➕ Создать лот", callback_data="ads_create"))
|
builder.row(InlineKeyboardButton(text="➕ Создать лот", callback_data="ads_create"))
|
||||||
builder.row(InlineKeyboardButton(text="👤 Мои лоты", callback_data="ads_my_items"))
|
builder.row(InlineKeyboardButton(text="👤 Мои лоты", callback_data="ads_my_items"))
|
||||||
|
builder.row(InlineKeyboardButton(text="🏠 В главное меню", callback_data="main_menu"))
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
def get_categories_kb():
|
def get_categories_kb():
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
cats = [
|
cats = [
|
||||||
("Продам", "sell"), ("Куплю", "buy"),
|
("Продам 💰", "sell"), ("Куплю 🛒", "buy"),
|
||||||
("Отдам", "give"), ("Услуги", "service")
|
("Отдам 🎁", "give"), ("Услуги 🔧", "service")
|
||||||
]
|
]
|
||||||
for name, code in cats:
|
for name, code in cats:
|
||||||
builder.add(InlineKeyboardButton(text=name, callback_data=f"adcat_{code}"))
|
builder.add(InlineKeyboardButton(text=name, callback_data=f"adcat_{code}"))
|
||||||
builder.adjust(2)
|
builder.adjust(2)
|
||||||
|
builder.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -64,6 +66,19 @@ async def cmd_market(message: Message):
|
||||||
)
|
)
|
||||||
await message.answer(text, reply_markup=get_ads_main_menu())
|
await message.answer(text, reply_markup=get_ads_main_menu())
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "market")
|
||||||
|
@router.callback_query(F.data == "ads_back")
|
||||||
|
async def cb_market(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Главный вход в маркет из инлайн-кнопок"""
|
||||||
|
await state.clear()
|
||||||
|
text = (
|
||||||
|
f"{fmt_msg('МАРКЕТ ЛКМ37', 'system')}\n\n"
|
||||||
|
"Здесь вы можете найти товары от соседей или предложить свои.\n"
|
||||||
|
"<i>Все лоты проходят модерацию.</i>"
|
||||||
|
)
|
||||||
|
from handlers.users import safe_edit_or_reply
|
||||||
|
await safe_edit_or_reply(callback, text, reply_markup=get_ads_main_menu())
|
||||||
|
|
||||||
@router.callback_query(F.data == "ads_create")
|
@router.callback_query(F.data == "ads_create")
|
||||||
async def start_ad_creation(callback: CallbackQuery, state: FSMContext):
|
async def start_ad_creation(callback: CallbackQuery, state: FSMContext):
|
||||||
await callback.message.answer("Выберите категорию для вашего лота:", reply_markup=get_categories_kb())
|
await callback.message.answer("Выберите категорию для вашего лота:", reply_markup=get_categories_kb())
|
||||||
|
|
@ -111,7 +126,7 @@ async def skip_photo(message: Message, state: FSMContext):
|
||||||
await save_ad(message, data, None)
|
await save_ad(message, data, None)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
async def save_ad(message, data, photo_id):
|
async def save_ad(message: Message, data, photo_id):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
new_ad = Ad(
|
new_ad = Ad(
|
||||||
user_id=message.from_user.id,
|
user_id=message.from_user.id,
|
||||||
|
|
@ -120,25 +135,30 @@ async def save_ad(message, data, photo_id):
|
||||||
description=data['description'],
|
description=data['description'],
|
||||||
price=data['price'],
|
price=data['price'],
|
||||||
photo_file_id=photo_id,
|
photo_file_id=photo_id,
|
||||||
expires_at=datetime.utcnow() + timedelta(days=30)
|
expires_at=datetime.utcnow() + timedelta(days=30),
|
||||||
|
is_moderated=True
|
||||||
)
|
)
|
||||||
session.add(new_ad)
|
session.add(new_ad)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"{fmt_msg('ЛОТ СОЗДАН', 'system')}\n\n"
|
f"{fmt_msg('ЛОТ СОЗДАН', 'system')}\n\n"
|
||||||
"Ваше объявление отправлено на модерацию. После одобрения оно появится в общем списке."
|
"Ваше объявление успешно создано и опубликовано в маркете LKM37!"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.callback_query(F.data == "ads_view_all")
|
@router.callback_query(F.data == "ads_view_all")
|
||||||
|
async def view_ads_trigger(callback: CallbackQuery):
|
||||||
|
await view_ads(callback, page=0)
|
||||||
|
|
||||||
async def view_ads(callback: CallbackQuery, page: int = 0):
|
async def view_ads(callback: CallbackQuery, page: int = 0):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).offset(page).limit(1)
|
stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).order_by(Ad.created_at.desc()).offset(page).limit(1)
|
||||||
res = await session.execute(stmt)
|
res = await session.execute(stmt)
|
||||||
ad = res.scalar_one_or_none()
|
ad = res.scalar_one_or_none()
|
||||||
|
|
||||||
if not ad:
|
if not ad:
|
||||||
return await callback.message.answer("К сожалению, пока лотов нет.")
|
await callback.answer("К сожалению, пока лотов нет.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
price_text = f"{ad.price} ₽" if ad.price > 0 else "ДАРOM"
|
price_text = f"{ad.price} ₽" if ad.price > 0 else "ДАРOM"
|
||||||
text = (
|
text = (
|
||||||
|
|
@ -155,11 +175,44 @@ async def view_ads(callback: CallbackQuery, page: int = 0):
|
||||||
else:
|
else:
|
||||||
kb.row(InlineKeyboardButton(text="🤝 ЗАБРОНИРОВАНО", callback_data="ad_reserved_info"))
|
kb.row(InlineKeyboardButton(text="🤝 ЗАБРОНИРОВАНО", callback_data="ad_reserved_info"))
|
||||||
|
|
||||||
kb.row(
|
# Пагинация
|
||||||
InlineKeyboardButton(text="⬅️", callback_data=f"ad_page_{page-1}"),
|
nav_buttons = []
|
||||||
InlineKeyboardButton(text="➡️", callback_data=f"ad_page_{page+1}")
|
if page > 0:
|
||||||
)
|
nav_buttons.append(InlineKeyboardButton(text="⬅️ Предыдущий", callback_data=f"ad_page_{page-1}"))
|
||||||
...
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
next_stmt = select(Ad).where(Ad.is_active == True, Ad.is_moderated == True).offset(page + 1).limit(1)
|
||||||
|
next_res = await session.execute(next_stmt)
|
||||||
|
has_next = next_res.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
if has_next:
|
||||||
|
nav_buttons.append(InlineKeyboardButton(text="Следующий ➡️", callback_data=f"ad_page_{page+1}"))
|
||||||
|
|
||||||
|
if nav_buttons:
|
||||||
|
kb.row(*nav_buttons)
|
||||||
|
|
||||||
|
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||||||
|
kb.row(InlineKeyboardButton(text="🏠 В главное меню", callback_data="main_menu"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if ad.photo_file_id:
|
||||||
|
await callback.message.answer_photo(ad.photo_file_id, caption=text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
else:
|
||||||
|
await callback.message.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("ad_page_"))
|
||||||
|
async def cb_ad_page(callback: CallbackQuery):
|
||||||
|
page = int(callback.data.split("_")[2])
|
||||||
|
if page < 0:
|
||||||
|
await callback.answer("Это первое объявление.")
|
||||||
|
return
|
||||||
|
await view_ads(callback, page=page)
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("ad_reserve_"))
|
@router.callback_query(F.data.startswith("ad_reserve_"))
|
||||||
async def reserve_ad(callback: CallbackQuery):
|
async def reserve_ad(callback: CallbackQuery):
|
||||||
ad_id = int(callback.data.split("_")[2])
|
ad_id = int(callback.data.split("_")[2])
|
||||||
|
|
@ -178,17 +231,63 @@ async def reserve_ad(callback: CallbackQuery):
|
||||||
f"Покупатель: ID{callback.from_user.id} (@{callback.from_user.username or '—'})",
|
f"Покупатель: ID{callback.from_user.id} (@{callback.from_user.username or '—'})",
|
||||||
parse_mode='HTML'
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
except: pass
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
await callback.answer("✅ Товар забронирован. Свяжитесь с продавцом!", show_alert=True)
|
await callback.answer("✅ Товар забронирован. Свяжитесь с продавцом!", show_alert=True)
|
||||||
await callback.message.edit_reply_markup(reply_markup=None) # Обновить бы на "Забронировано"
|
# Обновим клавиатуру
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(InlineKeyboardButton(text="📧 Написать продавцу", url=f"tg://user?id={ad.user_id}"))
|
||||||
|
kb.row(InlineKeyboardButton(text="🤝 ЗАБРОНИРОВАНО", callback_data="ad_reserved_info"))
|
||||||
|
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||||||
|
try:
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=kb.as_markup())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
await callback.answer("❌ Товар уже забронирован или не найден.")
|
await callback.answer("❌ Товар уже забронирован или не найден.", show_alert=True)
|
||||||
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="ads_back"))
|
|
||||||
|
|
||||||
if ad.photo_file_id:
|
@router.callback_query(F.data == "ad_reserved_info")
|
||||||
await callback.message.answer_photo(ad.photo_file_id, caption=text, reply_markup=kb.as_markup())
|
async def cb_reserved_info(callback: CallbackQuery):
|
||||||
else:
|
await callback.answer("Этот товар уже забронирован кем-то из соседей.", show_alert=True)
|
||||||
await callback.message.answer(text, reply_markup=kb.as_markup())
|
|
||||||
|
@router.callback_query(F.data == "ads_my_items")
|
||||||
|
async def view_my_ads(callback: CallbackQuery):
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = select(Ad).where(Ad.user_id == user_id, Ad.is_active == True).order_by(Ad.created_at.desc())
|
||||||
|
res = await session.execute(stmt)
|
||||||
|
ads = res.scalars().all()
|
||||||
|
|
||||||
|
if not ads:
|
||||||
|
await callback.answer("У вас пока нет активных объявлений.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "<b>👤 ВАШИ ОБЪЯВЛЕНИЯ:</b>\n\n"
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
for ad in ads:
|
||||||
|
status = "🟢 Активно" if ad.is_moderated else "⏳ На модерации"
|
||||||
|
price_text = f"{ad.price} ₽" if ad.price > 0 else "Даром"
|
||||||
|
text += f"🔹 <b>{ad.title}</b> ({price_text})\n └ Статус: {status}\n\n"
|
||||||
|
kb.row(
|
||||||
|
InlineKeyboardButton(text=f"🗑️ Удалить '{ad.title[:15]}'", callback_data=f"ad_delete_{ad.ad_id}")
|
||||||
|
)
|
||||||
|
|
||||||
|
kb.row(InlineKeyboardButton(text="🔙 В маркет", callback_data="ads_back"))
|
||||||
|
|
||||||
|
from handlers.users import safe_edit_or_reply
|
||||||
|
await safe_edit_or_reply(callback, text, reply_markup=kb.as_markup())
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("ad_delete_"))
|
||||||
|
async def delete_my_ad(callback: CallbackQuery):
|
||||||
|
ad_id = int(callback.data.split("_")[2])
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
ad = await session.get(Ad, ad_id)
|
||||||
|
if ad and ad.user_id == callback.from_user.id:
|
||||||
|
ad.is_active = False
|
||||||
|
await session.commit()
|
||||||
|
await callback.answer("✅ Объявление удалено.", show_alert=True)
|
||||||
|
else:
|
||||||
|
await callback.answer("❌ Объявление не найдено или вы не автор.", show_alert=True)
|
||||||
|
await view_my_ads(callback)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
from aiogram.filters import Command, CommandStart, StateFilter
|
from aiogram.filters import Command, CommandStart, StateFilter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy import select, update
|
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
|
||||||
|
|
@ -286,10 +287,16 @@ async def process_location(message: Message):
|
||||||
await message.answer("❌ Ошибка формата. Введите два числа через пробел.")
|
await message.answer("❌ Ошибка формата. Введите два числа через пробел.")
|
||||||
|
|
||||||
|
|
||||||
|
# Класс состояний обратной связи
|
||||||
|
class HelpFeedbackState(StatesGroup):
|
||||||
|
waiting_for_message = State()
|
||||||
|
waiting_for_reply = State()
|
||||||
|
|
||||||
@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, state: FSMContext):
|
||||||
text = ("<b>❓ СПРАВКА</b>\n\n"
|
await state.clear()
|
||||||
|
text = ("<b>❓ СПРАВКА И ПОДДЕРЖКА</b>\n\n"
|
||||||
"• Используйте кнопки в главном меню для навигации.\n"
|
"• Используйте кнопки в главном меню для навигации.\n"
|
||||||
"• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
|
"• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
|
||||||
"<b>Список команд:</b>\n"
|
"<b>Список команд:</b>\n"
|
||||||
|
|
@ -297,8 +304,130 @@ async def cb_help(event):
|
||||||
"/phones — Телефоны\n"
|
"/phones — Телефоны\n"
|
||||||
"/about — О доме\n"
|
"/about — О доме\n"
|
||||||
"/profile — Твой профиль\n"
|
"/profile — Твой профиль\n"
|
||||||
"/rules — Правила чата")
|
"/rules — Правила чата\n\n"
|
||||||
|
"<i>Вы можете отправить сообщение Инициативной группе / Администрации дома, нажав кнопку ниже.</i>")
|
||||||
kb = InlineKeyboardBuilder()
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(InlineKeyboardButton(text="✍️ Написать администрации", callback_data="help_feedback"))
|
||||||
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
|
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
|
||||||
|
|
||||||
await safe_edit_or_reply(event, text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
await safe_edit_or_reply(event, text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "help_feedback")
|
||||||
|
async def start_help_feedback(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await callback.answer()
|
||||||
|
await state.set_state(HelpFeedbackState.waiting_for_message)
|
||||||
|
|
||||||
|
text = ("✍️ <b>ОБРАТНАЯ СВЯЗЬ</b>\n\n"
|
||||||
|
"Пожалуйста, введите текст вашего сообщения для администрации дома LKM37.\n"
|
||||||
|
"Вы также можете прикрепить одно фото или документ к вашему сообщению.\n\n"
|
||||||
|
"<i>Для отмены нажмите кнопку ниже или введите /cancel.</i>")
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(InlineKeyboardButton(text="❌ Отмена", callback_data="main_menu"))
|
||||||
|
|
||||||
|
await callback.message.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
|
||||||
|
|
||||||
|
@router.message(HelpFeedbackState.waiting_for_message)
|
||||||
|
async def process_help_feedback(message: Message, state: FSMContext):
|
||||||
|
if message.text and message.text.startswith('/'):
|
||||||
|
if message.text.lower() in ['/cancel', '/start']:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("❌ Отправка сообщения отменена.", reply_markup=get_main_menu())
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
|
||||||
|
sender_name = message.from_user.full_name or f"user{user_id}"
|
||||||
|
username_str = f" (@{message.from_user.username})" if message.from_user.username else ""
|
||||||
|
apt_str = user.apartment or "Не указана" if user else "Не указана"
|
||||||
|
|
||||||
|
admin_text = (f"📬 <b>НОВОЕ ОБРАЩЕНИЕ ОТ ЖИЛЬЦА LKM37!</b>\n\n"
|
||||||
|
f"👤 Отправитель: <b>{sender_name}</b>{username_str}\n"
|
||||||
|
f"🏢 Квартира: <b>{apt_str}</b>\n"
|
||||||
|
f"🆔 ID: <code>{user_id}</code>\n\n"
|
||||||
|
f"📝 Сообщение:\n{message.text or message.caption or '—'}")
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.row(InlineKeyboardButton(text="✍️ Ответить жильцу", callback_data=f"help_reply_{user_id}"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if message.photo:
|
||||||
|
await message.bot.send_photo(
|
||||||
|
chat_id=config.ADMIN_USER_ID,
|
||||||
|
photo=message.photo[-1].file_id,
|
||||||
|
caption=admin_text,
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
elif message.document:
|
||||||
|
await message.bot.send_document(
|
||||||
|
chat_id=config.ADMIN_USER_ID,
|
||||||
|
document=message.document.file_id,
|
||||||
|
caption=admin_text,
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.bot.send_message(
|
||||||
|
chat_id=config.ADMIN_USER_ID,
|
||||||
|
text=admin_text,
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer("✅ <b>Ваше сообщение успешно отправлено!</b>\n\nАдминистрация ответит вам в ближайшее время.", reply_markup=get_main_menu(), parse_mode='HTML')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке фидбека админу: {e}")
|
||||||
|
await message.answer("❌ Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте позже.", reply_markup=get_main_menu())
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("help_reply_"))
|
||||||
|
async def start_help_reply(callback: CallbackQuery, state: FSMContext):
|
||||||
|
if callback.from_user.id != config.ADMIN_USER_ID:
|
||||||
|
await callback.answer("🔒 Доступ только для суперадмина.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_user_id = int(callback.data.split("_")[2])
|
||||||
|
await state.update_data(target_user_id=target_user_id)
|
||||||
|
await state.set_state(HelpFeedbackState.waiting_for_reply)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
await callback.message.answer(f"✍️ Введите ответ для пользователя <code>{target_user_id}</code>:\n\n<i>Для отмены пришлите /cancel.</i>", parse_mode='HTML')
|
||||||
|
|
||||||
|
@router.message(HelpFeedbackState.waiting_for_reply)
|
||||||
|
async def process_help_reply(message: Message, state: FSMContext):
|
||||||
|
if message.from_user.id != config.ADMIN_USER_ID:
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.text and message.text.lower() in ['/cancel', 'отмена']:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("❌ Ответ отменен.", reply_markup=get_main_menu())
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
target_user_id = data.get("target_user_id")
|
||||||
|
|
||||||
|
if not target_user_id:
|
||||||
|
await message.answer("❌ Ошибка: пользователь для ответа не найден в контексте.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
reply_text = (f"📩 <b>ОТВЕТ АДМИНИСТРАЦИИ LKM37:</b>\n\n"
|
||||||
|
f"{message.text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.bot.send_message(
|
||||||
|
chat_id=target_user_id,
|
||||||
|
text=reply_text,
|
||||||
|
parse_mode='HTML'
|
||||||
|
)
|
||||||
|
await message.answer(f"✅ Ответ успешно отправлен пользователю <code>{target_user_id}</code>.", parse_mode='HTML')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке ответа пользователю {target_user_id}: {e}")
|
||||||
|
await message.answer(f"❌ Не удалось отправить сообщение пользователю. Ошибка: {e}")
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool =
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text='📋 Опросы', callback_data='polls'),
|
InlineKeyboardButton(text='📋 Опросы', callback_data='polls'),
|
||||||
InlineKeyboardButton(text=' События', callback_data='events')
|
InlineKeyboardButton(text='🎉 События', callback_data='events')
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text='🏪 Маркет', callback_data='market'),
|
InlineKeyboardButton(text='🏪 Маркет', callback_data='market'),
|
||||||
|
|
|
||||||
0
nohup.out
Normal file
0
nohup.out
Normal file
|
|
@ -14,3 +14,5 @@ python-multipart>=0.0.6
|
||||||
pydub
|
pydub
|
||||||
psycopg2-binary>=2.9.9
|
psycopg2-binary>=2.9.9
|
||||||
asyncpg>=0.29.0
|
asyncpg>=0.29.0
|
||||||
|
google-generativeai
|
||||||
|
|
||||||
|
|
|
||||||
382
scraper_gis/cabinet_links_map.json
Normal file
382
scraper_gis/cabinet_links_map.json
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"text": "Справка по системе",
|
||||||
|
"href": "https://cdn.dom.gosuslugi.ru/webhelp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Регламенты и инструкции",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Все пользователи",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Граждане",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Кредитные организации и банковские агенты",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Органы государственного жилищного надзора",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Органы государственной власти",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Органы местного самоуправления",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Ответственные за реализацию приоритетного проекта \"Формирование комфортной городской среды\"",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Региональный оператор капитального ремонта",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Региональный оператор по обращению с твердыми коммунальными отходами",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Ресурсоснабжающие организации",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "РКЦ, ЕИРЦ и платежные агенты",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=13"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Управляющие организации, ТСЖ, ЖК, ЖСК",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Федеральные органы власти",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Фонд содействия реформированию ЖКХ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?userCtgrCode=11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Часто задаваемые вопросы",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/faq"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Карта сайта",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/site-map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Версия для слабовидящих",
|
||||||
|
"href": "https://dom.gosuslugi.ru/special/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Перейти на новый сайт",
|
||||||
|
"href": "https://portal.dom.gosuslugi.ru/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "ГИС ЖКХ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Обращение в службу поддержки",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/support-cabinet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Зарегистрироваться",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/registration-info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Получатели услуг",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/audience/consumers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Поставщики услуг",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/audience/suppliers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Органы власти",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/audience/authorities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Учебники",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/regulations?rubricNames=%D0%A3%D1%87%D0%B5%D0%B1%D0%BD%D0%B8%D0%BA%D0%B8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Видео ГИС ЖКХ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/videos/search?categories=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр программ в сфере ЖКХ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/programs/list"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр программ капитального ремонта",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/capital-repair/programs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Федеральный проект «Формирование комфортной городской среды»",
|
||||||
|
"href": "https://sreda.dom.gosuslugi.ru/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр объектов жилищного фонда",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/houses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестры поставщиков информации",
|
||||||
|
"href": "https://dom.gosuslugi.ru/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Органы государственной власти и местного самоуправления",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=7&orgType=12&orgType=4&orgType=6&orgType=17&orgType=5&orgType=8&orgType=10&orgType=16&orgType=28&orgType=29&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Региональные операторы капитального ремонта",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=14&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Организации, осуществляющие управление многоквартирными\n домами",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=1&orgType=19&orgType=22&orgType=21&orgType=20&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Ресурсоснабжающие организации",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=2&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Региональные операторы по обращению с твердыми коммунальными\n отходами",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=18&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Платежные агенты",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=24&orgType=25&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Банки, иные кредитные организации или органы, осуществляющие\n открытиеи ведение лицевых счетов в соответствии с\n бюджетным законодательством РФ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=26&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Иные поставщики информации",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/organizations?orgType=23&orgType=3&orgType=15&orgType=11&orgType=13&orgType=30&doSearch=false&restore=false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр контрольных (надзорных) мероприятий",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/rp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр информации о готовности к отопительному сезону (периоду)",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/rpg/readiness-passports-search-public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Информация о местах накопления",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-tko/tko-search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Информация о конкурсах по отбору региональных операторов",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/tkoregop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Сводный федеральный реестр лицензий",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/rls-public/licenses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр дисквалифицированных лиц",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/licenses/person/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр административных правонарушений",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public/rap-rp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Перечень органов власти",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/authorities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр решений об изъятии земельных участков",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/withdrawal/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Перечень населенных пунктов без доступа к сети Интернет",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/places/without/internet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр управляющих организаций и решений об определении управляющих\n организаций",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/tr-mo-pub/registry/organizations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реестр информации о конкурсах по отбору кредитных организаций",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/capital-repair/tenders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Оснащенность индивидуальными приборами учета",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/individual-meters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Оснащенность общедомовыми приборами учета",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/common-meters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Техническое состояние многоквартирных домов",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/houses-condition"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Способы управления многоквартирными домами",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/wdgt-mkd-control-method"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Статистика по управлению МКД временными управляющими организациями",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/transitory-mo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Работа с обращениями граждан",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/pafo/appeals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Проверки, проведенные контролирующими органами",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/examinations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Привлечение ресурсоснабжающих организаций к административной ответственности",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/resource-liability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Привлечение управляющих организаций к административной ответственности",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/managing-liability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Способ формирования фонда капитального ремонта",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/crp-fund-forming"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Реализация краткосрочных планов капитального ремонта",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/crp-plans-execution"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Размещение информации в субъектах РФ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/implementation-map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Тарифы на оплату коммунальных ресурсов",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-tariff/communal-resources/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Размер платы за содержание жилого помещения",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/CAPITAL_REPAIR/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Минимальный размер взноса на капитальный ремонт",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/CAPITAL_REPAIR_MIN_CONTRIBUTION/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Социальная норма потребления электрической энергии\n (мощности)",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/ELECTRICITY_CONSUMPTION_SOCIAL_NORM/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Размер платы за пользование жилым помещением",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/RENT_PAYMENT/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Нормативные правовые акты в сфере регулирования тарифов",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/rates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Льготные тарифы на жилищно-коммунальные услуги",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-tariff/extra/reduced-search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Индексы изменения платы граждан за ЖКУ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-indices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Компенсации расходов отдельным категориям граждан",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/msp/public/categories/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Субсидии на оплату ЖКУ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/msp/public/subsidy-provisions/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Нормативные правовые акты в сфере мер социальной поддержки",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/subsidies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Нормативы потребления коммунальных услуг",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-rate-consumption/list"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Информация о стандартах",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/public-standart"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Мероприятия общественного жилищного контроля",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/ojk/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Информация о плановых перерывах в предоставлении КУ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/planned-outage/list"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Открытые данные",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/open-data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Новости и события",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/news/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Обращаем внимание пользователей ГИС ЖКХ и \"Госуслуги Дом\"!",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=ff437377-2bd9-44c7-831d-53470bf154ff&audience=MAIN_PAGE&fromPlace=main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Уведомление пользователей системы",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=f8cba8bd-2025-4246-b433-fe0b8b38ecd1&audience=MAIN_PAGE&fromPlace=main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Перечень организаций жилищно-коммунального хозяйства, участвующих в эксперименте по взысканию задолженности.",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=e4a3ee99-0e98-4022-9757-41d440b0e1bb&audience=MAIN_PAGE&fromPlace=main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Пояснение к критериям отбора участников в рамках эксперимента по взысканию задолженности",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/newsView?newsId=8c9898f4-fe19-4f13-a139-214e1973a9a7&audience=MAIN_PAGE&fromPlace=main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Все сервисы",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/eServices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "ЖИЛИЩНЫЙ КОДЕКС РФ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/filestore/publicDownloadServlet?context=contentmanagement&uid=c4f8bc28-c4bb-4253-925c-b85389dc22ca"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Федеральный закон от 21.07.2014 № 209-ФЗ \"О государственной информационной системе жилищно-коммунального хозяйства\"",
|
||||||
|
"href": "https://dom.gosuslugi.ru/filestore/publicDownloadServlet?context=contentmanagement&uid=6e33668f-69e1-43c1-972e-b8553c789b4d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Законодательство по ГИС ЖКХ",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/legislation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Законодательство РФ",
|
||||||
|
"href": "http://dom.gosuslugi.ru/#!/legislationRF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Все мероприятия",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/events-calendar/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Обучающие материалы",
|
||||||
|
"href": "https://my.dom.gosuslugi.ru/#!/regulations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Все ссылки",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/useful-links"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Карта внедрения ГИС ЖКХ в субъектах Российской Федерации",
|
||||||
|
"href": "https://dom.gosuslugi.ru/#!/implementation-map"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -16,4 +16,4 @@ services:
|
||||||
- TZ=Europe/Ulyanovsk
|
- TZ=Europe/Ulyanovsk
|
||||||
- TELEGRAM_BOT_TOKEN=8725618164:AAH1tGalq-pw1l0t4P5c0sdLkCVJdh7IE7M
|
- TELEGRAM_BOT_TOKEN=8725618164:AAH1tGalq-pw1l0t4P5c0sdLkCVJdh7IE7M
|
||||||
- TELEGRAM_CHAT_ID=197957361
|
- TELEGRAM_CHAT_ID=197957361
|
||||||
command: ["./start.sh", "ultimate"]
|
command: ["./start.sh", "interactive"]
|
||||||
|
|
|
||||||
BIN
scraper_gis/exports/01_Main_Dashboard.png
Normal file
BIN
scraper_gis/exports/01_Main_Dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 375 KiB |
BIN
scraper_gis/exports/02_Appeals_view.png
Normal file
BIN
scraper_gis/exports/02_Appeals_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
BIN
scraper_gis/exports/02_Bills_view.png
Normal file
BIN
scraper_gis/exports/02_Bills_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
BIN
scraper_gis/exports/02_Main_view.png
Normal file
BIN
scraper_gis/exports/02_Main_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 372 KiB |
BIN
scraper_gis/exports/02_Voting_view.png
Normal file
BIN
scraper_gis/exports/02_Voting_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
87
scraper_gis/mcp_full_parser.py
Normal file
87
scraper_gis/mcp_full_parser.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
EXPORT_DIR = "/app/exports"
|
||||||
|
if not os.path.exists(EXPORT_DIR):
|
||||||
|
os.makedirs(EXPORT_DIR)
|
||||||
|
|
||||||
|
print("🚀 Запускаем глубокий скрейпинг Личного кабинета ГИС ЖКХ...")
|
||||||
|
|
||||||
|
def handle_download(download):
|
||||||
|
print(f"📥 Обнаружено скачивание файла: {download.suggested_filename}")
|
||||||
|
filepath = os.path.join(EXPORT_DIR, download.suggested_filename)
|
||||||
|
download.save_as(filepath)
|
||||||
|
print(f"✅ Файл успешно сохранен: {filepath}")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir="/app/browser_context",
|
||||||
|
headless=True,
|
||||||
|
accept_downloads=True,
|
||||||
|
args=[
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--window-size=1920,1080'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
page = browser.pages[0]
|
||||||
|
# Привязываем обработчик скачиваний
|
||||||
|
page.on("download", handle_download)
|
||||||
|
|
||||||
|
# 1. Переход на главную
|
||||||
|
print("🌐 Переходим на главную страницу ЛК (dom.gosuslugi.ru)...")
|
||||||
|
page.goto("https://dom.gosuslugi.ru/#!/main")
|
||||||
|
page.wait_for_timeout(7000) # Ждем прогрузки дашборда
|
||||||
|
|
||||||
|
# Скриншот главной
|
||||||
|
page.screenshot(path=os.path.join(EXPORT_DIR, "01_Main_Dashboard.png"), full_page=True)
|
||||||
|
print("📸 Скриншот: Главный дашборд сохранен.")
|
||||||
|
|
||||||
|
# 2. Ищем и кликаем по основным разделам
|
||||||
|
sections = [
|
||||||
|
{"name": "Обращения", "text": "Обращения"},
|
||||||
|
{"name": "Оплата_ЖКУ", "text": "Оплата"},
|
||||||
|
{"name": "Договоры", "text": "Договор"},
|
||||||
|
{"name": "Голосования", "text": "Голосования"}
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, sec in enumerate(sections, start=2):
|
||||||
|
print(f"🔍 Ищем раздел: {sec['name']}...")
|
||||||
|
try:
|
||||||
|
# Ищем ссылку или кнопку, содержащую нужный текст (игнорируем регистр)
|
||||||
|
# В SPA-приложениях вроде ГИС ЖКХ элементы часто подгружаются динамически
|
||||||
|
element = page.locator(f"text=/{sec['text']}/i").first
|
||||||
|
|
||||||
|
if element.is_visible():
|
||||||
|
print(f"✅ Найден раздел {sec['name']}, кликаем...")
|
||||||
|
element.click()
|
||||||
|
page.wait_for_timeout(5000) # Ждем прогрузки страницы
|
||||||
|
|
||||||
|
# Скриншот раздела
|
||||||
|
scr_name = f"{i:02d}_{sec['name']}.png"
|
||||||
|
page.screenshot(path=os.path.join(EXPORT_DIR, scr_name), full_page=True)
|
||||||
|
print(f"📸 Скриншот раздела сохранен: {scr_name}")
|
||||||
|
|
||||||
|
# Поиск кнопок "Скачать" или "Экспорт"
|
||||||
|
print("⏬ Проверяем наличие вложений для скачивания...")
|
||||||
|
download_btns = page.locator("text=/скачать|экспорт|download|pdf|docx|zip/i").all()
|
||||||
|
for btn in download_btns:
|
||||||
|
if btn.is_visible():
|
||||||
|
print("Кликаем по кнопке скачивания...")
|
||||||
|
btn.click()
|
||||||
|
page.wait_for_timeout(3000) # Даем время на инициализацию загрузки
|
||||||
|
|
||||||
|
# Возврат на главную для следующего цикла
|
||||||
|
page.goto("https://dom.gosuslugi.ru/#!/main")
|
||||||
|
page.wait_for_timeout(4000)
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Раздел {sec['name']} не найден или скрыт.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка при переходе в {sec['name']}: {e}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
print("🏁 Скрейпинг завершен! Все файлы в папке /app/exports")
|
||||||
88
scraper_gis/mcp_smart_parser.py
Normal file
88
scraper_gis/mcp_smart_parser.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
EXPORT_DIR = "/app/exports"
|
||||||
|
API_LOG_FILE = os.path.join(EXPORT_DIR, "api_responses.log")
|
||||||
|
|
||||||
|
if not os.path.exists(EXPORT_DIR):
|
||||||
|
os.makedirs(EXPORT_DIR)
|
||||||
|
|
||||||
|
# Очищаем лог перед новым запуском
|
||||||
|
with open(API_LOG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
f.write("")
|
||||||
|
|
||||||
|
print("🚀 Запускаем Умного Робота-Перехватчика ГИС ЖКХ...")
|
||||||
|
|
||||||
|
def handle_response(response):
|
||||||
|
# Ловим только JSON ответы, чтобы найти внутренние API Госуслуг
|
||||||
|
if "application/json" in response.headers.get("content-type", ""):
|
||||||
|
try:
|
||||||
|
url = response.url
|
||||||
|
# Игнорируем метрики и мусорные запросы
|
||||||
|
if "metric" in url or "log" in url or "stat" in url:
|
||||||
|
return
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
# Сохраняем интересные ответы в лог для анализа
|
||||||
|
# Нас интересуют ответы, где есть списки элементов (массивы) или файлы
|
||||||
|
if isinstance(body, dict) or isinstance(body, list):
|
||||||
|
# Просто чтобы не писать гигантские дампы, пишем URL и начало ответа
|
||||||
|
with open(API_LOG_FILE, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"\\n--- URL: {url} ---\\n")
|
||||||
|
json.dump(body, f, ensure_ascii=False, indent=2)
|
||||||
|
f.write("\\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
pass # Игнорируем ошибки парсинга (иногда JSON бывает битым или пустым)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir="/app/browser_context",
|
||||||
|
headless=True,
|
||||||
|
args=[
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--window-size=1920,1080'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
page = browser.pages[0]
|
||||||
|
|
||||||
|
# Включаем прослушку всех ответов от сервера!
|
||||||
|
page.on("response", handle_response)
|
||||||
|
|
||||||
|
print("🌐 Подключаемся к Личному кабинету...")
|
||||||
|
|
||||||
|
# Задаем список прямых ссылок (роутов) для обхода
|
||||||
|
routes = [
|
||||||
|
{"name": "Main", "url": "https://dom.gosuslugi.ru/#!/main"},
|
||||||
|
{"name": "Appeals", "url": "https://dom.gosuslugi.ru/#!/appeals"}, # Обращения
|
||||||
|
{"name": "Bills", "url": "https://dom.gosuslugi.ru/#!/bills"}, # Счета
|
||||||
|
{"name": "Voting", "url": "https://dom.gosuslugi.ru/#!/voting"} # Голосования
|
||||||
|
]
|
||||||
|
|
||||||
|
for route in routes:
|
||||||
|
print(f"\\n➡️ Переход в раздел: {route['name']}")
|
||||||
|
try:
|
||||||
|
page.goto(route['url'])
|
||||||
|
# Ждем 10 секунд, чтобы API успело отдать все JSON с документами
|
||||||
|
print("⏳ Ожидание загрузки данных через API (10 сек)...")
|
||||||
|
page.wait_for_timeout(10000)
|
||||||
|
|
||||||
|
scr_name = f"02_{route['name']}_view.png"
|
||||||
|
page.screenshot(path=os.path.join(EXPORT_DIR, scr_name), full_page=True)
|
||||||
|
print(f"📸 Скриншот раздела сохранен: {scr_name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка в разделе {route['name']}: {e}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print("\\n🏁 Сбор данных завершен!")
|
||||||
|
print(f"📂 Лог API запросов сохранен в: {API_LOG_FILE}")
|
||||||
|
print("🕵️♂️ Теперь мы можем проанализировать этот лог и вытащить оттуда прямые ссылки на скачивание PDF-файлов!")
|
||||||
28
scraper_gis/mcp_test_auth.py
Normal file
28
scraper_gis/mcp_test_auth.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
print("🚀 Запускаем тестовый браузер для авторизации...")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir="/app/browser_context",
|
||||||
|
headless=False,
|
||||||
|
args=[
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--window-size=1280,1024'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
page = browser.pages[0]
|
||||||
|
|
||||||
|
print("🌐 Переходим на dom.gosuslugi.ru...")
|
||||||
|
page.goto("https://dom.gosuslugi.ru/")
|
||||||
|
|
||||||
|
print("⏳ Окно открыто! У тебя есть 5 минут, чтобы авторизоваться через noVNC.")
|
||||||
|
time.sleep(300)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
print("✅ Сессия сохранена, браузер закрыт.")
|
||||||
54
scraper_gis/mcp_test_cabinet.py
Normal file
54
scraper_gis/mcp_test_cabinet.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
print("🚀 Запускаем Headless-агента для создания карты личного кабинета...")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# Запускаем уже в фоновом (headless) режиме, так как куки сохранены!
|
||||||
|
browser = p.chromium.launch_persistent_context(
|
||||||
|
user_data_dir="/app/browser_context",
|
||||||
|
headless=True,
|
||||||
|
args=[
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--window-size=1280,1024'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
page = browser.pages[0]
|
||||||
|
|
||||||
|
print("🌐 Переходим на главную страницу ЛК (dom.gosuslugi.ru)...")
|
||||||
|
page.goto("https://dom.gosuslugi.ru/#!/main")
|
||||||
|
|
||||||
|
# Ждем 5 секунд, чтобы все скрипты и данные кабинета прогрузились
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
print("📸 Делаем полноразмерный скриншот кабинета...")
|
||||||
|
page.screenshot(path="/app/cabinet_home_map.png", full_page=True)
|
||||||
|
|
||||||
|
print("🗺️ Составляем карту ссылок (парсим меню)...")
|
||||||
|
# Выполняем JS прямо на странице, чтобы вытащить структуру
|
||||||
|
links = page.evaluate('''() => {
|
||||||
|
let menu_links = [];
|
||||||
|
// Собираем все осмысленные ссылки
|
||||||
|
document.querySelectorAll('a').forEach(a => {
|
||||||
|
let text = a.innerText.trim();
|
||||||
|
if (a.href && text.length > 0 && a.href.includes('dom.gosuslugi.ru')) {
|
||||||
|
menu_links.push({text: text, href: a.href});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return menu_links;
|
||||||
|
}''')
|
||||||
|
|
||||||
|
# Сохраняем карту ссылок в JSON
|
||||||
|
with open("/app/cabinet_links_map.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(links, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"✅ Готово! Найдено уникальных ссылок: {len(links)}")
|
||||||
|
print("💾 Скриншот: cabinet_home_map.png")
|
||||||
|
print("💾 Карта: cabinet_links_map.json")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
87
web/app.py
87
web/app.py
|
|
@ -41,7 +41,7 @@ from database.models import (
|
||||||
User, Message as DBMessage, Poll, Ad, PaymentReminder, Schedule,
|
User, Message as DBMessage, Poll, Ad, PaymentReminder, Schedule,
|
||||||
VerificationRequest, Service, Announcement, Event, ScheduledPost,
|
VerificationRequest, Service, Announcement, Event, ScheduledPost,
|
||||||
Broadcast, BroadcastRead, Digest, InitiativeGroup, EmailAudit, EmailExportLog,
|
Broadcast, BroadcastRead, Digest, InitiativeGroup, EmailAudit, EmailExportLog,
|
||||||
ProfileHistory, Achievement, Thank, SpyLog, Artifact
|
ProfileHistory, Achievement, Thank, SpyLog, Artifact, Speedtest
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) =====
|
# ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) =====
|
||||||
|
|
@ -165,13 +165,14 @@ async def dashboard(request: Request, username: str = Depends(get_current_admin)
|
||||||
upcoming_events = (await session.execute(select(func.count(Event.id)).where(Event.is_active == True))).scalar()
|
upcoming_events = (await session.execute(select(func.count(Event.id)).where(Event.is_active == True))).scalar()
|
||||||
pending_count = (await session.execute(select(func.count(VerificationRequest.id)).where(VerificationRequest.status == 'pending'))).scalar()
|
pending_count = (await session.execute(select(func.count(VerificationRequest.id)).where(VerificationRequest.status == 'pending'))).scalar()
|
||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
return templates.TemplateResponse(request=request, name="dashboard.html", context={
|
||||||
"request": request, "username": username,
|
"username": username,
|
||||||
"stats": {
|
"users_count": users_count,
|
||||||
"users": users_count, "verified": verified_count,
|
"verified_count": verified_count,
|
||||||
"messages": messages_count, "ads": active_ads,
|
"messages_count": messages_count,
|
||||||
"polls": active_polls, "events": upcoming_events
|
"active_ads": active_ads,
|
||||||
},
|
"active_polls": active_polls,
|
||||||
|
"upcoming_events": upcoming_events,
|
||||||
"pending_count": pending_count
|
"pending_count": pending_count
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -208,8 +209,8 @@ async def users_page(request: Request, search: str = "", filter: str = "", usern
|
||||||
total_count = (await session.execute(select(func.count(User.user_id)))).scalar()
|
total_count = (await session.execute(select(func.count(User.user_id)))).scalar()
|
||||||
verified_count = (await session.execute(select(func.count(User.user_id)).where(User.verified == True))).scalar()
|
verified_count = (await session.execute(select(func.count(User.user_id)).where(User.verified == True))).scalar()
|
||||||
|
|
||||||
return templates.TemplateResponse("users.html", {
|
return templates.TemplateResponse(request=request, name="users.html", context={
|
||||||
"request": request, "username": username, "users": users, "search": search, "current_filter": filter,
|
"username": username, "users": users, "search": search, "current_filter": filter,
|
||||||
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "ig": len(ig_user_ids)},
|
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "ig": len(ig_user_ids)},
|
||||||
"ig_user_ids": ig_user_ids
|
"ig_user_ids": ig_user_ids
|
||||||
})
|
})
|
||||||
|
|
@ -238,7 +239,7 @@ async def verification_page(request: Request, username: str = Depends(get_curren
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
pending = (await session.execute(select(VerificationRequest).where(VerificationRequest.status == 'pending').order_by(VerificationRequest.created_at.desc()))).scalars().all()
|
pending = (await session.execute(select(VerificationRequest).where(VerificationRequest.status == 'pending').order_by(VerificationRequest.created_at.desc()))).scalars().all()
|
||||||
unverified = (await session.execute(select(User).where(User.verified == False).order_by(User.join_date.desc()).limit(50))).scalars().all()
|
unverified = (await session.execute(select(User).where(User.verified == False).order_by(User.join_date.desc()).limit(50))).scalars().all()
|
||||||
return templates.TemplateResponse("verification.html", {"request": request, "username": username, "pending_requests": pending, "unverified_users": unverified})
|
return templates.TemplateResponse(request=request, name="verification.html", context={"username": username, "pending_requests": pending, "unverified_users": unverified})
|
||||||
|
|
||||||
@app.post("/api/verification/{req_id}/approve")
|
@app.post("/api/verification/{req_id}/approve")
|
||||||
async def api_approve_verification(req_id: int, username: str = Depends(get_current_admin)):
|
async def api_approve_verification(req_id: int, username: str = Depends(get_current_admin)):
|
||||||
|
|
@ -342,31 +343,31 @@ async def api_toggle_ai(user_id: int, username: str = Depends(get_current_admin)
|
||||||
async def phones_page(request: Request, username: str = Depends(get_current_admin)):
|
async def phones_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
services = (await session.execute(select(Service).order_by(Service.category, Service.name))).scalars().all()
|
services = (await session.execute(select(Service).order_by(Service.category, Service.name))).scalars().all()
|
||||||
return templates.TemplateResponse("phones.html", {"request": request, "username": username, "services": services})
|
return templates.TemplateResponse(request=request, name="phones.html", context={"username": username, "services": services})
|
||||||
|
|
||||||
@app.get("/ads", response_class=HTMLResponse)
|
@app.get("/ads", response_class=HTMLResponse)
|
||||||
async def ads_page(request: Request, username: str = Depends(get_current_admin)):
|
async def ads_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
ads = (await session.execute(select(Ad).order_by(Ad.created_at.desc()).limit(100))).scalars().all()
|
ads = (await session.execute(select(Ad).order_by(Ad.created_at.desc()).limit(100))).scalars().all()
|
||||||
return templates.TemplateResponse("ads.html", {"request": request, "username": username, "ads": ads})
|
return templates.TemplateResponse(request=request, name="ads.html", context={"username": username, "ads": ads})
|
||||||
|
|
||||||
@app.get("/polls", response_class=HTMLResponse)
|
@app.get("/polls", response_class=HTMLResponse)
|
||||||
async def polls_page(request: Request, username: str = Depends(get_current_admin)):
|
async def polls_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
polls = (await session.execute(select(Poll).order_by(Poll.created_at.desc()).limit(50))).scalars().all()
|
polls = (await session.execute(select(Poll).order_by(Poll.created_at.desc()).limit(50))).scalars().all()
|
||||||
return templates.TemplateResponse("polls.html", {"request": request, "username": username, "polls": polls})
|
return templates.TemplateResponse(request=request, name="polls.html", context={"username": username, "polls": polls})
|
||||||
|
|
||||||
@app.get("/events", response_class=HTMLResponse)
|
@app.get("/events", response_class=HTMLResponse)
|
||||||
async def events_page(request: Request, username: str = Depends(get_current_admin)):
|
async def events_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
events = (await session.execute(select(Event).order_by(Event.event_date.desc()).limit(50))).scalars().all()
|
events = (await session.execute(select(Event).order_by(Event.event_date.desc()).limit(50))).scalars().all()
|
||||||
return templates.TemplateResponse("events.html", {"request": request, "username": username, "events": events})
|
return templates.TemplateResponse(request=request, name="events.html", context={"username": username, "events": events})
|
||||||
|
|
||||||
@app.get("/schedules", response_class=HTMLResponse)
|
@app.get("/schedules", response_class=HTMLResponse)
|
||||||
async def schedules_page(request: Request, username: str = Depends(get_current_admin)):
|
async def schedules_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
schedules = (await session.execute(select(Schedule).order_by(Schedule.start_time.desc()))).scalars().all()
|
schedules = (await session.execute(select(Schedule).order_by(Schedule.start_time.desc()))).scalars().all()
|
||||||
return templates.TemplateResponse("schedules.html", {"request": request, "username": username, "schedules": schedules})
|
return templates.TemplateResponse(request=request, name="schedules.html", context={"username": username, "schedules": schedules})
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# РАССЫЛКИ (SMART BROADCAST)
|
# РАССЫЛКИ (SMART BROADCAST)
|
||||||
|
|
@ -390,7 +391,7 @@ async def broadcast_page(request: Request, username: str = Depends(get_current_a
|
||||||
'broadcast_type': b.broadcast_type or 'regular',
|
'broadcast_type': b.broadcast_type or 'regular',
|
||||||
'is_reminder_sent': b.is_reminder_sent
|
'is_reminder_sent': b.is_reminder_sent
|
||||||
})
|
})
|
||||||
return templates.TemplateResponse("broadcast.html", {"request": request, "username": username, "broadcasts": broadcasts_data, "artifacts": artifacts})
|
return templates.TemplateResponse(request=request, name="broadcast.html", context={"username": username, "broadcasts": broadcasts_data, "artifacts": artifacts})
|
||||||
|
|
||||||
@app.post("/api/broadcast/create")
|
@app.post("/api/broadcast/create")
|
||||||
async def api_create_broadcast(
|
async def api_create_broadcast(
|
||||||
|
|
@ -495,7 +496,7 @@ async def api_broadcast_non_readers(broadcast_id: int, username: str = Depends(g
|
||||||
async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)):
|
async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
artifacts = (await session.execute(select(Artifact).order_by(Artifact.filename))).scalars().all()
|
artifacts = (await session.execute(select(Artifact).order_by(Artifact.filename))).scalars().all()
|
||||||
return templates.TemplateResponse("scheduled_posts.html", {"request": request, "username": username, "artifacts": artifacts})
|
return templates.TemplateResponse(request=request, name="scheduled_posts.html", context={"username": username, "artifacts": artifacts})
|
||||||
|
|
||||||
@app.get("/api/scheduled_posts/list")
|
@app.get("/api/scheduled_posts/list")
|
||||||
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
||||||
|
|
@ -565,7 +566,7 @@ async def digests_page(request: Request, username: str = Depends(get_current_adm
|
||||||
pending = (await session.execute(stmt_pending)).scalars().all()
|
pending = (await session.execute(stmt_pending)).scalars().all()
|
||||||
stmt_archive = select(Digest).where(Digest.status.in_(['approved', 'sent', 'rejected'])).order_by(Digest.created_at.desc()).limit(20)
|
stmt_archive = select(Digest).where(Digest.status.in_(['approved', 'sent', 'rejected'])).order_by(Digest.created_at.desc()).limit(20)
|
||||||
archive = (await session.execute(stmt_archive)).scalars().all()
|
archive = (await session.execute(stmt_archive)).scalars().all()
|
||||||
return templates.TemplateResponse("digests.html", {"request": request, "username": username, "pending_digests": pending, "archive_digests": archive})
|
return templates.TemplateResponse(request=request, name="digests.html", context={"username": username, "pending_digests": pending, "archive_digests": archive})
|
||||||
|
|
||||||
@app.post("/api/digest/generate")
|
@app.post("/api/digest/generate")
|
||||||
async def api_generate_digest(username: str = Depends(get_current_admin)):
|
async def api_generate_digest(username: str = Depends(get_current_admin)):
|
||||||
|
|
@ -589,7 +590,7 @@ async def api_generate_digest(username: str = Depends(get_current_admin)):
|
||||||
|
|
||||||
@app.get("/files", response_class=HTMLResponse)
|
@app.get("/files", response_class=HTMLResponse)
|
||||||
async def files_page(request: Request, username: str = Depends(get_current_admin)):
|
async def files_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
return templates.TemplateResponse("files.html", {"request": request, "username": username})
|
return templates.TemplateResponse(request=request, name="files.html", context={"username": username})
|
||||||
|
|
||||||
@app.get("/api/files/list")
|
@app.get("/api/files/list")
|
||||||
async def api_list_files(username: str = Depends(get_current_admin)):
|
async def api_list_files(username: str = Depends(get_current_admin)):
|
||||||
|
|
@ -637,7 +638,7 @@ async def export_page(request: Request, username: str = Depends(get_current_admi
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
exporter = ChatExporter(session)
|
exporter = ChatExporter(session)
|
||||||
stats = await exporter.get_stats()
|
stats = await exporter.get_stats()
|
||||||
return templates.TemplateResponse("export.html", {"request": request, "username": username, "export_stats": stats, "fmt_local": fmt_local})
|
return templates.TemplateResponse(request=request, name="export.html", context={"username": username, "export_stats": stats, "fmt_local": fmt_local})
|
||||||
|
|
||||||
@app.get("/api/export/json")
|
@app.get("/api/export/json")
|
||||||
async def api_export_json(username: str = Depends(get_current_admin)):
|
async def api_export_json(username: str = Depends(get_current_admin)):
|
||||||
|
|
@ -690,8 +691,8 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
|
||||||
except:
|
except:
|
||||||
last_sync_time = datetime.fromtimestamp(sync_time_file.stat().st_mtime, tz=timezone.utc)
|
last_sync_time = datetime.fromtimestamp(sync_time_file.stat().st_mtime, tz=timezone.utc)
|
||||||
|
|
||||||
return templates.TemplateResponse("emails.html", {
|
return templates.TemplateResponse(request=request, name="emails.html", context={
|
||||||
"request": request, "username": username, "audit_data": audit_data,
|
"username": username, "audit_data": audit_data,
|
||||||
"last_log": last_log, "latest_emails": latest_emails,
|
"last_log": last_log, "latest_emails": latest_emails,
|
||||||
"has_master_pdf": master_pdf_path.exists(), "last_update": last_update,
|
"has_master_pdf": master_pdf_path.exists(), "last_update": last_update,
|
||||||
"last_sync_time": last_sync_time, "fmt_local": fmt_local
|
"last_sync_time": last_sync_time, "fmt_local": fmt_local
|
||||||
|
|
@ -728,26 +729,40 @@ async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse(status_code=500, content={"message": str(e)})
|
return JSONResponse(status_code=500, content={"message": str(e)})
|
||||||
|
|
||||||
|
@app.get("/api/emails/pipeline_status")
|
||||||
|
async def api_emails_pipeline_status(username: str = Depends(get_current_admin)):
|
||||||
|
import time
|
||||||
|
status_file = Path("/home/matrixhasyou/mutt/pipeline_status.json")
|
||||||
|
if not status_file.exists():
|
||||||
|
return JSONResponse(content={"status": "idle", "step": "Неактивен", "updated_at": "-", "new_emails": 0, "total_emails": 0, "pdf_size_mb": 0.0})
|
||||||
|
try:
|
||||||
|
with open(status_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
mtime = status_file.stat().st_mtime
|
||||||
|
if data.get("status") == "running" and (time.time() - mtime > 600):
|
||||||
|
data["status"] = "stuck"
|
||||||
|
data["step"] = "Процесс завис или был остановлен"
|
||||||
|
return JSONResponse(content=data)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
@app.get("/infra", response_class=HTMLResponse)
|
@app.get("/infra", response_class=HTMLResponse)
|
||||||
async def get_infra_page(request: Request, username: str = Depends(get_current_admin)):
|
async def get_infra_page(request: Request, username: str = Depends(get_current_admin)):
|
||||||
import sqlite3
|
async with AsyncSessionLocal() as session:
|
||||||
conn = sqlite3.connect("/home/matrixhasyou/domovoy_bot/database/domovoy.db")
|
stmt = select(Speedtest).order_by(Speedtest.timestamp.desc()).limit(10)
|
||||||
cursor = conn.cursor()
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
cursor.execute("SELECT timestamp, download, upload, ping, server_name FROM speedtests ORDER BY timestamp DESC LIMIT 10")
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
speedtests = []
|
speedtests = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
speedtests.append({
|
speedtests.append({
|
||||||
"timestamp": r[0],
|
"timestamp": fmt_local(r.timestamp),
|
||||||
"download": r[1],
|
"download": r.download,
|
||||||
"upload": r[2],
|
"upload": r.upload,
|
||||||
"ping": r[3],
|
"ping": r.ping,
|
||||||
"server_name": r[4]
|
"server_name": r.server_name
|
||||||
})
|
})
|
||||||
|
|
||||||
return templates.TemplateResponse("infra.html", {"request": request, "username": username, "speedtests": speedtests})
|
return templates.TemplateResponse(request=request, name="infra.html", context={"username": username, "speedtests": speedtests})
|
||||||
|
|
||||||
@app.post("/api/infra/run_speedtest")
|
@app.post("/api/infra/run_speedtest")
|
||||||
async def api_run_speedtest(username: str = Depends(get_current_admin)):
|
async def api_run_speedtest(username: str = Depends(get_current_admin)):
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
<div class="card-header">СИСТЕМНЫЙ_СТАТУС</div>
|
<div class="card-header">СИСТЕМНЫЙ_СТАТУС</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="p-3 bg-black border border-success font-monospace small">
|
<div class="p-3 bg-black border border-success font-monospace small">
|
||||||
[OK] Database: sqlite3/domovoy.db<br>
|
[OK] Database: PostgreSQL (Synology NAS)<br>
|
||||||
[OK] Telegram: @lkmthreeseven_bot<br>
|
[OK] Telegram: @lkmthreeseven_bot<br>
|
||||||
[OK] Proxy: Active (127.0.0.1:10808)<br>
|
[OK] Proxy: Active (127.0.0.1:10808)<br>
|
||||||
[OK] Node: {{ username }}@server<br>
|
[OK] Node: {{ username }}@server<br>
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,21 @@
|
||||||
{% block extra_style %}
|
{% block extra_style %}
|
||||||
<style>
|
<style>
|
||||||
.digest-settings-container { max-width: 900px; }
|
.digest-settings-container { max-width: 900px; }
|
||||||
.digest-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
|
.digest-header { background: linear-gradient(135deg, var(--color-primary) 0%, rgba(10, 10, 15, 0.7) 100%); border: 1px solid var(--panel-border); color: white; padding: 30px; border-radius: 16px; margin-bottom: 30px; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
|
||||||
.digest-header h1 { font-size: 2em; margin-bottom: 10px; }
|
.digest-header h1 { font-size: 2em; margin-bottom: 10px; text-shadow: 0 0 10px var(--color-primary-glow); }
|
||||||
.digest-back-link { color: white; text-decoration: none; opacity: 0.9; }
|
.digest-back-link { color: var(--color-success); text-decoration: none; opacity: 0.9; transition: var(--transition-smooth); }
|
||||||
.digest-back-link:hover { opacity: 1; }
|
.digest-back-link:hover { opacity: 1; text-shadow: 0 0 8px var(--color-success-glow); }
|
||||||
.digest-card { background: white; padding: 25px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
.digest-card { background: var(--panel-bg); border: 1px solid var(--panel-border); padding: 25px; border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); transition: var(--transition-smooth); }
|
||||||
.digest-card h3 { margin-bottom: 15px; color: #667eea; }
|
.digest-card:hover { border-color: var(--panel-border-hover); background: var(--panel-bg-hover); }
|
||||||
|
.digest-card h3 { margin-bottom: 15px; color: var(--color-success); text-shadow: 0 0 5px var(--color-success-glow); }
|
||||||
.digest-form-group { margin-bottom: 20px; }
|
.digest-form-group { margin-bottom: 20px; }
|
||||||
.digest-form-group label { display: block; margin-bottom: 5px; font-weight: 500; }
|
.digest-form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: var(--color-text-main); }
|
||||||
.digest-form-group input[type="time"], .digest-form-group select { width: 100%; padding: 10px; border: 2px solid #e2e8f0; border-radius: 5px; font-size: 1em; }
|
.digest-form-group input[type="time"], .digest-form-group select { width: 100%; padding: 10px; border: 1px solid var(--panel-border); border-radius: 8px; background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); font-size: 1em; transition: var(--transition-smooth); }
|
||||||
.digest-checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
.digest-form-group input[type="time"]:focus, .digest-form-group select:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 8px var(--color-primary-glow); }
|
||||||
.digest-checkbox-group input[type="checkbox"] { width: 20px; height: 20px; }
|
.digest-checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; color: var(--color-text-main); }
|
||||||
.digest-btn { padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; background: #667eea; color: white; }
|
.digest-checkbox-group input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--color-success); cursor: pointer; }
|
||||||
.digest-btn:hover { background: #5568d3; }
|
.digest-btn { padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-size: 1em; background: var(--color-success); color: #000; font-weight: 600; transition: var(--transition-smooth); }
|
||||||
|
.digest-btn:hover { background: #00cca3; box-shadow: 0 0 10px var(--color-success-glow); }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,44 +11,47 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||||
.header { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
|
.header { background: linear-gradient(135deg, var(--color-primary) 0%, rgba(10, 10, 15, 0.7) 100%); border: 1px solid var(--panel-border); color: white; padding: 30px; border-radius: 16px; margin-bottom: 30px; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
|
||||||
.header h1 { font-size: 2em; margin-bottom: 10px; }
|
.header h1 { font-size: 2em; margin-bottom: 10px; text-shadow: 0 0 10px var(--color-primary-glow); }
|
||||||
.back-link { color: white; text-decoration: none; opacity: 0.9; }
|
.back-link { color: var(--color-success); text-decoration: none; opacity: 0.9; }
|
||||||
.back-link:hover { opacity: 1; }
|
.back-link:hover { opacity: 1; text-shadow: 0 0 8px var(--color-success-glow); }
|
||||||
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
|
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||||
.tab { padding: 12px 24px; background: white; border: none; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 1em; }
|
.tab { padding: 12px 24px; background: rgba(255, 255, 255, 0.05); color: var(--color-text-muted); border: 1px solid var(--panel-border); border-radius: 8px 8px 0 0; cursor: pointer; font-size: 1em; transition: var(--transition-smooth); }
|
||||||
.tab.active { background: #f5576c; color: white; }
|
.tab:hover { color: var(--color-text-main); background: rgba(255, 255, 255, 0.08); }
|
||||||
|
.tab.active { background: var(--color-primary); color: white; border-color: var(--color-primary); box-shadow: 0 0 10px var(--color-primary-glow); }
|
||||||
.tab-content { display: none; }
|
.tab-content { display: none; }
|
||||||
.tab-content.active { display: block; }
|
.tab-content.active { display: block; }
|
||||||
.pending-card { background: white; padding: 25px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-left: 5px solid #f5576c; }
|
.pending-card { background: var(--panel-bg); padding: 25px; border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); border: 1px solid var(--panel-border); border-left: 5px solid var(--color-primary); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); transition: var(--transition-smooth); }
|
||||||
.pending-card h3 { color: #f5576c; margin-bottom: 10px; }
|
.pending-card:hover { border-color: var(--panel-border-hover); background: var(--panel-bg-hover); }
|
||||||
.pending-card .meta { color: #666; font-size: 0.9em; margin-bottom: 15px; }
|
.pending-card h3 { color: var(--color-success); margin-bottom: 10px; text-shadow: 0 0 5px var(--color-success-glow); }
|
||||||
.pending-card .text { background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 15px 0; white-space: pre-wrap; max-height: 300px; overflow-y: auto; }
|
.pending-card .meta { color: var(--color-text-muted); font-size: 0.9em; margin-bottom: 15px; }
|
||||||
.btn { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 0.95em; margin-right: 10px; transition: all 0.2s; }
|
.pending-card .text { background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); border: 1px solid var(--panel-border); padding: 15px; border-radius: 8px; margin: 15px 0; white-space: pre-wrap; max-height: 300px; overflow-y: auto; font-family: var(--font-mono); }
|
||||||
.btn-approve { background: #48bb78; color: white; }
|
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.95em; margin-right: 10px; transition: var(--transition-smooth); }
|
||||||
.btn-approve:hover { background: #38a169; }
|
.btn-approve { background: var(--color-success); color: #000; font-weight: 600; }
|
||||||
.btn-reject { background: #f56565; color: white; }
|
.btn-approve:hover { background: #00cca3; box-shadow: 0 0 10px var(--color-success-glow); }
|
||||||
.btn-reject:hover { background: #e53e3e; }
|
.btn-reject { background: var(--color-danger); color: white; }
|
||||||
.btn-edit { background: #ed8936; color: white; }
|
.btn-reject:hover { background: #e0244a; box-shadow: 0 0 10px var(--color-danger-glow); }
|
||||||
.btn-edit:hover { background: #dd6b20; }
|
.btn-edit { background: var(--color-warning); color: #000; font-weight: 600; }
|
||||||
.btn-send { background: #4299e1; color: white; }
|
.btn-edit:hover { background: #d99800; box-shadow: 0 0 10px var(--color-warning-glow); }
|
||||||
.btn-send:hover { background: #3182ce; }
|
.btn-send { background: var(--color-info); color: #000; font-weight: 600; }
|
||||||
table { width: 100%; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
.btn-send:hover { background: #0099cc; box-shadow: 0 0 10px var(--color-info-glow); }
|
||||||
th { background: #f5576c; color: white; padding: 15px; text-align: left; }
|
table { width: 100%; background: var(--panel-bg); border-radius: 16px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.2); border: 1px solid var(--panel-border); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||||
td { padding: 15px; border-bottom: 1px solid #eee; }
|
th { background: rgba(138, 43, 226, 0.2); color: var(--color-success); padding: 15px; text-align: left; border-bottom: 2px solid var(--panel-border); font-weight: 600; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; }
|
||||||
tr:hover { background: #f8f9fa; }
|
td { padding: 15px; border-bottom: 1px solid var(--panel-border); color: var(--color-text-main); }
|
||||||
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
|
tr:hover { background: rgba(255, 255, 255, 0.02); }
|
||||||
.badge-pending { background: #feebc8; color: #7c2d12; }
|
.badge { display: inline-block; padding: 4px 8px; border-radius: 6px; font-size: 0.85em; font-weight: 500; border: 1px solid transparent; }
|
||||||
.badge-approved { background: #c6f6d5; color: #22543d; }
|
.badge-pending { background: rgba(255, 179, 0, 0.15); color: var(--color-warning); border-color: var(--color-warning); }
|
||||||
.badge-rejected { background: #fed7d7; color: #742a2a; }
|
.badge-approved { background: rgba(0, 255, 204, 0.15); color: var(--color-success); border-color: var(--color-success); }
|
||||||
.badge-sent { background: #bee3f8; color: #2c5282; }
|
.badge-rejected { background: rgba(255, 45, 85, 0.15); color: var(--color-danger); border-color: var(--color-danger); }
|
||||||
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; }
|
.badge-sent { background: rgba(0, 191, 255, 0.15); color: var(--color-info); border-color: var(--color-info); }
|
||||||
|
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; backdrop-filter: blur(8px); }
|
||||||
.modal.active { display: flex; align-items: center; justify-content: center; }
|
.modal.active { display: flex; align-items: center; justify-content: center; }
|
||||||
.modal-content { background: white; padding: 30px; border-radius: 10px; max-width: 900px; width: 90%; max-height: 80vh; overflow-y: auto; }
|
.modal-content { background: var(--bg-main); border: 1px solid var(--panel-border); color: var(--color-text-main); padding: 30px; border-radius: 16px; max-width: 900px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 15px var(--color-primary-glow); }
|
||||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--panel-border); padding-bottom: 15px; }
|
||||||
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; }
|
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--color-text-muted); }
|
||||||
textarea { width: 100%; min-height: 300px; padding: 15px; border: 2px solid #e2e8f0; border-radius: 5px; font-family: inherit; font-size: 0.95em; }
|
.close-btn:hover { color: var(--color-text-main); }
|
||||||
textarea:focus { outline: none; border-color: #f5576c; }
|
textarea { width: 100%; min-height: 300px; padding: 15px; border: 1px solid var(--panel-border); border-radius: 8px; background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); font-family: var(--font-mono); font-size: 0.95em; transition: var(--transition-smooth); }
|
||||||
|
textarea:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 8px var(--color-primary-glow); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -124,9 +124,32 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col">
|
<!-- КАРТОЧКА ДИНАМИЧЕСКОГО СТАТУСА ПАЙПЛАЙНА -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-primary" id="pipelineStatusCard" style="display: none;">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>>_ ТЕКУЩИЙ_СТАТУС_ПАЙПЛАЙНА</span>
|
||||||
|
<span class="spinner-grow spinner-grow-sm text-primary" id="pipelineSpinner" role="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="p-3 bg-black border border-primary font-monospace small">
|
||||||
|
[STEP] Шаг: <span id="pipelineStep" class="text-info">Ожидание</span> <br>
|
||||||
|
[TIME] Обновлено: <span id="pipelineTime">-</span> <br>
|
||||||
|
<div class="progress my-3" style="height: 10px; background-color: #111;">
|
||||||
|
<div id="pipelineProgress" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
[DATA] Новых писем: <span id="pipelineNewEmails" class="text-success">0</span> <br>
|
||||||
|
[DATA] Всего в архиве: <span id="pipelineTotalEmails">0</span> <br>
|
||||||
|
[DATA] Размер архива: <span id="pipelineSize">0.00 MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ЛОГ ПОСЛЕДНЕГО ЗАПУСКА -->
|
||||||
|
<div class="col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА</div>
|
<div class="card-header">ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА (БАЗА ДАННЫХ)</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="p-3 bg-black border border-success font-monospace small">
|
<div class="p-3 bg-black border border-success font-monospace small">
|
||||||
{% if last_log %}
|
{% if last_log %}
|
||||||
|
|
@ -144,6 +167,77 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
async function checkPipelineStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/emails/pipeline_status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const card = document.getElementById('pipelineStatusCard');
|
||||||
|
const step = document.getElementById('pipelineStep');
|
||||||
|
const time = document.getElementById('pipelineTime');
|
||||||
|
const progress = document.getElementById('pipelineProgress');
|
||||||
|
const newEmails = document.getElementById('pipelineNewEmails');
|
||||||
|
const totalEmails = document.getElementById('pipelineTotalEmails');
|
||||||
|
const size = document.getElementById('pipelineSize');
|
||||||
|
const spinner = document.getElementById('pipelineSpinner');
|
||||||
|
|
||||||
|
if (data.status === 'running') {
|
||||||
|
card.style.display = 'block';
|
||||||
|
card.className = "card border-primary h-100";
|
||||||
|
step.className = "text-info";
|
||||||
|
step.textContent = data.step;
|
||||||
|
time.textContent = data.updated_at;
|
||||||
|
spinner.style.display = 'inline-block';
|
||||||
|
|
||||||
|
// Динамический прогресс
|
||||||
|
let width = '10%';
|
||||||
|
if (data.step.includes('Сбор')) width = '35%';
|
||||||
|
else if (data.step.includes('Консолидация')) width = '70%';
|
||||||
|
else if (data.step.includes('Google Drive')) width = '90%';
|
||||||
|
progress.style.width = width;
|
||||||
|
progress.className = "progress-bar progress-bar-striped progress-bar-animated bg-primary";
|
||||||
|
|
||||||
|
newEmails.textContent = data.new_emails;
|
||||||
|
totalEmails.textContent = data.total_emails;
|
||||||
|
size.textContent = data.pdf_size_mb + ' MB';
|
||||||
|
|
||||||
|
// Опрашиваем часто
|
||||||
|
setTimeout(checkPipelineStatus, 2000);
|
||||||
|
} else if (data.status === 'success') {
|
||||||
|
card.style.display = 'block';
|
||||||
|
card.className = "card border-success h-100";
|
||||||
|
step.className = "text-success";
|
||||||
|
step.innerHTML = '✅ Завершен успешно';
|
||||||
|
time.textContent = data.updated_at;
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
progress.style.width = '100%';
|
||||||
|
progress.className = "progress-bar bg-success";
|
||||||
|
|
||||||
|
newEmails.textContent = data.new_emails;
|
||||||
|
totalEmails.textContent = data.total_emails;
|
||||||
|
size.textContent = data.pdf_size_mb + ' MB';
|
||||||
|
|
||||||
|
// Скроем статус через 10 секунд
|
||||||
|
setTimeout(() => { card.style.display = 'none'; }, 10000);
|
||||||
|
} else if (data.status === 'error' || data.status === 'stuck') {
|
||||||
|
card.style.display = 'block';
|
||||||
|
card.className = "card border-danger h-100";
|
||||||
|
step.className = "text-danger";
|
||||||
|
step.innerHTML = '❌ ' + data.step;
|
||||||
|
time.textContent = data.updated_at;
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
progress.style.width = '100%';
|
||||||
|
progress.className = "progress-bar bg-danger";
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', checkPipelineStatus);
|
||||||
|
|
||||||
document.getElementById('fullSyncBtn').addEventListener('click', async function() {
|
document.getElementById('fullSyncBtn').addEventListener('click', async function() {
|
||||||
const btn = this;
|
const btn = this;
|
||||||
if (!confirm('Запустить полный цикл: Сбор почты -> PDF -> G-Drive?')) return;
|
if (!confirm('Запустить полный цикл: Сбор почты -> PDF -> G-Drive?')) return;
|
||||||
|
|
@ -156,6 +250,8 @@ document.getElementById('fullSyncBtn').addEventListener('click', async function(
|
||||||
const response = await fetch('/api/emails/run_full_sync', { method: 'POST' });
|
const response = await fetch('/api/emails/run_full_sync', { method: 'POST' });
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
alert(result.message);
|
alert(result.message);
|
||||||
|
// Запускаем мониторинг статуса
|
||||||
|
setTimeout(checkPipelineStatus, 1000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка: ' + e);
|
alert('Ошибка: ' + e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -182,7 +278,6 @@ document.getElementById('syncAiBtn').addEventListener('click', async function()
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = oldHtml;
|
btn.innerHTML = oldHtml;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,36 +19,42 @@
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Статистика (ПЛОСКИЕ ПЕРЕМЕННЫЕ) -->
|
<!-- Статистика (ПЛОСКИЕ ПЕРЕМЕННЫЕ) -->
|
||||||
|
<style>
|
||||||
|
.filter-card { cursor: pointer; transition: var(--transition-smooth) !important; }
|
||||||
|
.filter-card:hover { transform: translateY(-4px) !important; }
|
||||||
|
.filter-card.active-filter { box-shadow: 0 0 15px currentColor !important; border-width: 2px !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card bg-black border-success text-success">
|
<div class="card bg-black border-success text-success filter-card {% if not current_filter %}active-filter{% endif %}" onclick="location.href='/users'">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3>{{ total_count }}</h3>
|
<h3>{{ total_count }}</h3>
|
||||||
<small>ВСЕГО</small>
|
<small>⚡️ ВСЕГО</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card bg-black border-success text-success">
|
<div class="card bg-black border-success text-success filter-card {% if current_filter == 'verified' %}active-filter{% endif %}" onclick="location.href='/users?filter=verified'">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3>{{ verified_count }}</h3>
|
<h3>{{ verified_count }}</h3>
|
||||||
<small>ВЕРИФИЦИРОВАНЫ</small>
|
<small>✅ ВЕРИФИЦИРОВАНЫ</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card bg-black border-info text-info">
|
<div class="card bg-black border-info text-info filter-card {% if current_filter == 'ig' %}active-filter{% endif %}" onclick="location.href='/users?filter=ig'">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3>{{ ig_count }}</h3>
|
<h3>{{ ig_count }}</h3>
|
||||||
<small>ИНИЦИАТИВНАЯ ГРУППА</small>
|
<small>🔵 ИНИЦИАТИВНАЯ ГРУППА</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card bg-black border-warning text-warning">
|
<div class="card bg-black border-warning text-warning filter-card {% if current_filter == 'active_unverified' %}active-filter{% endif %}" onclick="location.href='/users?filter=active_unverified'">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3>{{ active_unverified }}</h3>
|
<h3>{{ active_unverified }}</h3>
|
||||||
<small>АКТИВНЫЕ ГОСТИ</small>
|
<small>🔥 АКТИВНЫЕ ГОСТИ</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue