Раздел 2: Источники данных
Проект: Ðвтоматизированный правовой мониторинг Ð´Ð»Ñ e-commerce
Модуль: Lex / Data Sources
ВерÑиÑ: 1.0
Дата: Январь 2026
2.1 Обзор иÑточников данных
Карта иÑточников
Ð¡Ð²Ð¾Ð´Ð½Ð°Ñ Ñ‚Ð°Ð±Ð»Ð¸Ñ†Ð° иÑточников
| ИÑточник | Тип | Метод | ВерÑÐ¸Ñ | Приоритет | ОбÑзательноÑть |
|---|---|---|---|---|---|
| КонÑÑƒÐ»ÑŒÑ‚Ð°Ð½Ñ‚ÐŸÐ»ÑŽÑ | ÐŸÑ€Ð°Ð²Ð¾Ð²Ð°Ñ ÑиÑтема | ПарÑинг | MVP | 1 | ✅ ОбÑзательно |
| Гарант | ÐŸÑ€Ð°Ð²Ð¾Ð²Ð°Ñ ÑиÑтема | ПарÑинг | MVP | 2 | ✅ ОбÑзательно |
| Ð ÑƒÑ‡Ð½Ð°Ñ Ð·Ð°Ð³Ñ€ÑƒÐ·ÐºÐ° (URL) | ПользовательÑкий ввод | HTTP Fetch | MVP | — | ✅ ОбÑзательно |
| Ð ÑƒÑ‡Ð½Ð°Ñ Ð·Ð°Ð³Ñ€ÑƒÐ·ÐºÐ° (файл) | ПользовательÑкий ввод | File Parse | MVP | — | ✅ ОбÑзательно |
| РоÑÑийÑÐºÐ°Ñ Ð³Ð°Ð·ÐµÑ‚Ð° | Официальное издание | ПарÑинг | v2.0 | 3 | Желательно |
| pravo.gov.ru | Портал правовой информации | API/ПарÑинг | v2.0 | 4 | Желательно |
| ФÐС (nalog.gov.ru) | ГоÑорган | ПарÑинг | v2.0 | 5 | Опционально |
| РоÑпотребнадзор | ГоÑорган | ПарÑинг | v2.0 | 6 | Опционально |
| РоÑÐ°ÐºÐºÑ€ÐµÐ´Ð¸Ñ‚Ð°Ñ†Ð¸Ñ | ГоÑорган | ПарÑинг | v2.0 | 7 | Опционально |
| ЧеÑтный ЗÐÐК | СиÑтема маркировки | ПарÑинг | v2.0 | 8 | Опционально |
Типы Ñобираемых документов
| Тип документа | Код | ИÑточники | ОпиÑание |
|---|---|---|---|
| Федеральный закон | federal_law | КонÑультантПлюÑ, Гарант | ФЗ, принÑтые ГоÑдумой |
| Изменение ÐПР| amendment | КонÑультантПлюÑ, Гарант | Поправки в дейÑтвующие законы |
| ПоÑтановление | decree | КонÑультантПлюÑ, Гарант | ПоÑÑ‚Ð°Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ ÐŸÑ€Ð°Ð²Ð¸Ñ‚ÐµÐ»ÑŒÑтва РФ |
| Судебное решение | court_decision | КонÑультантПлюÑ, Гарант | Ð ÐµÑˆÐµÐ½Ð¸Ñ Ñудов по e-commerce |
| РазъÑÑнение | clarification | КонÑультантПлюÑ, Гарант | ПиÑьма ФÐС, Минпромторга |
| Стандарт | standard | КонÑультантПлюÑ, Гарант | ГОСТы, техничеÑкие регламенты |
2.2 КонÑультантПлюÑ
2.2.1 ÐžÐ±Ñ‰Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ
| Параметр | Значение |
|---|---|
| Ðазвание | КонÑÑƒÐ»ÑŒÑ‚Ð°Ð½Ñ‚ÐŸÐ»ÑŽÑ |
| URL | https://www.consultant.ru |
| Тип доÑтупа | Публичный (беÑплатные разделы) |
| Метод Ñбора | ПарÑинг через Watcher Agents |
| ЧаÑтота | Ежедневно (21:00–07:00) |
2.2.2 Структура Ñайта
2.2.3 Точки входа Ð´Ð»Ñ Ð¿Ð°Ñ€Ñинга
| Раздел | URL | ЧаÑтота Ð¾Ð±Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ | Приоритет |
|---|---|---|---|
| ГорÑчие документы | /hotdocs/ | Ежедневно | 1 |
| Ðовые документы | /new/ | Ежедневно | 2 |
| Федеральные законы | /document/cons_doc_LAW/ | По Ñобытию | 3 |
| Ð¡ÑƒÐ´ÐµÐ±Ð½Ð°Ñ Ð¿Ñ€Ð°ÐºÑ‚Ð¸ÐºÐ° | /cons/cgi/online.cgi?req=card | Еженедельно | 4 |
| ПиÑьма ФÐС | /document/cons_doc_QUEST/ | Еженедельно | 5 |
2.2.4 Селекторы Ð´Ð»Ñ Ð¸Ð·Ð²Ð»ÐµÑ‡ÐµÐ½Ð¸Ñ Ð´Ð°Ð½Ð½Ñ‹Ñ…
python
# consultant_plus_selectors.py
SELECTORS = {
# СпиÑок документов (Ñтраница новоÑтей)
"document_list": {
"container": "div.news-list, div.hot-docs-list",
"item": "div.news-item, div.hot-doc-item",
"title": "a.title, h3 a",
"date": "span.date, div.doc-date",
"link": "a.title::attr(href), h3 a::attr(href)"
},
# Карточка документа
"document_card": {
"title": "h1.document-title, div.doc-header h1",
"number": "div.doc-number, span.requisites",
"date": "div.doc-date, span.doc-date",
"effective_date": "div.entry-into-force, span.effective-date",
"status": "div.doc-status, span.status",
"category": "div.doc-rubric, span.category",
"text_container": "div.document-text, div.doc-body",
"related_docs": "div.related-documents a"
},
# Метаданные
"metadata": {
"issuer": "div.issuer, span.organ",
"doc_type": "div.doc-type, span.type",
"keywords": "div.keywords, meta[name='keywords']::attr(content)"
}
}2.2.5 Пример задачи парÑинга
json
{
"task_id": "lex_cp_001",
"task_type": "lex_parse",
"source": "consultant_plus",
"url": "https://www.consultant.ru/document/cons_doc_LAW_XXX/",
"created_at": "2026-01-20T20:30:00Z",
"priority": 2,
"metadata": {
"entry_point": "hot_docs",
"expected_type": "federal_law"
}
}2.2.6 Пример результата парÑинга
json
{
"task_id": "lex_cp_001",
"status": "completed",
"source": "consultant_plus",
"url": "https://www.consultant.ru/document/cons_doc_LAW_XXX/",
"parsed_at": "2026-01-20T23:45:00Z",
"raw_data": {
"title": "Федеральный закон от 15.12.2025 N 500-ФЗ \"О внеÑении изменений в Закон РоÑÑийÑкой Федерации \"О защите прав потребителей\"",
"number": "500-ФЗ",
"date": "2025-12-15",
"effective_date": "2026-03-01",
"status": "ДейÑтвующий",
"issuer": "ГоÑударÑÑ‚Ð²ÐµÐ½Ð½Ð°Ñ Ð”ÑƒÐ¼Ð°",
"doc_type": "Федеральный закон",
"category": "Защита прав потребителей",
"text": "[Полный текÑÑ‚ документа...]",
"text_length": 45230,
"related_docs": [
{
"title": "Закон РФ \"О защите прав потребителей\"",
"url": "https://www.consultant.ru/document/cons_doc_LAW_305/"
}
],
"keywords": ["защита прав потребителей", "возврат товара", "маркетплейÑ"]
},
"extraction_metadata": {
"parser_version": "1.0",
"extraction_time_ms": 1250,
"selectors_used": ["document_card", "metadata"]
}
}2.3 Гарант
2.3.1 ÐžÐ±Ñ‰Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ
| Параметр | Значение |
|---|---|
| Ðазвание | Гарант |
| URL | https://www.garant.ru |
| Тип доÑтупа | Публичный (беÑплатные разделы) |
| Метод Ñбора | ПарÑинг через Watcher Agents |
| ЧаÑтота | Ежедневно (21:00–07:00) |
2.3.2 Структура Ñайта
2.3.3 Точки входа Ð´Ð»Ñ Ð¿Ð°Ñ€Ñинга
| Раздел | URL | ЧаÑтота Ð¾Ð±Ð½Ð¾Ð²Ð»ÐµÐ½Ð¸Ñ | Приоритет |
|---|---|---|---|
| ÐовоÑти права | /news/ | Ежедневно | 1 |
| ГорÑчие темы | /hot/ | Ежедневно | 2 |
| Федеральные законы | /products/ipo/prime/doc/ | По Ñобытию | 3 |
| Ð¡ÑƒÐ´ÐµÐ±Ð½Ð°Ñ Ð¿Ñ€Ð°ÐºÑ‚Ð¸ÐºÐ° | /products/ipo/prime/doc/court/ | Еженедельно | 4 |
2.3.4 Селекторы Ð´Ð»Ñ Ð¸Ð·Ð²Ð»ÐµÑ‡ÐµÐ½Ð¸Ñ Ð´Ð°Ð½Ð½Ñ‹Ñ…
python
# garant_selectors.py
SELECTORS = {
# СпиÑок документов
"document_list": {
"container": "div.news-list, ul.document-list",
"item": "li.news-item, div.doc-item",
"title": "a.doc-title, h3 a",
"date": "span.pub-date, div.date",
"link": "a.doc-title::attr(href)"
},
# Карточка документа
"document_card": {
"title": "h1.doc-title, div.document-header h1",
"number": "div.doc-requisites span.number",
"date": "div.doc-requisites span.date",
"effective_date": "div.effective-info span.date",
"status": "div.doc-status span",
"category": "div.doc-category a",
"text_container": "div.document-content, div.doc-text",
"source_org": "div.source-organization"
},
# Метаданные
"metadata": {
"issuer": "div.issuing-authority",
"doc_type": "div.document-type",
"tags": "div.doc-tags a"
}
}2.3.5 Пример задачи парÑинга
json
{
"task_id": "lex_gr_001",
"task_type": "lex_parse",
"source": "garant",
"url": "https://www.garant.ru/products/ipo/prime/doc/XXXXXXX/",
"created_at": "2026-01-20T20:30:00Z",
"priority": 2,
"metadata": {
"entry_point": "news",
"expected_type": "clarification"
}
}2.3.6 Пример результата парÑинга
json
{
"task_id": "lex_gr_001",
"status": "completed",
"source": "garant",
"url": "https://www.garant.ru/products/ipo/prime/doc/XXXXXXX/",
"parsed_at": "2026-01-20T23:50:00Z",
"raw_data": {
"title": "ПиÑьмо ФÐС РоÑÑии от 10.01.2026 N БС-4-11/123@ \"О налогообложении доходов от продаж на маркетплейÑах\"",
"number": "БС-4-11/123@",
"date": "2026-01-10",
"effective_date": null,
"status": "ДейÑтвующий",
"issuer": "ФÐС РоÑÑии",
"doc_type": "ПиÑьмо",
"category": "Ðалогообложение",
"text": "[Полный текÑÑ‚ документа...]",
"text_length": 12500,
"tags": ["налоги", "маркетплейÑ", "ÐДФЛ", "УСÐ"]
},
"extraction_metadata": {
"parser_version": "1.0",
"extraction_time_ms": 980,
"selectors_used": ["document_card", "metadata"]
}
}2.4 Ð ÑƒÑ‡Ð½Ð°Ñ Ð·Ð°Ð³Ñ€ÑƒÐ·ÐºÐ° документов
2.4.1 Загрузка по URL
Поддерживаемые домены (whitelist):
| Домен | ИÑточник |
|---|---|
consultant.ru | КонÑÑƒÐ»ÑŒÑ‚Ð°Ð½Ñ‚ÐŸÐ»ÑŽÑ |
www.consultant.ru | КонÑÑƒÐ»ÑŒÑ‚Ð°Ð½Ñ‚ÐŸÐ»ÑŽÑ |
garant.ru | Гарант |
www.garant.ru | Гарант |
pravo.gov.ru | Официальный портал (v2.0) |
publication.pravo.gov.ru | Официальное опубликование (v2.0) |
ПроцеÑÑ Ð·Ð°Ð³Ñ€ÑƒÐ·ÐºÐ¸:
Пример запроÑа:
http
POST /api/v1/lex/upload
Authorization: Bearer {token}
Content-Type: application/json
{
"url": "https://www.consultant.ru/document/cons_doc_LAW_XXX/"
}Пример ответа:
json
{
"success": true,
"document_id": 456,
"source": "consultant_plus",
"title": "Федеральный закон от 15.12.2025 N 500-ФЗ",
"relevance_score": 0.87,
"relevance_level": "high",
"category": "consumer_rights",
"summary": "Закон вноÑит Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ Ð² правила возврата товаров..."
}2.4.2 Загрузка файла
Поддерживаемые форматы:
| Формат | MIME Type | МакÑимальный размер | Метод парÑинга |
|---|---|---|---|
application/pdf | 10 MB | PyMuPDF + OCR (при необходимоÑти) | |
| DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document | 10 MB | python-docx |
| DOC | application/msword | 10 MB | antiword / LibreOffice |
| TXT | text/plain | 5 MB | ПрÑмое чтение |
| RTF | application/rtf | 10 MB | striprtf |
ПроцеÑÑ Ð·Ð°Ð³Ñ€ÑƒÐ·ÐºÐ¸:
Пример запроÑа:
http
POST /api/v1/lex/upload
Authorization: Bearer {token}
Content-Type: multipart/form-data
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="law_500fz.pdf"
Content-Type: application/pdf
[Binary PDF content]
------WebKitFormBoundary--Пример ответа:
json
{
"success": true,
"document_id": 457,
"source": "manual_upload",
"filename": "law_500fz.pdf",
"title": "О внеÑении изменений в Закон о защите прав потребителей",
"relevance_score": 0.92,
"relevance_level": "high",
"category": "consumer_rights",
"summary": "Документ уÑтанавливает новые правила...",
"file_metadata": {
"pages": 15,
"size_bytes": 245780,
"has_ocr": false
}
}2.5 Ðдаптеры иÑточников
2.5.1 Базовый клаÑÑ Ð°Ð´Ð°Ð¿Ñ‚ÐµÑ€Ð°
python
# adapters/base.py
from abc import ABC, abstractmethod
from typing import List, Dict, Optional
from dataclasses import dataclass
from datetime import datetime
@dataclass
class RawDocument:
"""Сырой документ поÑле парÑинга."""
source: str
url: str
title: str
number: Optional[str]
date: Optional[datetime]
effective_date: Optional[datetime]
status: str
issuer: Optional[str]
doc_type: str
category: Optional[str]
text: str
text_length: int
related_docs: List[Dict]
keywords: List[str]
raw_html: str
parsed_at: datetime
@dataclass
class TaskDefinition:
"""Определение задачи Ð´Ð»Ñ Ð¿Ð°Ñ€Ñинга."""
task_type: str = "lex_parse"
source: str = ""
url: str = ""
priority: int = 5
metadata: Dict = None
class BaseSourceAdapter(ABC):
"""Базовый клаÑÑ Ð°Ð´Ð°Ð¿Ñ‚ÐµÑ€Ð° иÑточника данных."""
source_name: str = ""
base_url: str = ""
@abstractmethod
async def generate_tasks(self) -> List[TaskDefinition]:
"""Ð“ÐµÐ½ÐµÑ€Ð°Ñ†Ð¸Ñ Ð·Ð°Ð´Ð°Ñ‡ Ð´Ð»Ñ Ð¿Ð°Ñ€Ñинга новых документов."""
pass
@abstractmethod
async def parse_document(self, html: str, url: str) -> RawDocument:
"""ПарÑинг HTML-Ñтраницы документа."""
pass
@abstractmethod
def get_entry_points(self) -> List[Dict]:
"""Получение точек входа Ð´Ð»Ñ Ð¼Ð¾Ð½Ð¸Ñ‚Ð¾Ñ€Ð¸Ð½Ð³Ð°."""
pass
async def fetch_entry_point(self, entry_point: Dict) -> List[str]:
"""Получение ÑпиÑка URL документов Ñ Ñ‚Ð¾Ñ‡ÐºÐ¸ входа."""
# РеализуетÑÑ Ð² конкретных адаптерах
pass2.5.2 Ðдаптер КонÑультантПлюÑ
python
# adapters/consultant_plus.py
from .base import BaseSourceAdapter, RawDocument, TaskDefinition
from .selectors import CONSULTANT_SELECTORS
from typing import List, Dict
from bs4 import BeautifulSoup
from datetime import datetime
import re
class ConsultantPlusAdapter(BaseSourceAdapter):
"""Ðдаптер Ð´Ð»Ñ ÐšÐ¾Ð½ÑультантПлюÑ."""
source_name = "consultant_plus"
base_url = "https://www.consultant.ru"
def get_entry_points(self) -> List[Dict]:
"""Точки входа Ð´Ð»Ñ Ð¼Ð¾Ð½Ð¸Ñ‚Ð¾Ñ€Ð¸Ð½Ð³Ð°."""
return [
{
"name": "hot_docs",
"url": f"{self.base_url}/hotdocs/",
"priority": 1,
"frequency": "daily"
},
{
"name": "new_docs",
"url": f"{self.base_url}/new/",
"priority": 2,
"frequency": "daily"
},
{
"name": "fns_letters",
"url": f"{self.base_url}/document/cons_doc_QUEST/",
"priority": 3,
"frequency": "weekly"
}
]
async def generate_tasks(self) -> List[TaskDefinition]:
"""Ð“ÐµÐ½ÐµÑ€Ð°Ñ†Ð¸Ñ Ð·Ð°Ð´Ð°Ñ‡ на оÑнове точек входа."""
tasks = []
for entry_point in self.get_entry_points():
# Задача на парÑинг точки входа (получение ÑпиÑка документов)
tasks.append(TaskDefinition(
source=self.source_name,
url=entry_point["url"],
priority=entry_point["priority"],
metadata={
"task_subtype": "list_documents",
"entry_point": entry_point["name"]
}
))
return tasks
async def parse_document_list(self, html: str) -> List[str]:
"""ПарÑинг ÑпиÑка документов Ñ Ñ‚Ð¾Ñ‡ÐºÐ¸ входа."""
soup = BeautifulSoup(html, 'lxml')
urls = []
selectors = CONSULTANT_SELECTORS["document_list"]
container = soup.select_one(selectors["container"])
if container:
items = container.select(selectors["item"])
for item in items:
link = item.select_one(selectors["link"])
if link and link.get("href"):
url = link["href"]
if not url.startswith("http"):
url = f"{self.base_url}{url}"
urls.append(url)
return urls
async def parse_document(self, html: str, url: str) -> RawDocument:
"""ПарÑинг Ñтраницы документа."""
soup = BeautifulSoup(html, 'lxml')
selectors = CONSULTANT_SELECTORS["document_card"]
meta_selectors = CONSULTANT_SELECTORS["metadata"]
# Извлечение оÑновных полей
title = self._extract_text(soup, selectors["title"])
number = self._extract_text(soup, selectors["number"])
date_str = self._extract_text(soup, selectors["date"])
effective_date_str = self._extract_text(soup, selectors["effective_date"])
status = self._extract_text(soup, selectors["status"]) or "ДейÑтвующий"
category = self._extract_text(soup, selectors["category"])
# Извлечение метаданных
issuer = self._extract_text(soup, meta_selectors["issuer"])
doc_type = self._extract_text(soup, meta_selectors["doc_type"]) or self._detect_doc_type(title)
keywords = self._extract_keywords(soup, meta_selectors["keywords"])
# Извлечение текÑта документа
text_container = soup.select_one(selectors["text_container"])
text = text_container.get_text(separator="\n", strip=True) if text_container else ""
# СвÑзанные документы
related_docs = self._extract_related_docs(soup, selectors.get("related_docs"))
return RawDocument(
source=self.source_name,
url=url,
title=title,
number=number,
date=self._parse_date(date_str),
effective_date=self._parse_date(effective_date_str),
status=status,
issuer=issuer,
doc_type=doc_type,
category=category,
text=text,
text_length=len(text),
related_docs=related_docs,
keywords=keywords,
raw_html=html,
parsed_at=datetime.utcnow()
)
def _extract_text(self, soup: BeautifulSoup, selector: str) -> str:
"""Извлечение текÑта по Ñелектору."""
if not selector:
return ""
for sel in selector.split(", "):
element = soup.select_one(sel)
if element:
return element.get_text(strip=True)
return ""
def _parse_date(self, date_str: str) -> datetime:
"""ПарÑинг даты из Ñтроки."""
if not date_str:
return None
patterns = [
r"(\d{2})\.(\d{2})\.(\d{4})", # DD.MM.YYYY
r"(\d{4})-(\d{2})-(\d{2})", # YYYY-MM-DD
]
for pattern in patterns:
match = re.search(pattern, date_str)
if match:
groups = match.groups()
if len(groups[0]) == 4: # YYYY-MM-DD
return datetime(int(groups[0]), int(groups[1]), int(groups[2]))
else: # DD.MM.YYYY
return datetime(int(groups[2]), int(groups[1]), int(groups[0]))
return None
def _detect_doc_type(self, title: str) -> str:
"""Определение типа документа по заголовку."""
title_lower = title.lower()
if "федеральный закон" in title_lower:
return "federal_law"
elif "поÑтановление" in title_lower:
return "decree"
elif "пиÑьмо" in title_lower:
return "clarification"
elif "решение" in title_lower and ("Ñуд" in title_lower or "арбитраж" in title_lower):
return "court_decision"
elif "гоÑÑ‚" in title_lower or "Ñтандарт" in title_lower:
return "standard"
elif "изменени" in title_lower:
return "amendment"
return "other"
def _extract_keywords(self, soup: BeautifulSoup, selector: str) -> List[str]:
"""Извлечение ключевых Ñлов."""
keywords = []
if selector:
for sel in selector.split(", "):
if "::attr" in sel:
base_sel, attr = sel.split("::attr(")
attr = attr.rstrip(")")
element = soup.select_one(base_sel)
if element and element.get(attr):
keywords.extend([k.strip() for k in element[attr].split(",")])
else:
elements = soup.select(sel)
for el in elements:
keywords.append(el.get_text(strip=True))
return list(set(keywords))
def _extract_related_docs(self, soup: BeautifulSoup, selector: str) -> List[Dict]:
"""Извлечение ÑвÑзанных документов."""
related = []
if selector:
elements = soup.select(selector)
for el in elements[:10]: # Ограничение до 10
href = el.get("href", "")
if not href.startswith("http"):
href = f"{self.base_url}{href}"
related.append({
"title": el.get_text(strip=True),
"url": href
})
return related2.5.3 Ðдаптер Гарант
python
# adapters/garant.py
from .base import BaseSourceAdapter, RawDocument, TaskDefinition
from .selectors import GARANT_SELECTORS
from typing import List, Dict
from bs4 import BeautifulSoup
from datetime import datetime
class GarantAdapter(BaseSourceAdapter):
"""Ðдаптер Ð´Ð»Ñ Ð“Ð°Ñ€Ð°Ð½Ñ‚."""
source_name = "garant"
base_url = "https://www.garant.ru"
def get_entry_points(self) -> List[Dict]:
"""Точки входа Ð´Ð»Ñ Ð¼Ð¾Ð½Ð¸Ñ‚Ð¾Ñ€Ð¸Ð½Ð³Ð°."""
return [
{
"name": "news",
"url": f"{self.base_url}/news/",
"priority": 1,
"frequency": "daily"
},
{
"name": "hot",
"url": f"{self.base_url}/hot/",
"priority": 2,
"frequency": "daily"
},
{
"name": "federal_law",
"url": f"{self.base_url}/products/ipo/prime/doc/",
"priority": 3,
"frequency": "weekly"
}
]
async def generate_tasks(self) -> List[TaskDefinition]:
"""Ð“ÐµÐ½ÐµÑ€Ð°Ñ†Ð¸Ñ Ð·Ð°Ð´Ð°Ñ‡ на оÑнове точек входа."""
tasks = []
for entry_point in self.get_entry_points():
tasks.append(TaskDefinition(
source=self.source_name,
url=entry_point["url"],
priority=entry_point["priority"],
metadata={
"task_subtype": "list_documents",
"entry_point": entry_point["name"]
}
))
return tasks
async def parse_document(self, html: str, url: str) -> RawDocument:
"""ПарÑинг Ñтраницы документа."""
soup = BeautifulSoup(html, 'lxml')
selectors = GARANT_SELECTORS["document_card"]
meta_selectors = GARANT_SELECTORS["metadata"]
title = self._extract_text(soup, selectors["title"])
number = self._extract_text(soup, selectors["number"])
date_str = self._extract_text(soup, selectors["date"])
effective_date_str = self._extract_text(soup, selectors["effective_date"])
status = self._extract_text(soup, selectors["status"]) or "ДейÑтвующий"
category = self._extract_text(soup, selectors["category"])
issuer = self._extract_text(soup, meta_selectors["issuer"])
doc_type = self._extract_text(soup, meta_selectors["doc_type"]) or self._detect_doc_type(title)
keywords = self._extract_tags(soup, meta_selectors["tags"])
text_container = soup.select_one(selectors["text_container"])
text = text_container.get_text(separator="\n", strip=True) if text_container else ""
return RawDocument(
source=self.source_name,
url=url,
title=title,
number=number,
date=self._parse_date(date_str),
effective_date=self._parse_date(effective_date_str),
status=status,
issuer=issuer,
doc_type=doc_type,
category=category,
text=text,
text_length=len(text),
related_docs=[],
keywords=keywords,
raw_html=html,
parsed_at=datetime.utcnow()
)
# ... аналогичные вÑпомогательные методы2.6 Ð“ÐµÐ½ÐµÑ€Ð°Ñ†Ð¸Ñ Ð·Ð°Ð´Ð°Ñ‡
2.6.1 ПроцеÑÑ Ð³ÐµÐ½ÐµÑ€Ð°Ñ†Ð¸Ð¸
2.6.2 Код генератора задач
python
# services/task_generator.py
from typing import List, Dict
from datetime import datetime, timedelta
from adapters import ConsultantPlusAdapter, GarantAdapter
from models import LexSource, LexTaskLog
from core.redis import redis_client
from core.database import db_session
import json
class LexTaskGenerator:
"""Генератор задач Ð´Ð»Ñ Ð¿Ð°Ñ€Ñинга правовых иÑточников."""
ADAPTERS = {
"consultant_plus": ConsultantPlusAdapter,
"garant": GarantAdapter
}
QUEUE_NAME = "task_queue:lex"
async def generate_tasks(self) -> Dict:
"""Ð“ÐµÐ½ÐµÑ€Ð°Ñ†Ð¸Ñ Ð²Ñех задач на текущую ночь."""
stats = {
"sources_processed": 0,
"tasks_created": 0,
"tasks_skipped": 0,
"errors": []
}
# Получение активных иÑточников
sources = await self._get_enabled_sources()
for source in sources:
try:
adapter_class = self.ADAPTERS.get(source.adapter_name)
if not adapter_class:
stats["errors"].append(f"Unknown adapter: {source.adapter_name}")
continue
adapter = adapter_class()
tasks = await adapter.generate_tasks()
for task in tasks:
if await self._should_process(task.url):
await self._enqueue_task(task)
stats["tasks_created"] += 1
else:
stats["tasks_skipped"] += 1
stats["sources_processed"] += 1
except Exception as e:
stats["errors"].append(f"{source.name}: {str(e)}")
return stats
async def _get_enabled_sources(self) -> List[LexSource]:
"""Получение активных иÑточников."""
async with db_session() as session:
result = await session.execute(
"SELECT * FROM lex_sources WHERE enabled = true ORDER BY priority"
)
return result.fetchall()
async def _should_process(self, url: str) -> bool:
"""Проверка, нужно ли обрабатывать URL."""
today = datetime.utcnow().date()
async with db_session() as session:
result = await session.execute(
"""
SELECT COUNT(*) FROM lex_task_log
WHERE url = :url
AND DATE(created_at) = :today
AND status IN ('completed', 'in_progress')
""",
{"url": url, "today": today}
)
count = result.scalar()
return count == 0
async def _enqueue_task(self, task) -> None:
"""Добавление задачи в очередь."""
task_data = {
"task_id": f"lex_{task.source}_{datetime.utcnow().timestamp()}",
"task_type": task.task_type,
"source": task.source,
"url": task.url,
"priority": task.priority,
"metadata": task.metadata or {},
"created_at": datetime.utcnow().isoformat()
}
await redis_client.rpush(self.QUEUE_NAME, json.dumps(task_data))
# Логирование
async with db_session() as session:
await session.execute(
"""
INSERT INTO lex_task_log (task_id, source, url, status, created_at)
VALUES (:task_id, :source, :url, 'queued', :created_at)
""",
{
"task_id": task_data["task_id"],
"source": task.source,
"url": task.url,
"created_at": datetime.utcnow()
}
)
await session.commit()2.7 Обработка результатов парÑинга
2.7.1 Pipeline обработки
2.7.2 Код обработчика результатов
python
# services/result_processor.py
from typing import Optional, Dict
from dataclasses import dataclass
from adapters.base import RawDocument
from services.ai_filter import AIFilter
from services.ai_classifier import AIClassifier
from services.ai_summarizer import AISummarizer
from services.document_formatter import DocumentFormatter
from services.alert_engine import AlertEngine
from core.knowledge_base import kb_client
from core.database import db_session
from models import LexDocument, LexAlert
@dataclass
class ProcessingResult:
"""Результат обработки документа."""
success: bool
document_id: Optional[int] = None
reject_reason: Optional[str] = None
error: Optional[str] = None
class ResultProcessor:
"""Обработчик результатов парÑинга."""
def __init__(self):
self.ai_filter = AIFilter()
self.ai_classifier = AIClassifier()
self.ai_summarizer = AISummarizer()
self.formatter = DocumentFormatter()
self.alert_engine = AlertEngine()
async def process(self, raw_document: RawDocument) -> ProcessingResult:
"""Обработка ÑпарÑенного документа."""
# 1. ВалидациÑ
validation_error = self._validate(raw_document)
if validation_error:
return ProcessingResult(success=False, error=validation_error)
# 2. Проверка дубликатов
if await self._is_duplicate(raw_document):
return ProcessingResult(success=False, reject_reason="duplicate")
# 3. AI-фильтрациÑ
relevance = await self.ai_filter.evaluate(raw_document.text)
if not relevance.is_relevant:
await self._log_rejected(raw_document, relevance.score)
return ProcessingResult(
success=False,
reject_reason=f"low_relevance ({relevance.score:.2f})"
)
# 4. AI-клаÑÑификациÑ
classification = await self.ai_classifier.classify(
raw_document.text,
raw_document.title
)
# 5. AI-резюмирование
summary = await self.ai_summarizer.summarize(
raw_document.text,
raw_document.title,
classification.category
)
# 6. Формирование Markdown
markdown = self.formatter.format(
raw_document=raw_document,
classification=classification,
summary=summary,
relevance_score=relevance.score
)
# 7. Загрузка в Knowledge Base
kb_id = await kb_client.upload(
content=markdown,
metadata={
"source": raw_document.source,
"category": classification.category,
"access_level": "manager",
"brand_id": "shared"
}
)
# 8. Сохранение метаданных
document_id = await self._save_document(
raw_document=raw_document,
classification=classification,
summary=summary,
relevance_score=relevance.score,
kb_id=kb_id
)
# 9. Создание алерта
await self.alert_engine.create_alert(
document_id=document_id,
title=raw_document.title,
category=classification.category,
relevance=classification.relevance_level,
summary=summary.short_summary
)
return ProcessingResult(success=True, document_id=document_id)
def _validate(self, doc: RawDocument) -> Optional[str]:
"""Ð’Ð°Ð»Ð¸Ð´Ð°Ñ†Ð¸Ñ Ð´Ð¾ÐºÑƒÐ¼ÐµÐ½Ñ‚Ð°."""
if not doc.text or len(doc.text) < 100:
return "Text too short or empty"
if not doc.title:
return "Title is missing"
if len(doc.text) > 1_000_000:
return "Text too long (>1MB)"
return None
async def _is_duplicate(self, doc: RawDocument) -> bool:
"""Проверка на дубликат."""
async with db_session() as session:
result = await session.execute(
"""
SELECT COUNT(*) FROM lex_documents
WHERE (url = :url OR document_number = :number)
AND source = :source
""",
{
"url": doc.url,
"number": doc.number,
"source": doc.source
}
)
return result.scalar() > 0
async def _log_rejected(self, doc: RawDocument, score: float) -> None:
"""Логирование отклонённого документа."""
async with db_session() as session:
await session.execute(
"""
INSERT INTO lex_statistics
(date, source, action, count, metadata)
VALUES (CURRENT_DATE, :source, 'rejected', 1, :metadata)
ON CONFLICT (date, source, action)
DO UPDATE SET count = lex_statistics.count + 1
""",
{
"source": doc.source,
"metadata": {"url": doc.url, "score": score}
}
)
await session.commit()
async def _save_document(self, **kwargs) -> int:
"""Сохранение документа в БД."""
async with db_session() as session:
result = await session.execute(
"""
INSERT INTO lex_documents (
source, url, title, document_number, document_date,
effective_date, doc_type, category, relevance_level,
relevance_score, summary, kb_id, created_at
) VALUES (
:source, :url, :title, :number, :date,
:effective_date, :doc_type, :category, :relevance_level,
:relevance_score, :summary, :kb_id, :created_at
) RETURNING id
""",
{
"source": kwargs["raw_document"].source,
"url": kwargs["raw_document"].url,
"title": kwargs["raw_document"].title,
"number": kwargs["raw_document"].number,
"date": kwargs["raw_document"].date,
"effective_date": kwargs["raw_document"].effective_date,
"doc_type": kwargs["classification"].doc_type,
"category": kwargs["classification"].category,
"relevance_level": kwargs["classification"].relevance_level,
"relevance_score": kwargs["relevance_score"],
"summary": kwargs["summary"].full_summary,
"kb_id": kwargs["kb_id"],
"created_at": kwargs["raw_document"].parsed_at
}
)
await session.commit()
return result.scalar()2.8 ИÑточники v2.0 (планы)
2.8.1 РоÑÑийÑÐºÐ°Ñ Ð³Ð°Ð·ÐµÑ‚Ð°
| Параметр | Значение |
|---|---|
| URL | https://rg.ru |
| Раздел | /official-documents/ |
| Тип | Официальные публикации |
| ЧаÑтота | Ежедневно |
2.8.2 pravo.gov.ru
| Параметр | Значение |
|---|---|
| URL | http://pravo.gov.ru, http://publication.pravo.gov.ru |
| API | Возможно наличие API |
| Тип | Официальное опубликование |
| ЧаÑтота | Ежедневно |
2.8.3 Сайты гоÑорганов
| ИÑточник | URL | Разделы |
|---|---|---|
| ФÐС | nalog.gov.ru | ПиÑьма, разъÑÑÐ½ÐµÐ½Ð¸Ñ |
| РоÑпотребнадзор | rospotrebnadzor.ru | Ð˜Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ Ð´Ð»Ñ Ð¿Ð¾Ñ‚Ñ€ÐµÐ±Ð¸Ñ‚ÐµÐ»ÐµÐ¹ |
| РоÑÐ°ÐºÐºÑ€ÐµÐ´Ð¸Ñ‚Ð°Ñ†Ð¸Ñ | fsa.gov.ru | Маркировка, ÑÐµÑ€Ñ‚Ð¸Ñ„Ð¸ÐºÐ°Ñ†Ð¸Ñ |
| ЧеÑтный ЗÐÐК | чеÑтныйзнак.рф | ÐовоÑти ÑиÑтемы маркировки |
2.9 Мониторинг иÑточников
2.9.1 Health Checks
| Проверка | Интервал | ДейÑтвие при Ñбое |
|---|---|---|
| ДоÑтупноÑть КонÑÑƒÐ»ÑŒÑ‚Ð°Ð½Ñ‚ÐŸÐ»ÑŽÑ | 1 Ñ‡Ð°Ñ | Ðлерт Admin |
| ДоÑтупноÑть Гарант | 1 Ñ‡Ð°Ñ | Ðлерт Admin |
| УÑпешноÑть парÑинга > 90% | ПоÑле цикла | Ðлерт Admin |
| КоличеÑтво документов > 0 | ПоÑле цикла | Warning Admin |
2.9.2 Метрики
| Метрика | ОпиÑание |
|---|---|
lex.source.{name}.tasks_created | Создано задач |
lex.source.{name}.tasks_completed | УÑпешно обработано |
lex.source.{name}.tasks_failed | Ошибки парÑинга |
lex.source.{name}.documents_accepted | ПринÑто документов |
lex.source.{name}.documents_rejected | Отклонено документов |
lex.source.{name}.avg_parse_time | Среднее Ð²Ñ€ÐµÐ¼Ñ Ð¿Ð°Ñ€Ñинга |
Приложение Ð: Контрольные точки Data Sources
| Критерий | Проверка |
|---|---|
| Ðдаптеры загружаютÑÑ | Импорт без ошибок |
| Точки входа корректны | URL возвращают 200 |
| Селекторы работают | ПарÑинг теÑтовых Ñтраниц уÑпешен |
| Задачи генерируютÑÑ | > 0 задач в 20:30 |
| Результаты обрабатываютÑÑ | Документы поÑвлÑÑŽÑ‚ÑÑ Ð² KB |
| Ðлерты ÑоздаютÑÑ | Ð£Ð²ÐµÐ´Ð¾Ð¼Ð»ÐµÐ½Ð¸Ñ Ð´Ð¾ÑтавлÑÑŽÑ‚ÑÑ |
Документ подготовлен: Январь 2026
ВерÑиÑ: 1.0
СтатуÑ: Черновик