diff --git a/scraper_gis/ARCHITECTURE_V4_MASTER_PLAN.md b/scraper_gis/ARCHITECTURE_V4_MASTER_PLAN.md
new file mode 100644
index 0000000..2b2a2de
--- /dev/null
+++ b/scraper_gis/ARCHITECTURE_V4_MASTER_PLAN.md
@@ -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:** Отчет о начале каждой фазы, о скачивании каждого файла, о завершении миссии.
diff --git a/scraper_gis/Dockerfile b/scraper_gis/Dockerfile
new file mode 100644
index 0000000..1cf75d7
--- /dev/null
+++ b/scraper_gis/Dockerfile
@@ -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"]
diff --git a/scraper_gis/contracts_scraper.py b/scraper_gis/contracts_scraper.py
new file mode 100644
index 0000000..024684c
--- /dev/null
+++ b/scraper_gis/contracts_scraper.py
@@ -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("🤖 Титановый Жнец запущен.\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"🎣 Начинаю выкачку: {contract_name[:50]}...")
+
+ # Скриншот
+ 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"✅ Успешно: Файлов собрано: {len(downloaded_files)}. Возвращаюсь к списку.")
+ else:
+ await send_tg_report(f"⚠️ Внимание: В договоре нет файлов. Скриншот сделан. Иду дальше.")
+
+ # Возвращаемся в список
+ 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"🚀 Цель найдена: {target_text[:40]}...\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())
diff --git a/scraper_gis/docker-compose.yml b/scraper_gis/docker-compose.yml
new file mode 100644
index 0000000..4699ef5
--- /dev/null
+++ b/scraper_gis/docker-compose.yml
@@ -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"]
diff --git a/scraper_gis/map_and_auth.py b/scraper_gis/map_and_auth.py
new file mode 100644
index 0000000..63f7e15
--- /dev/null
+++ b/scraper_gis/map_and_auth.py
@@ -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("🌐 Браузер запущен!\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("✅ Успешный вход!\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"🗺️ Карта ЛК составлена!\n\nНайдено разделов: {len(set(structure))}\nФайл сохранен в Obsidian.")
+
+ except Exception as e:
+ logger.error(f"Error during mapping: {e}")
+ await send_tg_report(f"❌ Ошибка при сканировании ЛК:\n{str(e)}")
+
+ await context.close()
+
+if __name__ == "__main__":
+ asyncio.run(map_personal_account())
diff --git a/scraper_gis/master_scraper.py b/scraper_gis/master_scraper.py
new file mode 100644
index 0000000..fdafeef
--- /dev/null
+++ b/scraper_gis/master_scraper.py
@@ -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("🔍 Инициализация. Проверка сессии.")
+ 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"📂 Захожу в раздел: {section_name}")
+ 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"⏳ Открываю: {txt[:40]}...")
+ 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"🏆 Раздел {section_name} полностью выкачан!")
+
+async def phase_3_contracts(page):
+ await send_tg_report("📂 Захожу в раздел: Договора")
+ 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"⏳ Открываю договор: {txt[:40]}...")
+ 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("🏆 Раздел Договора полностью выкачан!")
+
+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("🚀 ФИНАЛЬНЫЙ АГЕНТ ЗАПУЩЕН. Жду сессию.")
+
+ 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("🎉 ВСЕ ЗАДАЧИ ВЫПОЛНЕНЫ! СВОБОДА!")
+
+ while True: await asyncio.sleep(3600)
+
+if __name__ == "__main__":
+ asyncio.run(run_ultimate_mission())
diff --git a/scraper_gis/models.py b/scraper_gis/models.py
new file mode 100644
index 0000000..adb343c
--- /dev/null
+++ b/scraper_gis/models.py
@@ -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)
diff --git a/scraper_gis/recon_agent.py b/scraper_gis/recon_agent.py
new file mode 100644
index 0000000..5df774e
--- /dev/null
+++ b/scraper_gis/recon_agent.py
@@ -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"⏳ Жду входа... (Попытка {attempt})\nЯ смотрю на экран и не вижу разделов жильца. Проверь файл current_view.png в Обсидиане.")
+
+ await asyncio.sleep(10)
+
+ await send_tg_report(f"🎯 Цель захвачена! Увидел: {found_marker}.\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("🏆 Миссия выполнена!\n\nФайл TOTAL_MAP.md и скриншоты в папке проекта в Obsidian.")
+
+ while True:
+ await asyncio.sleep(3600)
+
+if __name__ == "__main__":
+ asyncio.run(deep_scan())
diff --git a/scraper_gis/requirements.txt b/scraper_gis/requirements.txt
new file mode 100644
index 0000000..ee638f0
--- /dev/null
+++ b/scraper_gis/requirements.txt
@@ -0,0 +1,6 @@
+playwright
+sqlalchemy
+aiosqlite
+python-dotenv
+requests
+aiohttp
diff --git a/scraper_gis/scraper.py b/scraper_gis/scraper.py
new file mode 100644
index 0000000..1758900
--- /dev/null
+++ b/scraper_gis/scraper.py
@@ -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"""
+🏛️ GIS ZHKH Archiver: Stage 1 Complete
+
+✅ Среда Docker подготовлена.
+✅ База данных SQLite инициализирована.
+✅ Коннект к dom.gosuslugi.ru: {status}
+
+🚀 Проект запущен. Следующий этап: реализация Persistent Context для авторизации.
+"""
+ await send_tg_report(report)
+ logger.info("Stage 1 report sent to Telegram.")
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/scraper_gis/start.sh b/scraper_gis/start.sh
new file mode 100755
index 0000000..a8a1a3a
--- /dev/null
+++ b/scraper_gis/start.sh
@@ -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
diff --git a/scraper_gis/ultimate_reaper.py b/scraper_gis/ultimate_reaper.py
new file mode 100644
index 0000000..491a8c8
--- /dev/null
+++ b/scraper_gis/ultimate_reaper.py
@@ -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("🔍 Инициализация.\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("✅ Успех: Я вижу Личный Кабинет! Начинаю работу.")
+ return True
+
+ await asyncio.sleep(20)
+ return False
+
+async def map_dashboard(page):
+ await send_tg_report("🗺️ Начинаю картографирование Личного Кабинета.")
+ 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("✅ Карта составлена и сохранена в Obsidian (reports/Dashboard_Map.md).")
+ await human_delay()
+
+async def process_notifications(page):
+ await send_tg_report("📦 Раздел: Информационные сообщения. Захожу...")
+ 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"✅ Уведомления: Скриншот сделан, скачано {len(files)} файлов.")
+ await human_delay()
+
+async def process_appeals(page):
+ await send_tg_report("✉️ Раздел: Обращения и ответы. Захожу...")
+ 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"⏳ Открываю обращение: {txt[:40]}...")
+ 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("🏆 Раздел Обращений выкачан полностью!")
+
+async def process_contracts(page):
+ await send_tg_report("📑 Раздел: Информация о договорах по дому. Захожу...")
+ 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"⏳ Открываю договор: {txt[:40]}...")
+ 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("🏆 Раздел Договоров выкачан полностью!")
+
+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("🎉 ЖНЕЦ-УЛЬТИМАТУМ ЗАВЕРШИЛ РАБОТУ!")
+ else:
+ await send_tg_report("❌ Таймаут ожидания логина. Отбой миссии.")
+
+ while True: await asyncio.sleep(3600)
+
+if __name__ == "__main__":
+ asyncio.run(ultimate_mission())