🚀 feat: Запуск ЖНЕЦ-УЛЬТИМАТУМ (финальная архитектура)

This commit is contained in:
Admin 2026-05-04 10:47:50 +04:00
parent 49723a85d8
commit 36e3e7d347
12 changed files with 1279 additions and 0 deletions

View file

@ -0,0 +1,60 @@
# 🏛️ Архитектура Жнеца V4: "Магистр" (ТЗ и План Работ)
**Дата:** 02.05.2026
**Автор:** Gemini CLI (на базе глубокого анализа ошибок и логики Аристотеля)
**Цель:** 100% автономный сбор критически важных юридических документов (Информационные сообщения, Обращения, Договоры) с портала ГИС ЖКХ.
## 🔬 Анализ ошибок предыдущих версий
1. **Слепота к фреймам:** ГИС ЖКХ активно использует iframe и подгрузку контента через Angular. Обычные селекторы не срабатывали.
2. **Stale Element Reference:** При возврате из карточки документа назад в список (через кнопку "Назад"), Angular полностью перерисовывает страницу. Сохраненные ранее в памяти скрипта ссылки на документы уничтожались. Скрипт кликал "в пустоту" и падал.
3. **Отсутствие четкой последовательности:** Агент пытался быть "умным" и угадывать состояние, что приводило к хаотичным прыжкам между разделами.
4. **Игнорирование конкретных указаний пользователя:** Неумение распознать кнопку входа (когда куки живы) и неспособность пройти по четко заданным разделам.
## 🏗️ Новая Архитектура: Жесткий Маршрутизатор
Вместо того чтобы "угадывать" состояние страницы, робот будет работать как **Машина Состояний (FSM)** с четким линейным планом.
### Фаза 0: Авторизация и Инициализация
- Открываем `dom.gosuslugi.ru`.
- **Проверка входа:**
- Ищем кнопку "Выйти" или ФИО пользователя. Если есть -> Идем к Фазе 1.
- Ищем кнопку "Личный кабинет" или "Войти". Если есть -> Кликаем, ждем загрузки, проверяем вход.
- Если ничего не понятно -> Пишем в Телеграм: "Нужен логин руками", ждем 60 секунд.
### Фаза 1: Информационные сообщения (Уведомления)
*Юридически значимые оповещения.*
1. Переход в раздел уведомлений.
2. Ожидание полной загрузки таблицы.
3. Скриншот всего экрана (для фиксации дат).
4. Поиск и скачивание всех прикрепленных файлов (документы, PDF, ZIP).
5. Проверка пагинации -> если есть "Следующая", кликаем, скриншотим, скачиваем.
### Фаза 2: Обращения и ответы на них
*Переписка с госорганами и УК.*
1. Переход в раздел обращений.
2. Скриншот списка.
3. Получение количества обращений на текущей странице.
4. Цикл `for i in range(count)`:
- Находим список заново (защита от Stale Element).
- Кликаем на `i`-тое обращение.
- Скриншот карточки обращения.
- Поиск и скачивание всех прикрепленных файлов.
- Возврат назад (клик по "хлебным крошкам" или `page.go_back()`).
- Ожидание прогрузки списка.
5. Обработка пагинации.
### Фаза 3: Информация о договорах по дому
1. Переход в раздел договоров.
2. Нажатие кнопки "Найти" -> ожидание таблицы.
3. Цикл `for i in range(count)` (Аналогично Фазе 2):
- Заново ищем все ссылки "Договор...".
- Кликаем `i`-тую ссылку.
- Скриншот карточки.
- Поиск всех кнопок скачивания во всех фреймах.
- Загрузка в NAS (`gis_archive/contracts/[ИМЯ]/`).
- Генерация MD-отчета в Obsidian.
- Возврат назад, ожидание таблицы.
## 🛡️ Модуль Защиты и Отчетности
- **Playwright Persistent Context:** Куки сохраняются.
- **Smart Waiters:** Использование `wait_for_load_state("networkidle")` и случайных пауз (3-7 сек) для имитации человека.
- **Telegram Logging:** Отчет о начале каждой фазы, о скачивании каждого файла, о завершении миссии.

30
scraper_gis/Dockerfile Normal file
View file

@ -0,0 +1,30 @@
FROM mcr.microsoft.com/playwright/python:v1.43.0-jammy
# Подавляем интерактивные запросы
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
ENV DISPLAY=:99
WORKDIR /app
# Установка системных зависимостей по отдельности для контроля
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
x11vnc \
fluxbox \
novnc \
websockify \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Установка Python зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install chromium
COPY . .
RUN chmod +x start.sh
# Порт для noVNC
EXPOSE 6080
CMD ["./start.sh", "interactive"]

View file

@ -0,0 +1,209 @@
import asyncio
import os
import logging
import random
import re
from datetime import datetime
from playwright.async_api import async_playwright
# Настройка логов
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("logs/contracts.log"), logging.StreamHandler()]
)
logger = logging.getLogger("GIS-Titanium-Harvester")
USER_DATA_DIR = "/app/browser_context"
OBSIDIAN_BASE = "/app/obsidian/Gemini/Projects/GIS_ZHKH"
NAS_DOWNLOADS = "/app/downloads/nas/contracts"
processed_contracts = set()
async def send_tg_report(message):
token = os.getenv("TELEGRAM_BOT_TOKEN")
chat_id = os.getenv("TELEGRAM_CHAT_ID")
if not token or not chat_id: return
import aiohttp
url = f"https://api.telegram.org/bot{token}/sendMessage"
try:
async with aiohttp.ClientSession() as session:
await session.post(url, json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"})
except: pass
async def automation_mission():
async with async_playwright() as p:
logger.info("Launching Titanium Harvester (Text-Based State Machine)...")
context = await p.chromium.launch_persistent_context(
user_data_dir=USER_DATA_DIR,
headless=False,
args=["--no-sandbox", "--disable-setuid-sandbox", "--start-maximized"]
)
page = await context.new_page()
await page.set_viewport_size({"width": 1600, "height": 1024})
os.makedirs(f"{NAS_DOWNLOADS}", exist_ok=True)
os.makedirs(f"{OBSIDIAN_BASE}/screenshots", exist_ok=True)
os.makedirs(f"{OBSIDIAN_BASE}/contracts_data/evidence", exist_ok=True)
await page.goto("https://dom.gosuslugi.ru/")
await send_tg_report("🤖 <b>Титановый Жнец запущен.</b>\nАрхитектура перестроена. Теперь я не теряю ссылки из памяти. Заходи в список договоров.")
while True:
try:
await asyncio.sleep(5)
# Глазок для Обсидиана
await page.screenshot(path=f"{OBSIDIAN_BASE}/screenshots/contracts_view.png")
frames = [page] + page.frames
full_text = ""
for f in frames:
try: full_text += await f.content()
except: continue
# --- 1. ПРОВЕРКА: МЫ ВНУТРИ КАРТОЧКИ? ---
if "Карточка договора" in full_text or "Документы договора" in full_text:
logger.info("State: INSIDE CONTRACT")
# Пытаемся понять, в каком мы договоре
contract_name = "Unknown_Contract"
for f in frames:
try:
h1 = await f.query_selector("h1, .page-header, .document-title")
if h1:
contract_name = (await h1.inner_text()).strip().split("\n")[0]
break
except: continue
safe_name = "".join([c for c in contract_name if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
if safe_name not in processed_contracts:
await send_tg_report(f"🎣 <b>Начинаю выкачку:</b> <code>{contract_name[:50]}...</code>")
# Скриншот
scr_name = f"evidence_{safe_name}.png"
await page.screenshot(path=f"{OBSIDIAN_BASE}/contracts_data/evidence/{scr_name}", full_page=True)
# Агрессивный поиск файлов
downloaded_files = []
for f in frames:
selectors = [
"a[title*='Скачать']",
"button:has-text('Скачать')",
"a:has-text('Скачать все')",
"a[href*='.pdf']", "a[href*='.zip']", "a[href*='.docx']"
]
for s in selectors:
try:
elements = await f.locator(s).all()
for btn in elements:
if not await btn.is_visible(): continue
try:
async with page.expect_download(timeout=30000) as di:
await btn.click()
download = await di.value
fname = download.suggested_filename
dest = f"{NAS_DOWNLOADS}/{safe_name}/{fname}"
os.makedirs(os.path.dirname(dest), exist_ok=True)
await download.save_as(dest)
downloaded_files.append(f"- `{fname}`")
logger.info(f"Downloaded: {fname}")
except Exception as dl_e:
logger.error(f"Download err: {dl_e}")
except: continue
# Пишем отчет
with open(f"{OBSIDIAN_BASE}/contracts_data/{safe_name}.md", "w", encoding="utf-8") as of:
of.write(f"# {contract_name}\n\n![[Gemini/Projects/GIS_ZHKH/contracts_data/evidence/{scr_name}]]\n\n")
if downloaded_files:
of.write("## 📄 Скачанные документы\n" + "\n".join(downloaded_files))
else:
of.write("## 📄 Скачанные документы\nФайлы не обнаружены. Скриншот зафиксирован.")
processed_contracts.add(safe_name)
if downloaded_files:
await send_tg_report(f"✅ <b>Успешно:</b> Файлов собрано: {len(downloaded_files)}. Возвращаюсь к списку.")
else:
await send_tg_report(f"⚠️ <b>Внимание:</b> В договоре нет файлов. Скриншот сделан. Иду дальше.")
# Возвращаемся в список
logger.info("Returning to list...")
found_back = False
for f in frames:
try:
back_btn = f.locator("text='Информация о договорах по дому'").first
if await back_btn.is_visible(timeout=1000):
await back_btn.click()
found_back = True
break
except: continue
if not found_back:
await page.go_back()
await asyncio.sleep(8) # Ждем прогрузки списка
continue
# --- 2. ПРОВЕРКА: МЫ В СПИСКЕ? ---
if "Результаты поиска" in full_text:
logger.info("State: IN LIST")
# Ищем тексты всех договоров на экране
available_contracts_texts = []
for f in frames:
try:
# Ищем все элементы, содержащие слово Договор
elements = await f.locator("text=/Договор.*/i").all()
for el in elements:
if await el.is_visible():
txt = (await el.inner_text()).strip()
# Жесткий фильтр мусора
if txt.startswith("Договор") and "" in txt and "Закон" not in txt and len(txt) > 15:
if txt not in available_contracts_texts:
available_contracts_texts.append(txt)
except: continue
if not available_contracts_texts:
logger.info("List detected, but no valid contract titles found yet.")
continue
# Ищем первый необработанный
target_text = None
for txt in available_contracts_texts:
s_name = "".join([c for c in txt if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
if s_name not in processed_contracts:
target_text = txt
break
if target_text:
await send_tg_report(f"🚀 <b>Цель найдена:</b> <code>{target_text[:40]}...</code>\nКликаю!")
# Кликаем именно по тексту, заново его находя
clicked = False
for f in frames:
try:
btn = f.locator(f"text='{target_text}'").first
if await btn.is_visible(timeout=1000):
await btn.scroll_into_view_if_needed()
await btn.click()
clicked = True
break
except: continue
if clicked:
await asyncio.sleep(8) # Ждем прогрузки карточки
else:
logger.error(f"Failed to click target text: {target_text}")
else:
logger.info("All contracts on this page are processed.")
# --- 3. НЕИЗВЕСТНОЕ СОСТОЯНИЕ ---
else:
logger.info("State: UNKNOWN. Waiting for user action.")
except Exception as e:
logger.error(f"Main Loop Error: {e}")
await asyncio.sleep(10)
if __name__ == "__main__":
asyncio.run(automation_mission())

View file

@ -0,0 +1,18 @@
version: '3.8'
services:
gis-scraper:
build: .
container_name: lkm37-gis-scraper
ports:
- "6080:6080"
volumes:
- .:/app
- /mnt/nas/tubearchivist/media/gis_archive:/app/downloads/nas
- /mnt/nas/obsidian/data/vault:/app/obsidian
- ./browser_context:/app/browser_context
environment:
- TZ=Europe/Ulyanovsk
- TELEGRAM_BOT_TOKEN=8725618164:AAH1tGalq-pw1l0t4P5c0sdLkCVJdh7IE7M
- TELEGRAM_CHAT_ID=197957361
command: ["./start.sh", "ultimate"]

View file

@ -0,0 +1,75 @@
import asyncio
import os
import logging
from playwright.async_api import async_playwright
from sqlalchemy import create_engine
from models import Base
# Настройка логов
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("GIS-Mapper")
USER_DATA_DIR = "/app/browser_context"
TARGET_URL = "https://dom.gosuslugi.ru/"
async def send_tg_report(message):
token = os.getenv("TELEGRAM_BOT_TOKEN")
chat_id = os.getenv("TELEGRAM_CHAT_ID")
if not token or not chat_id: return
import aiohttp
url = f"https://api.telegram.org/bot{token}/sendMessage"
async with aiohttp.ClientSession() as session:
await session.post(url, json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"})
async def map_personal_account():
async with async_playwright() as p:
logger.info("Starting browser with persistent context...")
context = await p.chromium.launch_persistent_context(
user_data_dir=USER_DATA_DIR,
headless=False, # Обязательно False, чтобы ты видел окно!
args=["--no-sandbox", "--disable-setuid-sandbox"]
)
page = await context.new_page()
await page.goto(TARGET_URL)
logger.info("Waiting for user to log in...")
await send_tg_report("🌐 <b>Браузер запущен!</b>\n\nЗаходи по ссылке: http://192.168.10.116:6080/vnc.html\nЛогинься в ГИС ЖКХ. Я жду появления личного кабинета...")
# Ждем появления элемента, который есть только в личном кабинете
# Например, кнопка "Выйти" или имя пользователя
try:
# Ждем долго (10 минут), пока ты вводишь пароли и СМС
await page.wait_for_selector("text=Выйти", timeout=600000)
logger.info("User logged in successfully!")
await send_tg_report("✅ <b>Успешный вход!</b>\n\nЯ вижу личный кабинет. Начинаю сканирование структуры разделов...")
# Снимаем главный экран ЛК
await page.screenshot(path="/app/screenshots/dashboard.png", full_page=True)
# Собираем ссылки из главного меню
menu_items = await page.query_selector_all("a")
structure = []
for item in menu_items:
text = await item.inner_text()
href = await item.get_attribute("href")
if text and href and href.startswith("/"):
structure.append(f"- **{text.strip()}**: `{href}`")
# Сохраняем карту в Обсидиан (через маунт)
report_path = "/app/downloads/nas/GIS_STRUCTURE_MAP.md"
with open(report_path, "w") as f:
f.write("# 🗺️ Карта Личного Кабинета ГИС ЖКХ\n\n")
f.write(f"Дата сканирования: {asyncio.get_event_loop().time()}\n\n")
f.write("\n".join(set(structure)))
await send_tg_report(f"🗺️ <b>Карта ЛК составлена!</b>\n\nНайдено разделов: {len(set(structure))}\nФайл сохранен в Obsidian.")
except Exception as e:
logger.error(f"Error during mapping: {e}")
await send_tg_report(f"❌ <b>Ошибка при сканировании ЛК:</b>\n<code>{str(e)}</code>")
await context.close()
if __name__ == "__main__":
asyncio.run(map_personal_account())

View file

@ -0,0 +1,296 @@
import asyncio
import os
import logging
import random
from datetime import datetime
from playwright.async_api import async_playwright
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[logging.FileHandler("logs/contracts.log"), logging.StreamHandler()])
logger = logging.getLogger("GIS-Ultimate")
USER_DATA_DIR = "/app/browser_context"
OBSIDIAN_BASE = "/app/obsidian/Gemini/Projects/GIS_ZHKH"
NAS_DOWNLOADS = "/app/downloads/nas/gis_archive"
BLACKLIST = [
"Справка по системе", "Регламенты и инструкции", "Часто задаваемые вопросы",
"Карта сайта", "Версия для слабовидящих", "Обращение в службу поддержки",
"Личный кабинет", "Главная страница", "Помещения (жилые дома)",
"Информационные сообщения", "Обращения и ответы на них", "Подключенные ЛС",
"Оплата ЖКУ", "Аналитика и отчеты", "Форум", "Свернуть поиск", "Найти",
"Очистить", "предыдущая", "следующая", "Выводить по"
]
async def send_tg_report(message):
token = os.getenv("TELEGRAM_BOT_TOKEN")
chat_id = os.getenv("TELEGRAM_CHAT_ID")
if not token or not chat_id: return
import aiohttp
url = f"https://api.telegram.org/bot{token}/sendMessage"
try:
async with aiohttp.ClientSession() as session:
await session.post(url, json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"})
except: pass
async def human_delay(min_sec=4, max_sec=8):
await asyncio.sleep(random.uniform(min_sec, max_sec))
async def take_screenshot(page, title, subfolder):
safe_name = "".join([c for c in title if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{safe_name}_{timestamp}.png"
path = f"{OBSIDIAN_BASE}/{subfolder}/{filename}"
os.makedirs(os.path.dirname(path), exist_ok=True)
await page.screenshot(path=path, full_page=True)
return filename
async def download_all_files(page, save_folder):
files_saved = []
dest_dir = f"{NAS_DOWNLOADS}/{save_folder}"
os.makedirs(dest_dir, exist_ok=True)
selectors = ["a[title*='Скачать']", "button:has-text('Скачать')", "a:has-text('Скачать все')", "a[href*='.pdf']", "a[href*='.zip']", "a[href*='.doc']", "a[href*='.rar']"]
for f in [page] + page.frames:
for selector in selectors:
try:
elements = await f.locator(selector).all()
for el in elements:
if not await el.is_visible(): continue
try:
async with page.expect_download(timeout=15000) as di:
await el.click()
download = await di.value
fname = download.suggested_filename
dest = f"{dest_dir}/{fname}"
await download.save_as(dest)
files_saved.append(fname)
except: continue
except: continue
return files_saved
async def phase_0_login(page):
await send_tg_report("🔍 <b>Инициализация. Проверка сессии.</b>")
await page.goto("https://dom.gosuslugi.ru/")
await human_delay()
for _ in range(30):
content = ""
for f in [page] + page.frames:
try: content += await f.content()
except: pass
if "Выйти" in content or "Александрович" in content:
await send_tg_report("✅ Сессия активна.")
return True
await asyncio.sleep(5)
return False
async def process_generic_list(page, url, section_name, folder_name, link_prefix=""):
await send_tg_report(f"📂 <b>Захожу в раздел: {section_name}</b>")
await page.goto(url)
await human_delay(5, 8)
processed = set()
page_num = 1
while True:
await send_tg_report(f"📄 Сканирую страницу {page_num}...")
await take_screenshot(page, f"Список_стр_{page_num}", f"{folder_name}/lists")
while True:
targets = []
for f in [page] + page.frames:
try:
elements = await f.locator("a").all()
for el in elements:
if await el.is_visible():
txt = (await el.inner_text()).strip()
if len(txt) > 10 and txt not in processed:
is_blacklisted = any(b in txt for b in BLACKLIST)
if not is_blacklisted:
if not link_prefix or txt.startswith(link_prefix):
targets.append((el, txt))
except: pass
if targets: break
if not targets: break # Все ссылки на этой странице обработаны
el, txt = targets[0]
safe_name = "".join([c for c in txt if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
processed.add(txt)
await send_tg_report(f"⏳ Открываю: <code>{txt[:40]}...</code>")
try:
await el.click()
await human_delay(4, 7)
inner_scr = await take_screenshot(page, safe_name, f"{folder_name}/details")
files = await download_all_files(page, f"{folder_name}/{safe_name}")
path = f"{OBSIDIAN_BASE}/{folder_name}/{safe_name}.md"
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f_out:
f_out.write(f"# {txt}\n\n![[Gemini/Projects/GIS_ZHKH/{folder_name}/details/{inner_scr}]]\n\n## Файлы\n")
if files:
for fn in files: f_out.write(f"- `{fn}`\n")
else:
f_out.write("Файлов нет.\n")
await send_tg_report(f"✅ Готово. Файлов: {len(files)}")
await page.go_back()
await human_delay(4, 7)
except Exception as e:
logger.error(f"Error on {txt}: {e}")
await page.goto(url)
await human_delay(5, 8)
# Пагинация
next_found = False
for f in [page] + page.frames:
try:
btn = f.locator("a:has-text('следующая')").first
if await btn.is_visible():
await btn.click()
page_num += 1
next_found = True
await human_delay(5, 8)
break
except: pass
if not next_found: break
await send_tg_report(f"🏆 <b>Раздел {section_name} полностью выкачан!</b>")
async def phase_3_contracts(page):
await send_tg_report("📂 <b>Захожу в раздел: Договора</b>")
await page.goto("https://dom.gosuslugi.ru/#!/house-info")
await human_delay(4, 6)
# Клик по иконке раздела
for f in [page] + page.frames:
try:
btn = f.locator("text='Информация о договорах по дому'").first
if await btn.is_visible():
await btn.click()
await human_delay(4, 6)
break
except: pass
# Клик Найти
for f in [page] + page.frames:
try:
btn = f.locator("button:has-text('Найти')").first
if await btn.is_visible():
await btn.click()
await human_delay(6, 10)
break
except: pass
processed = set()
page_num = 1
while True:
await send_tg_report(f"📄 Договора: Страница {page_num}...")
await take_screenshot(page, f"Договора_Стр_{page_num}", "contracts/lists")
while True:
targets = []
for f in [page] + page.frames:
try:
elements = await f.locator("a").all()
for el in elements:
if await el.is_visible():
txt = (await el.inner_text()).strip()
if txt.startswith("Договор") and "Закон" not in txt and txt not in processed and len(txt) > 15:
targets.append((el, txt))
except: pass
if targets: break
if not targets: break
el, txt = targets[0]
safe_name = "".join([c for c in txt if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
processed.add(txt)
await send_tg_report(f"⏳ Открываю договор: <code>{txt[:40]}...</code>")
try:
await el.click()
await human_delay(5, 8)
inner_scr = await take_screenshot(page, safe_name, "contracts/details")
files = await download_all_files(page, f"contracts/{safe_name}")
path = f"{OBSIDIAN_BASE}/contracts/{safe_name}.md"
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f_out:
f_out.write(f"# {txt}\n\n![[Gemini/Projects/GIS_ZHKH/contracts/details/{inner_scr}]]\n\n## Файлы\n")
if files:
for fn in files: f_out.write(f"- `{fn}`\n")
else:
f_out.write("Файлов нет.\n")
await send_tg_report(f"✅ Готово. Файлов: {len(files)}")
# Клик по хлебным крошкам для возврата
back_clicked = False
for f in [page] + page.frames:
try:
bbtn = f.locator("text='Информация о договорах по дому'").first
if await bbtn.is_visible(timeout=1000):
await bbtn.click()
back_clicked = True
break
except: pass
if not back_clicked:
await page.go_back()
await human_delay(5, 8)
except Exception as e:
logger.error(f"Error on {txt}: {e}")
await page.go_back()
await human_delay(5, 8)
# Пагинация
next_found = False
for f in [page] + page.frames:
try:
btn = f.locator("a:has-text('следующая')").first
if await btn.is_visible():
await btn.click()
page_num += 1
next_found = True
await human_delay(5, 8)
break
except: pass
if not next_found: break
await send_tg_report("🏆 <b>Раздел Договора полностью выкачан!</b>")
async def run_ultimate_mission():
async with async_playwright() as p:
context = await p.chromium.launch_persistent_context(
user_data_dir=USER_DATA_DIR, headless=False, args=["--no-sandbox", "--disable-setuid-sandbox", "--start-maximized"]
)
page = context.pages[0] if context.pages else await context.new_page()
await page.set_viewport_size({"width": 1600, "height": 1024})
await send_tg_report("🚀 <b>ФИНАЛЬНЫЙ АГЕНТ ЗАПУЩЕН.</b> Жду сессию.")
if await phase_0_login(page):
# 1. Госпочта (Уведомления)
await process_generic_list(page, "https://my.dom.gosuslugi.ru/citizen-cabinet/#!/hcsi/nnl?tab=INCOMING", "Госпочта (Уведомления)", "notifications")
# 2. Обращения
await process_generic_list(page, "https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals", "Обращения", "appeals")
# 3. Договоры
await phase_3_contracts(page)
await send_tg_report("🎉 <b>ВСЕ ЗАДАЧИ ВЫПОЛНЕНЫ! СВОБОДА!</b>")
while True: await asyncio.sleep(3600)
if __name__ == "__main__":
asyncio.run(run_ultimate_mission())

40
scraper_gis/models.py Normal file
View file

@ -0,0 +1,40 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class Document(Base):
__tablename__ = 'documents'
id = Column(Integer, primary_key=True)
file_id = Column(String(255), unique=True)
title = Column(Text)
category = Column(String(100))
gis_upload_date = Column(DateTime)
scraped_at = Column(DateTime, default=datetime.utcnow)
file_path = Column(Text)
screenshot_path = Column(Text)
is_downloaded = Column(Boolean, default=False)
file_hash = Column(String(64))
contract_id = Column(Integer, ForeignKey('contracts.id'))
class Contract(Base):
__tablename__ = 'contracts'
id = Column(Integer, primary_key=True)
gis_id = Column(String(255), unique=True) # Внутренний ID договора в ГИС
name = Column(Text) # Название/номер договора
status = Column(String(50)) # Текущий статус обработки
found_at = Column(DateTime, default=datetime.utcnow)
processed_at = Column(DateTime, nullable=True)
screenshot_main = Column(Text) # Скриншот страницы договора
documents = relationship("Document", backref="contract")
class ScraperLog(Base):
__tablename__ = 'logs'
id = Column(Integer, primary_key=True)
timestamp = Column(DateTime, default=datetime.utcnow)
action = Column(String(255))
status = Column(String(50))
message = Column(Text)

110
scraper_gis/recon_agent.py Normal file
View file

@ -0,0 +1,110 @@
import asyncio
import os
import logging
import random
from playwright.async_api import async_playwright
# Настройка логов
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("logs/recon.log"), logging.StreamHandler()]
)
logger = logging.getLogger("GIS-Aristoteles-Recon")
USER_DATA_DIR = "/app/browser_context"
TARGET_URL = "https://dom.gosuslugi.ru/"
OBSIDIAN_BASE = "/app/obsidian/Gemini/Projects/GIS_ZHKH"
async def send_tg_report(message):
token = os.getenv("TELEGRAM_BOT_TOKEN")
chat_id = os.getenv("TELEGRAM_CHAT_ID")
if not token or not chat_id: return
import aiohttp
url = f"https://api.telegram.org/bot{token}/sendMessage"
try:
async with aiohttp.ClientSession() as session:
await session.post(url, json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"})
except: pass
async def deep_scan():
async with async_playwright() as p:
logger.info("Launching browser (Aristoteles Mode)...")
context = await p.chromium.launch_persistent_context(
user_data_dir=USER_DATA_DIR,
headless=False,
args=["--no-sandbox", "--disable-setuid-sandbox", "--start-maximized"]
)
page = await context.new_page()
await page.set_viewport_size({"width": 1600, "height": 1024})
logger.info(f"Navigating to {TARGET_URL}")
await page.goto(TARGET_URL)
os.makedirs(f"{OBSIDIAN_BASE}/screenshots", exist_ok=True)
markers = [
"Информация о договорах по дому",
"Капитальный ремонт",
"Итоги общих собраний собственников"
]
found_marker = None
attempt = 0
while not found_marker:
attempt += 1
logger.info(f"Scan attempt {attempt}...")
# Сохраняем "глазок" в Обсидиан для отладки
debug_scr = f"{OBSIDIAN_BASE}/screenshots/current_view.png"
await page.screenshot(path=debug_scr)
# Проверяем наличие маркеров
content = await page.content()
for m in markers:
if m in content:
found_marker = m
break
if found_marker: break
if attempt % 10 == 0:
await send_tg_report(f"⏳ <b>Жду входа...</b> (Попытка {attempt})\nЯ смотрю на экран и не вижу разделов жильца. Проверь файл <code>current_view.png</code> в Обсидиане.")
await asyncio.sleep(10)
await send_tg_report(f"🎯 <b>Цель захвачена!</b> Увидел: <code>{found_marker}</code>.\nНачинаю тотальное картирование всех кнопок.")
# 1. Снимаем финальный вид
await page.screenshot(path=f"{OBSIDIAN_BASE}/screenshots/dashboard_full.png", full_page=True)
# 2. Собираем элементы (Исправленный CSS селектор!)
elements = await page.query_selector_all("a, button, [role='button']")
report_lines = ["# 🏛️ ТОТАЛЬНАЯ КАРТА ЛК: ТЫРИН АЛЕКСАНДР\n"]
report_lines.append(f"Дата сканирования: {asyncio.get_event_loop().time()}\n")
report_lines.append("![[Gemini/Projects/GIS_ZHKH/screenshots/dashboard_full.png]]\n")
report_lines.append("## 🔍 Все найденные интерактивные элементы:\n")
seen = set()
for el in elements:
try:
txt = await el.inner_text()
txt = txt.strip().replace("\n", " ")
if txt and len(txt) > 2 and txt not in seen:
seen.add(txt)
report_lines.append(f"- `{txt}`")
except: continue
with open(f"{OBSIDIAN_BASE}/TOTAL_MAP.md", "w", encoding="utf-8") as f:
f.write("\n".join(report_lines))
await send_tg_report("🏆 <b>Миссия выполнена!</b>\n\nФайл <code>TOTAL_MAP.md</code> и скриншоты в папке проекта в Obsidian.")
while True:
await asyncio.sleep(3600)
if __name__ == "__main__":
asyncio.run(deep_scan())

View file

@ -0,0 +1,6 @@
playwright
sqlalchemy
aiosqlite
python-dotenv
requests
aiohttp

77
scraper_gis/scraper.py Normal file
View file

@ -0,0 +1,77 @@
import asyncio
import os
import logging
print("!!! DEBUG START !!!")
from sqlalchemy import create_engine
from models import Base
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("logs/scraper.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger("GIS-Scraper")
DATABASE_URL = "sqlite:///data/gis_archive.db"
def init_db():
logger.info("Initializing database...")
engine = create_engine(DATABASE_URL)
Base.metadata.create_all(engine)
logger.info("Database initialized.")
async def check_connectivity():
import aiohttp
target = "https://dom.gosuslugi.ru"
logger.info(f"Checking connectivity to {target}...")
try:
async with aiohttp.ClientSession() as session:
async with session.get(target, timeout=30) as response:
if response.status == 200:
logger.info("✅ GIS ZHKH is accessible.")
return True
else:
logger.warning(f"⚠️ GIS ZHKH returned status {response.status}")
return False
except Exception as e:
logger.error(f"❌ Connection failed: {e}")
return False
async def send_tg_report(message):
token = os.getenv("TELEGRAM_BOT_TOKEN")
chat_id = os.getenv("TELEGRAM_CHAT_ID")
if not token or not chat_id:
return
import aiohttp
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {"chat_id": chat_id, "text": message, "parse_mode": "HTML"}
try:
async with aiohttp.ClientSession() as session:
await session.post(url, json=payload)
except Exception as e:
logger.error(f"Failed to send TG report: {e}")
async def main():
init_db()
status = "OK" if await check_connectivity() else "FAILED"
report = f"""
🏛 <b>GIS ZHKH Archiver: Stage 1 Complete</b>
Среда Docker подготовлена.
База данных SQLite инициализирована.
Коннект к dom.gosuslugi.ru: <b>{status}</b>
🚀 Проект запущен. Следующий этап: реализация Persistent Context для авторизации.
"""
await send_tg_report(report)
logger.info("Stage 1 report sent to Telegram.")
if __name__ == "__main__":
asyncio.run(main())

46
scraper_gis/start.sh Executable file
View file

@ -0,0 +1,46 @@
#!/bin/bash
export DISPLAY=:99
echo "DEBUG: Received argument: '$1'"
# Удаляем старые X-замки
rm -f /tmp/.X99-lock
Xvfb :99 -screen 0 1280x1024x24 &
sleep 2
fluxbox &
# x11vnc: -shared позволяет несколько подключений, -forever не закрывается
x11vnc -display :99 -forever -nopw -rfbport 5900 -shared -bg
# Запуск websockify напрямую (noVNC)
websockify --web /usr/share/novnc 6080 localhost:5900 &
echo "📺 Система зрения готова: http://192.168.10.116:6080/vnc.html"
# Удаляем замки хромиума перед стартом
rm -rf /app/browser_context/Singleton*
if [ "$1" == "interactive" ]; then
echo "Entering interactive mode..."
tail -f /dev/null
elif [ "$1" == "mapping" ]; then
echo "🚀 Запуск скрипта картографии..."
python map_and_auth.py
tail -f /dev/null
elif [ "$1" == "recon" ]; then
echo "🕵️‍♂️ Запуск агента ГЛУБОКОЙ разведки..."
python recon_agent.py
tail -f /dev/null
elif [ "$1" == "contracts" ]; then
echo "📑 Запуск Жнеца Договоров..."
python contracts_scraper.py
tail -f /dev/null
elif [ "$1" == "ultimate" ]; then
echo "🎓 Запуск ЖНЕЦ-УЛЬТИМАТУМ (Финальная версия)..."
python ultimate_reaper.py
tail -f /dev/null
else
echo "⚠️ Неизвестный режим: '$1'. Запускаю базовый скрейпер."
python scraper.py
tail -f /dev/null
fi

View file

@ -0,0 +1,312 @@
import asyncio
import os
import logging
import random
from datetime import datetime
from playwright.async_api import async_playwright
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[logging.FileHandler("logs/reaper.log"), logging.StreamHandler()])
logger = logging.getLogger("Ultimate-Reaper")
USER_DATA_DIR = "/app/browser_context"
OBSIDIAN_BASE = "/app/obsidian/Gemini/Projects/GIS_ZHKH"
NAS_DOWNLOADS = "/app/downloads/nas/gis_archive"
async def send_tg_report(message):
token = os.getenv("TELEGRAM_BOT_TOKEN")
chat_id = os.getenv("TELEGRAM_CHAT_ID")
if not token or not chat_id: return
import aiohttp
url = f"https://api.telegram.org/bot{token}/sendMessage"
try:
async with aiohttp.ClientSession() as session:
await session.post(url, json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"})
except: pass
async def human_delay():
# Имитируем медлительного человека: от 5 до 22 секунд
delay = random.uniform(5, 22)
logger.info(f"Human delay: {delay:.2f}s")
await asyncio.sleep(delay)
async def take_screenshot(page, title, subfolder):
safe_name = "".join([c for c in title if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{safe_name}_{timestamp}.png"
path = f"{OBSIDIAN_BASE}/{subfolder}/{filename}"
os.makedirs(os.path.dirname(path), exist_ok=True)
await page.screenshot(path=path, full_page=True)
return filename
async def get_all_frames(page):
return [page] + page.frames
async def download_files_from_frames(page, save_folder):
files_saved = []
dest_dir = f"{NAS_DOWNLOADS}/{save_folder}"
os.makedirs(dest_dir, exist_ok=True)
selectors = [
"a[title*='Скачать']", "button:has-text('Скачать')", "a:has-text('Скачать все')",
"a[href*='.pdf']", "a[href*='.zip']", "a[href*='.doc']", "a[href*='.rar']"
]
for f in await get_all_frames(page):
for selector in selectors:
try:
elements = await f.locator(selector).all()
for el in elements:
if not await el.is_visible(): continue
try:
async with page.expect_download(timeout=15000) as di:
await el.click()
download = await di.value
fname = download.suggested_filename
dest = f"{dest_dir}/{fname}"
await download.save_as(dest)
files_saved.append(fname)
except: continue
except: continue
return files_saved
async def wait_for_user_login(page):
await send_tg_report("🔍 <b>Инициализация.</b>\nЯ открыл страницу ГИС ЖКХ и жду, когда ты залогинишься и зайдешь в Личный Кабинет.\nURL: http://192.168.10.116:6080/vnc.html")
await page.goto("https://dom.gosuslugi.ru/")
await human_delay()
for _ in range(60): # Ждем долго (около 20 минут в сумме с задержками)
content = ""
for f in await get_all_frames(page):
try: content += await f.content()
except: pass
if "Выйти" in content or "Александрович" in content:
await send_tg_report("✅ <b>Успех:</b> Я вижу Личный Кабинет! Начинаю работу.")
return True
await asyncio.sleep(20)
return False
async def map_dashboard(page):
await send_tg_report("🗺️ <b>Начинаю картографирование Личного Кабинета.</b>")
await human_delay()
scr = await take_screenshot(page, "Dashboard", "reports")
buttons = set()
for f in await get_all_frames(page):
try:
els = await f.locator("a, button, [role='button'], .menu-item").all()
for el in els:
if await el.is_visible():
txt = (await el.inner_text()).strip()
if txt and len(txt) > 3:
buttons.add(txt)
except: pass
path = f"{OBSIDIAN_BASE}/reports/Dashboard_Map.md"
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write("# Карта Личного Кабинета\n\n")
f.write(f"![[Gemini/Projects/GIS_ZHKH/reports/{scr}]]\n\n")
f.write("## Найденные элементы:\n")
for b in sorted(buttons):
f.write(f"- `{b}`\n")
await send_tg_report("✅ <b>Карта составлена</b> и сохранена в Obsidian (reports/Dashboard_Map.md).")
await human_delay()
async def process_notifications(page):
await send_tg_report("📦 <b>Раздел: Информационные сообщения.</b> Захожу...")
await page.goto("https://my.dom.gosuslugi.ru/citizen-cabinet/#!/hcsi/nnl?tab=INCOMING")
await human_delay()
scr = await take_screenshot(page, "Уведомления", "notifications")
files = await download_files_from_frames(page, "notifications")
path = f"{OBSIDIAN_BASE}/reports/Notifications.md"
with open(path, "w", encoding="utf-8") as f:
f.write("# Информационные сообщения\n\n")
f.write(f"![[Gemini/Projects/GIS_ZHKH/notifications/{scr}]]\n\n")
f.write("## Файлы:\n")
for file in files: f.write(f"- `{file}`\n")
await send_tg_report(f"✅ <b>Уведомления:</b> Скриншот сделан, скачано {len(files)} файлов.")
await human_delay()
async def process_appeals(page):
await send_tg_report("✉️ <b>Раздел: Обращения и ответы.</b> Захожу...")
await page.goto("https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals")
await human_delay()
# Ждем загрузки списка
await asyncio.sleep(5)
scr = await take_screenshot(page, "Обращения_Список", "appeals")
page_num = 1
processed = set()
while True:
await send_tg_report(f"📄 Обращения: Обрабатываю страницу {page_num}...")
while True:
targets = []
for f in await get_all_frames(page):
try:
links = await f.locator("a").all()
for l in links:
if await l.is_visible():
txt = (await l.inner_text()).strip()
# Ищем ссылки-номера обращений или темы
if len(txt) > 10 and txt not in processed and ("" in txt or "Ответ" in txt or "Обращение" in txt):
# Исключаем пагинацию и меню
if "следующая" not in txt.lower() and "предыдущая" not in txt.lower():
targets.append((l, txt))
except: pass
if targets: break
if not targets: break
el, txt = targets[0]
safe_name = "".join([c for c in txt if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
processed.add(txt)
await send_tg_report(f"⏳ Открываю обращение: <code>{txt[:40]}...</code>")
try:
await el.click()
await human_delay() # Имитация человека
inner_scr = await take_screenshot(page, safe_name, "appeals")
files = await download_files_from_frames(page, f"appeals/{safe_name}")
path = f"{OBSIDIAN_BASE}/reports/Appeal_{safe_name}.md"
with open(path, "w", encoding="utf-8") as f_out:
f_out.write(f"# Обращение: {txt}\n\n![[Gemini/Projects/GIS_ZHKH/appeals/{inner_scr}]]\n\n## Файлы:\n")
if files:
for fn in files: f_out.write(f"- `{fn}`\n")
else:
f_out.write("Файлов нет.\n")
await send_tg_report(f"✅ Готово. Файлов: {len(files)}")
await page.go_back()
await human_delay()
except Exception as e:
logger.error(f"Error on appeal {txt}: {e}")
await page.goto("https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals")
await human_delay()
# Пагинация
next_found = False
for f in await get_all_frames(page):
try:
btn = f.locator("a:has-text('следующая')").first
if await btn.is_visible():
await btn.click()
page_num += 1
next_found = True
await human_delay()
break
except: pass
if not next_found: break
await send_tg_report("🏆 <b>Раздел Обращений выкачан полностью!</b>")
async def process_contracts(page):
await send_tg_report("📑 <b>Раздел: Информация о договорах по дому.</b> Захожу...")
await page.goto("https://dom.gosuslugi.ru/#!/house-info")
await human_delay()
# Клик по иконке раздела
clicked = False
for f in await get_all_frames(page):
try:
btn = f.locator("text='Информация о договорах по дому'").first
if await btn.is_visible():
await btn.click()
await human_delay()
clicked = True
break
except: pass
if not clicked:
await send_tg_report("⚠️ Не смог найти раздел 'Информация о договорах по дому'.")
return
# Клик Найти
for f in await get_all_frames(page):
try:
btn = f.locator("button:has-text('Найти')").first
if await btn.is_visible():
await btn.click()
await human_delay()
break
except: pass
processed = set()
while True:
targets = []
for f in await get_all_frames(page):
try:
elements = await f.locator("a").all()
for el in elements:
if await el.is_visible():
txt = (await l.inner_text()).strip() if hasattr(el, 'inner_text') else (await el.inner_text()).strip()
if txt.startswith("Договор") and "Закон" not in txt and txt not in processed and len(txt) > 15:
targets.append((el, txt))
except: pass
if targets: break
if not targets: break
el, txt = targets[0]
safe_name = "".join([c for c in txt if c.isalnum() or c in (' ', '_', '-')]).strip().replace(" ", "_")
processed.add(txt)
await send_tg_report(f"⏳ Открываю договор: <code>{txt[:40]}...</code>")
try:
await el.click()
await human_delay()
inner_scr = await take_screenshot(page, safe_name, "contracts")
files = await download_files_from_frames(page, f"contracts/{safe_name}")
path = f"{OBSIDIAN_BASE}/reports/Contract_{safe_name}.md"
with open(path, "w", encoding="utf-8") as f_out:
f_out.write(f"# {txt}\n\n![[Gemini/Projects/GIS_ZHKH/contracts/{inner_scr}]]\n\n## Файлы:\n")
if files:
for fn in files: f_out.write(f"- `{fn}`\n")
else:
f_out.write("Файлов нет.\n")
await send_tg_report(f"✅ Готово. Файлов: {len(files)}")
await page.go_back()
await human_delay()
except Exception as e:
logger.error(f"Error on {txt}: {e}")
await page.go_back()
await human_delay()
await send_tg_report("🏆 <b>Раздел Договоров выкачан полностью!</b>")
async def ultimate_mission():
async with async_playwright() as p:
context = await p.chromium.launch_persistent_context(
user_data_dir=USER_DATA_DIR, headless=False, args=["--no-sandbox", "--disable-setuid-sandbox", "--start-maximized"]
)
page = context.pages[0] if context.pages else await context.new_page()
await page.set_viewport_size({"width": 1600, "height": 1024})
if await wait_for_user_login(page):
await map_dashboard(page)
await process_notifications(page)
await process_appeals(page)
await process_contracts(page)
await send_tg_report("🎉 <b>ЖНЕЦ-УЛЬТИМАТУМ ЗАВЕРШИЛ РАБОТУ!</b>")
else:
await send_tg_report("❌ Таймаут ожидания логина. Отбой миссии.")
while True: await asyncio.sleep(3600)
if __name__ == "__main__":
asyncio.run(ultimate_mission())