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())