🚀 feat: Запуск ЖНЕЦ-УЛЬТИМАТУМ (финальная архитектура)
This commit is contained in:
parent
49723a85d8
commit
36e3e7d347
12 changed files with 1279 additions and 0 deletions
60
scraper_gis/ARCHITECTURE_V4_MASTER_PLAN.md
Normal file
60
scraper_gis/ARCHITECTURE_V4_MASTER_PLAN.md
Normal 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
30
scraper_gis/Dockerfile
Normal 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"]
|
||||||
209
scraper_gis/contracts_scraper.py
Normal file
209
scraper_gis/contracts_scraper.py
Normal 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())
|
||||||
18
scraper_gis/docker-compose.yml
Normal file
18
scraper_gis/docker-compose.yml
Normal 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"]
|
||||||
75
scraper_gis/map_and_auth.py
Normal file
75
scraper_gis/map_and_auth.py
Normal 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())
|
||||||
296
scraper_gis/master_scraper.py
Normal file
296
scraper_gis/master_scraper.py
Normal 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
40
scraper_gis/models.py
Normal 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
110
scraper_gis/recon_agent.py
Normal 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())
|
||||||
6
scraper_gis/requirements.txt
Normal file
6
scraper_gis/requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
playwright
|
||||||
|
sqlalchemy
|
||||||
|
aiosqlite
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
|
aiohttp
|
||||||
77
scraper_gis/scraper.py
Normal file
77
scraper_gis/scraper.py
Normal 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
46
scraper_gis/start.sh
Executable 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
|
||||||
312
scraper_gis/ultimate_reaper.py
Normal file
312
scraper_gis/ultimate_reaper.py
Normal 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())
|
||||||
Loading…
Reference in a new issue