Загрузка данных
"""
services/nemezida.py — поиск в базе данных Немезида (nemez1da.ru).
Источник: https://nemez1da.ru — сайт группы RaHDit, содержащий персональные данные
украинских военных, сотрудников спецслужб и силовых структур.
Доступен только с российских IP (vpn_location: "Moscow").
Алгоритм:
1. Переключение VPN на российскую локацию (если vpn_location задан)
2. Поиск через WordPress-поиск /?s=NAME (первая страница, до 20 результатов)
3. Парсинг rel="bookmark" ссылок в зоне posts-wrapper
4. Дополнительная загрузка деталей для совпавших записей (до MAX_DETAIL)
5. Опциональная фильтрация по дате рождения
Поля карточки:
full_name — ФИО (русское + украинское название из заголовка)
category — категория (ГУР, СБУ, СВР и т.д.)
birth_date — дата рождения (DD.MM.YYYY)
position — должность
address — адрес регистрации
drfo — ДРФО (украинский налоговый номер)
passport — номер паспорта
phone — телефон
email — электронная почта
vk — ВКонтакте
activity — деятельность/подразделение
extra — дополнительно
photo_url — прямая ссылка на фото
profile_url — ссылка на страницу профиля
"""
from __future__ import annotations
import asyncio
import logging
import re
import unicodedata
from typing import Any
from urllib.parse import quote
import aiohttp
logger = logging.getLogger(__name__)
_BASE_URL = "https://nemez1da.ru"
_TIMEOUT = aiohttp.ClientTimeout(total=20)
_MAX_DETAIL = 5 # максимум загрузок детальных страниц за один поиск
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
}
# Перевод URL-слагов категорий в читаемые названия
_CATEGORY_SLUGS: dict[str, str] = {
"gur": "ГУР",
"sotrudniki-sbu": "СБУ",
"svr-ukrainy": "СВР Украины",
"inostrannye-naemniki": "Иностранные наёмники",
"inostrannye-posobniki-i-naemniki": "Иностранные пособники и наёмники",
"vsu": "ВСУ",
"uchastniki-ato": "Участники АТО",
"stratkom": "Страткомом ВСУ",
"czipso": "ЦИПСО",
"voennoplennye": "Военнопленные",
"bez-sroka-davnosti": "Без срока давности",
"batalon-revansh": "Батальон Реванш",
}
# Маппинг заголовков полей на ключи результата
_FIELD_MAP: dict[str, str] = {
"Дата рождения": "birth_date",
"Категория": "category",
"Должность": "position",
"Зарегистрирован по адресу": "address",
"ДРФО": "drfo",
"Паспорт": "passport",
"Телефон": "phone",
"Почта": "email",
"Вконтакте": "vk",
"Деятельность": "activity",
"Дополнительно": "extra",
}
# ── Вспомогательные функции ────────────────────────────────────────────────────
def _normalize(text: str) -> set[str]:
"""Возвращает множество слов в верхнем регистре для сравнения имён."""
text = unicodedata.normalize("NFC", text)
return set(re.sub(r"[^А-ЯЁA-Z0-9\s]", "", text.upper()).split())
def _parse_search_results(html: str) -> list[dict]:
"""
Извлекает список записей из страницы поиска WordPress.
Ищет ссылки с rel="bookmark" в зоне posts-wrapper.
Каждый результат: {title, url, category_slug, category}.
"""
start = html.find("simple-grid-posts-wrapper")
end = html.find("simple-grid-featured-posts-area")
zone = html[start:end] if 0 < start < end else html
entries: list[dict] = []
for m in re.finditer(
r'href="(https://nemez1da\.ru/([^"]+))"[^>]*rel="bookmark">([^<]+)</a>',
zone,
):
url, path, title = m.group(1), m.group(2), m.group(3).strip()
if not title:
continue
parts = path.rstrip("/").split("/")
slug = parts[-2] if len(parts) >= 2 else ""
entries.append({
"title": title,
"url": url,
"category_slug": slug,
"category": _CATEGORY_SLUGS.get(slug, slug),
})
return entries
def _parse_person_page(html: str, url: str) -> dict:
"""
Извлекает все поля из страницы профиля человека.
Поля: <div><b>FIELD_NAME</b> VALUE</div>
Фото: href='...' внутри div.photos_single_place
"""
person: dict[str, Any] = {"profile_url": url}
# Фото
photo_m = re.search(
r'class="photos_single_place".*?href=\'([^\']+)\'',
html, re.DOTALL,
)
if photo_m:
person["photo_url"] = photo_m.group(1)
# Зона entry-content
content_m = re.search(
r'<div[^>]*class="entry-content[^"]*"[^>]*>(.*?)<!-- \.entry-content -->',
html, re.DOTALL,
)
if not content_m:
return person
raw = content_m.group(1)
# Все пары <b>FIELD</b> VALUE
for fm in re.finditer(r"<b>([^<]+)</b>(.*?)(?=<b>|<!--)", raw, re.DOTALL):
field_name = fm.group(1).strip()
field_val = re.sub(r"<[^>]+>", " ", fm.group(2))
field_val = re.sub(r"\s+", " ", field_val).strip()
key = _FIELD_MAP.get(field_name)
if key and field_val:
person[key] = field_val
return person
def _match_date(bd_query: str | None, bd_found: str | None) -> bool:
"""
Проверяет совпадение дат рождения.
Принимает DD.MM.YYYY или YYYY-MM-DD в bd_query; bd_found — DD.MM.YYYY.
Возвращает True если совпадение есть или bd_query не задан.
"""
if not bd_query:
return True
if not bd_found:
return False
bd_q = bd_query.strip()
if re.match(r"^\d{4}-\d{2}-\d{2}", bd_q):
p = bd_q[:10].split("-")
bd_q = f"{p[2]}.{p[1]}.{p[0]}"
return bd_q in bd_found
# ── Основная функция поиска ────────────────────────────────────────────────────
async def search_nemezida(
name: str | None,
birth_date: str | None = None,
vpn_location: str | None = None,
) -> dict[str, Any]:
"""
Ищет человека на nemez1da.ru по ФИО и опциональной дате рождения.
Параметры
---------
name : ФИО для поиска (обязательно)
birth_date : дата рождения (DD.MM.YYYY или YYYY-MM-DD), опционально
vpn_location : локация VPN перед запросами (например "Moscow")
Возвращает
----------
{
"status" : "found" | "not_found" | "skipped" | "error",
"matched" : list[dict],
"total" : int, — кол-во совпадений имени на первой странице
"error" : str | None,
}
"""
if not name or not name.strip():
return {"status": "skipped", "matched": [], "total": 0, "error": None}
if vpn_location:
from services.vpn import ensure_location
ok = await ensure_location(vpn_location)
if not ok:
return {
"status": "error", "matched": [], "total": 0,
"error": f"Не удалось переключить VPN на {vpn_location}",
}
query_words = _normalize(name)
try:
async with aiohttp.ClientSession(headers=_HEADERS, timeout=_TIMEOUT) as session:
# ── Шаг 1: поиск ──────────────────────────────────────────────────
search_url = f"{_BASE_URL}/?s={quote(name.strip())}"
async with session.get(search_url) as resp:
resp.raise_for_status()
search_html = await resp.text()
entries = _parse_search_results(search_html)
logger.info("nemezida: поиск '%s' → %d результатов", name, len(entries))
# Все слова запроса должны присутствовать в заголовке записи
candidates = [
e for e in entries
if query_words.issubset(_normalize(e["title"]))
]
if not candidates:
return {"status": "not_found", "matched": [], "total": 0, "error": None}
# ── Шаг 2: загрузка деталей ───────────────────────────────────────
to_fetch = candidates[:_MAX_DETAIL]
tasks = [_fetch_person(session, e) for e in to_fetch]
results = await asyncio.gather(*tasks, return_exceptions=True)
matched: list[dict] = []
for i, result in enumerate(results):
entry = to_fetch[i]
if isinstance(result, Exception):
logger.warning("nemezida: ошибка загрузки %s: %s", entry["url"], result)
person: dict = {
"full_name": entry["title"],
"category": entry["category"],
"profile_url": entry["url"],
}
else:
person = {
"full_name": entry["title"],
# Категория из детальной страницы приоритетнее (полное название)
"category": result.get("category") or entry["category"],
**{k: v for k, v in result.items() if k != "category"},
}
if not _match_date(birth_date, person.get("birth_date")):
continue
matched.append(person)
return {
"status": "found" if matched else "not_found",
"matched": matched,
"total": len(candidates),
"error": None,
}
except Exception as exc:
logger.warning("nemezida: ошибка поиска '%s': %s", name, exc)
return {"status": "error", "matched": [], "total": 0, "error": str(exc)}
async def _fetch_person(session: aiohttp.ClientSession, entry: dict) -> dict:
"""Загружает и парсит детальную страницу профиля."""
async with session.get(entry["url"]) as resp:
resp.raise_for_status()
html = await resp.text()
return _parse_person_page(html, entry["url"])