Вынос логики предметного указателя в отдельный модуль

This commit is contained in:
Dmitry
2026-04-26 19:50:23 +03:00
parent 56c54f504b
commit 29e1ee26fb
5 changed files with 214 additions and 86 deletions
+77 -4
View File
@@ -7,34 +7,52 @@ from lxml import html
from modules.loader import download_html
START_URL = "https://www.consultant.ru/document/cons_doc_LAW_10699/"
FIRST_ARTICLE_URL = START_URL + "e8ecf933c52a85d9223094e0e7fbf52f0128d399/"
START_URL = "https://www.consultant.ru/document/cons_doc_LAW_10699/" # Базовый адрес УК РФ (откуда мы начинаем обход)
FIRST_ARTICLE_URL = START_URL + "e8ecf933c52a85d9223094e0e7fbf52f0128d399/" # Первая статья (для детерминированности обхода)
ARTICLE_RE = re.compile(r"^\s*(?:УК РФ,?\s+)?Статья\s+\d+(?:\.\d+)?\.", re.IGNORECASE)
ARTICLE_RE = re.compile(r"^\s*(?:УК РФ,?\s+)?Статья\s+\d+(?:\.\d+)?\.", re.IGNORECASE)
STRUCTURE_PREFIX_RE = re.compile(r"^\s*(?:\d+(?:\.\d+)*|[а-яё])[\.)]\s+", re.IGNORECASE)
SPACE_RE = re.compile(r"[ \t\r\f\v]+")
def normalize_url(base_url: str, href: str) -> str:
"""
Преобразует относительный href в абсолютный URL и отрезает фрагмент (#...).
"""
url = urljoin(base_url, href)
url, _ = urldefrag(url)
return url
def document_prefix(url: str) -> str:
"""
Возвращает двухуровневый префикс пути URL — например /document/cons_doc_LAW_10699/.
Используется для проверки, что ссылка ведёт в тот же документ.
"""
parts = [part for part in urlparse(url).path.split("/") if part]
return f"/{parts[0]}/{parts[1]}/"
def is_same_document(url: str, prefix: str) -> bool:
"""
Проверяет, что URL принадлежит тому же документу на consultant.ru.
"""
parsed = urlparse(url)
return parsed.netloc in {"", "consultant.ru", "www.consultant.ru"} and parsed.path.startswith(prefix)
def download_document_page(url: str, allow_partial: bool = False) -> str:
"""
Загружает страницу документа, повторяя запрос с возрастающими таймаутами (8, 30, 30 с).
По умолчанию считает страницу полной только если HTML заканчивается тегом </html>.
allow_partial=True отключает эту проверку (нужно для индексной страницы, которая может
грузиться медленно). Между попытками — пауза 0.5 с.
"""
last_html = ""
for timeout in (8, 30, 30):
for timeout in (8, 15, 30):
try:
page_html = download_html(url, retries=1, timeout=timeout)
except RuntimeError:
@@ -54,6 +72,9 @@ def download_document_page(url: str, allow_partial: bool = False) -> str:
def clean_text(text: str) -> str:
"""
Нормализует пробелы и срезает нумерационные префиксы вида «1. », «а) », «2.1 ».
"""
text = text.replace("\xa0", " ")
text = SPACE_RE.sub(" ", text)
text = STRUCTURE_PREFIX_RE.sub("", text)
@@ -61,11 +82,17 @@ def clean_text(text: str) -> str:
def main_content(tree):
"""
Возвращает корневой div.document-page__content или None, если он не найден.
"""
nodes = tree.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' document-page__content ')]")
return nodes[0] if nodes else None
def article_title(tree) -> str:
"""
Извлекает заголовок статьи: сначала ищет <h1>, fallback — первый div.doc-style.
"""
content = main_content(tree)
if content is None:
return ""
@@ -81,6 +108,12 @@ def article_title(tree) -> str:
def service_line(line: str) -> bool:
"""
Возвращает True для редакционных пометок и реквизитов, которые не нужны в тексте.
Фильтрует: пометки «(в ред. ФЗ ...)», «(введён ...)», «(см. текст ...)»,
подпись президента, дату подписания и номер закона вида «N 63-ФЗ».
"""
low = line.lower()
if low.startswith("(см. текст") or low.startswith("(в ред.") or low.startswith("(введен"):
@@ -100,6 +133,14 @@ def service_line(line: str) -> bool:
def extract_page_text(tree) -> str:
"""
Извлекает смысловой текст статьи из HTML-дерева.
Перед сбором абзацев удаляет из дерева заголовок (h1), блоки doc-style,
doc-insert и doc-roll (вставки, примечания, раскрывающиеся блоки), чтобы
их текст не попал в результат. Затем собирает все <p>, фильтрует служебные
строки через service_line() и возвращает текст через \n.
"""
content = main_content(tree)
if content is None:
return ""
@@ -122,6 +163,12 @@ def extract_page_text(tree) -> str:
def extract_article_urls(tree, base_url: str) -> list[str]:
"""
Собирает ссылки на статьи со страницы — те, чей текст совпадает с ARTICLE_RE.
Возвращает список уникальных URL в порядке появления на странице.
Внешние ссылки (не на тот же документ) отбрасываются.
"""
prefix = document_prefix(base_url)
result = []
@@ -135,6 +182,9 @@ def extract_article_urls(tree, base_url: str) -> list[str]:
def extract_next_document_url(tree, base_url: str) -> str | None:
"""
Возвращает URL следующей страницы через кнопку-стрелку pages__right, или None.
"""
prefix = document_prefix(base_url)
links = tree.xpath("//a[contains(concat(' ', normalize-space(@class), ' '), ' pages__right ')][@href]")
if not links:
@@ -145,6 +195,12 @@ def extract_next_document_url(tree, base_url: str) -> str | None:
def next_known_article(current_url: str, known_urls: list[str], visited: set[str]) -> str | None:
"""
Находит следующую непосещённую статью из known_urls после current_url.
Используется как fallback, когда страница не содержит кнопки pages__right —
например, когда статья занимает ровно одну страницу и стрелки нет.
"""
if current_url not in known_urls:
return None
@@ -157,12 +213,29 @@ def next_known_article(current_url: str, known_urls: list[str], visited: set[str
def add_known_urls(known_urls: list[str], urls: list[str]) -> None:
"""
Добавляет новые URL в known_urls, не нарушая порядок и без дублей."""
for url in urls:
if url not in known_urls:
known_urls.append(url)
def crawl_document(start_url: str = START_URL, max_pages: int = 800) -> list[dict]:
"""
Обходит документ УК РФ и возвращает список статей.
Алгоритм:
1. Загружает индексную страницу и собирает ссылки на все статьи (known_urls).
2. Начинает с первой статьи и идёт по ним в порядке оглавления.
3. На каждой странице:
- добавляет найденные ссылки на статьи в known_urls;
- если заголовок — статья, сохраняет текст;
- переходит на следующую страницу через pages__right или next_known_article().
4. Останавливается на «Статья 361.» (последняя статья УК), по лимиту max_pages
или если следующий URL недоступен.
Возвращает list[dict] с ключами: url, title, text.
"""
pages = []
visited = set()
known_urls = []