gendesign/backend/app/services/scrapers/izyatie_client.py
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

126 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""HTTP-клиент для раздела «Изъятие для муниципальных нужд» екатеринбург.рф (#1062).
Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/градостроительство/изъятие
Страница содержит список ссылок /file/<md5hash> на PDF-сканы «Сообщений о планируемом
изъятии…» под муниципальные нужды.
TLS: екатеринбург.рф использует российский УЦ → verify=False (как в ekb_geoportal_client.py,
issue #242). Прод-IP имеет доступ к домену (мы уже скрапим РНС/КРТ с того же домена).
"""
from __future__ import annotations
import logging
import re
import urllib.parse
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
# Punycode URL раздела изъятия (ekb.rf → xn--80acgfbsl1azdqr.xn--p1ai).
_IZYATIE_URL = (
"https://xn--80acgfbsl1azdqr.xn--p1ai"
"/%D0%B4%D0%BB%D1%8F%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D1%8B"
"/%D0%B3%D0%B8%D0%B7"
"/%D0%B3%D1%80%D0%B0%D0%B4%D0%BE%D1%81%D1%82%D1%80%D0%BE%D0%B8%D1%82%D0%B5%D0%BB%D1%8C%D1%81%D1%82%D0%B2%D0%BE"
"/%D0%B8%D0%B7%D1%8A%D1%8F%D1%82%D0%B8%D0%B5"
)
_BASE_URL = "https://xn--80acgfbsl1azdqr.xn--p1ai"
# Браузерный User-Agent — домен отдаёт robots.txt только для ботов.
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
# Паттерн file-ссылки — /file/<md5hash> (32 hex-символа).
_RE_FILE_HREF = re.compile(r"^/file/[0-9a-f]{32}$", re.IGNORECASE)
# Ключевые слова для отфильтровки ссылок раздела изъятия.
# Берём все /file/-ссылки на странице: раздел посвящён только изъятию, других PDF там нет.
_IZYATIE_KEYWORDS = re.compile(
r"изъят|планируемом|сообщени|муниципальн|татищева|базовый|комсомольск|"
r"энергостроит|павлодарск|иркутск|щербак|шаумян",
re.IGNORECASE | re.UNICODE,
)
def list_izyatie_documents(url: str = _IZYATIE_URL) -> list[dict[str, str]]:
"""Получает список PDF-документов об изъятии с раздела екатеринбург.рф.
Парсит страницу раздела и возвращает все /file/<hash>-ссылки.
Если страница вернула пустой список — логирует предупреждение (не падает).
Args:
url: URL раздела изъятия (по умолчанию prod-URL).
Returns:
Список словарей {'title': str, 'url': str} — название ссылки и абсолютный URL PDF.
Пустой список при сетевой ошибке или отсутствии ссылок.
"""
try:
with httpx.Client(
timeout=30.0,
verify=False,
follow_redirects=True,
headers={"User-Agent": _UA},
) as client:
resp = client.get(url)
resp.raise_for_status()
html = resp.text
except Exception as exc:
logger.error("list_izyatie_documents: не удалось загрузить страницу %s: %s", url, exc)
return []
soup = BeautifulSoup(html, "html.parser")
docs: list[dict[str, str]] = []
for a_tag in soup.find_all("a", href=True):
href: str = a_tag["href"]
# Берём только /file/<hash>-ссылки.
if not _RE_FILE_HREF.match(href):
continue
# Заголовок: текст якоря → title-атрибут → href как последний фолбэк.
title: str = a_tag.get_text(strip=True) or a_tag.get("title") or href
absolute_url = urllib.parse.urljoin(_BASE_URL, href)
docs.append({"title": title, "url": absolute_url})
if not docs:
logger.warning(
"list_izyatie_documents: не найдено /file/-ссылок на странице %s "
"(возможно, изменилась структура HTML или все документы удалены)",
url,
)
else:
logger.info("list_izyatie_documents: найдено %d документов на %s", len(docs), url)
return docs
def fetch_pdf(url: str) -> bytes:
"""Загружает PDF по URL с екатеринбург.рф.
Args:
url: абсолютный URL PDF-файла (/file/<hash>).
Returns:
Сырые байты PDF.
Raises:
httpx.HTTPError: при ошибке сети или не-200 статусе.
"""
with httpx.Client(
timeout=60.0,
verify=False,
follow_redirects=True,
headers={"User-Agent": _UA},
) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
__all__ = ["fetch_pdf", "list_izyatie_documents"]