mirror of
https://github.com/ada-dmitry/CourseWork_IRFM.git
synced 2026-09-24 09:20:19 +00:00
Вынос логики предметного указателя в отдельный модуль
This commit is contained in:
+77
-4
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user