Редактировать web_utils.py

This commit is contained in:
Markov Andrey
2026-06-30 21:28:12 +00:00
parent bfc9d2a901
commit f77fb9d590

View File

@@ -2,15 +2,7 @@
""" """
Утилиты для загрузки веб-страниц и PDF, извлечения основного текста, Утилиты для загрузки веб-страниц и PDF, извлечения основного текста,
рекурсивного обхода ссылок. рекурсивного обхода ссылок.
Добавлено ограничение размера загружаемого контента.
Использует aiohttp для асинхронных запросов, BeautifulSoup для парсинга,
readability-lxml для извлечения контента (опционально).
Параметры (max_pages, max_depth) берутся из конфига при вызове из xmpp_client.
Импортируется в:
- indexing_worker.py (fetch_any_url, crawl_url)
- возможно, в других функциях при обработке URL.
""" """
import asyncio import asyncio
@@ -35,24 +27,20 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def fetch_any_url(url: str, timeout: int = 30) -> Tuple[Optional[str], Optional[str]]: async def fetch_any_url(url: str, timeout: int = 30, max_size_mb: int = 5) -> Tuple[Optional[str], Optional[str]]:
""" """
Универсальная загрузка контента по URL. Универсальная загрузка контента по URL с ограничением размера.
Аргументы: Аргументы:
url (str): целевой адрес. url: целевой адрес.
timeout (int): таймаут запроса в секундах. timeout: таймаут запроса в секундах.
max_size_mb: максимальный размер загружаемого контента в МБ.
Возвращает: Возвращает:
Tuple[Optional[str], Optional[str]]: (заголовок, текст). Tuple[Optional[str], Optional[str]]: (заголовок, текст).
Для HTML: заголовок страницы и очищенный основной текст. Для HTML: заголовок страницы и очищенный основной текст.
Для PDF: имя файла (базовая часть URL) и извлечённый текст. Для PDF: имя файла (базовая часть URL) и извлечённый текст.
В случае ошибки возвращает (None, None). В случае ошибки возвращает (None, None).
Примечания:
- Для PDF используется временный файл, который удаляется после чтения.
- Поддерживаются только HTTP/HTTPS.
- Таймаут применяется к запросу в целом (чтение + соединение).
""" """
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -60,14 +48,22 @@ async def fetch_any_url(url: str, timeout: int = 30) -> Tuple[Optional[str], Opt
if resp.status != 200: if resp.status != 200:
logger.error(f"HTTP {resp.status} для {url}") logger.error(f"HTTP {resp.status} для {url}")
return None, None return None, None
content_type = resp.headers.get('Content-Type', '').lower() content_type = resp.headers.get('Content-Type', '').lower()
limit = max_size_mb * 1024 * 1024
try:
data = await resp.content.read(limit=limit)
except aiohttp.ContentTooLong:
logger.error(f"Содержимое {url} превышает лимит {max_size_mb} МБ")
return None, None
# Обработка PDF # Обработка PDF
if 'application/pdf' in content_type or url.lower().endswith('.pdf'): if 'application/pdf' in content_type or url.lower().endswith('.pdf'):
# Сохраняем PDF во временный файл
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
tmp_path = tmp.name tmp_path = tmp.name
async with aiofiles.open(tmp_path, 'wb') as f: async with aiofiles.open(tmp_path, 'wb') as f:
await f.write(await resp.read()) await f.write(data)
try: try:
from pypdf import PdfReader from pypdf import PdfReader
reader = PdfReader(tmp_path) reader = PdfReader(tmp_path)
@@ -81,7 +77,7 @@ async def fetch_any_url(url: str, timeout: int = 30) -> Tuple[Optional[str], Opt
os.unlink(tmp_path) os.unlink(tmp_path)
else: else:
# Обычная HTML-страница # Обычная HTML-страница
html = await resp.text() html = data.decode('utf-8', errors='ignore')
title, text = extract_text_from_html(html, url) title, text = extract_text_from_html(html, url)
return title, text return title, text
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -93,28 +89,8 @@ async def fetch_any_url(url: str, timeout: int = 30) -> Tuple[Optional[str], Opt
def extract_text_from_html(html: str, url: str = "") -> Tuple[Optional[str], str]: def extract_text_from_html(html: str, url: str = "") -> Tuple[Optional[str], str]:
""" """Извлекает заголовок и основной текст из HTML (без изменений)."""
Извлекает заголовок и основной текст из HTML.
Аргументы:
html (str): исходный HTML-код.
url (str): URL страницы (используется для формирования заголовка, если теги отсутствуют).
Возвращает:
Tuple[Optional[str], str]: (заголовок, очищенный текст).
Заголовок может быть None, если не удалось извлечь.
Алгоритм извлечения заголовка (по приоритету):
1. <title>
2. <meta property="og:title">
3. <h1>
4. Элементы с классами 'title', 'article-title', 'page-title', 'document-title'
5. Последний сегмент пути URL
6. Домен (как запасной вариант)
"""
soup = BeautifulSoup(html, 'lxml') soup = BeautifulSoup(html, 'lxml')
# ---- 1. Извлечение заголовка ----
title = None title = None
if soup.title and soup.title.string: if soup.title and soup.title.string:
title = soup.title.string.strip() title = soup.title.string.strip()
@@ -127,14 +103,12 @@ def extract_text_from_html(html: str, url: str = "") -> Tuple[Optional[str], str
if h1: if h1:
title = h1.get_text(strip=True) title = h1.get_text(strip=True)
if not title: if not title:
# Поиск по распространённым классам
for cls in ['title', 'article-title', 'page-title', 'document-title']: for cls in ['title', 'article-title', 'page-title', 'document-title']:
elem = soup.find(class_=cls) elem = soup.find(class_=cls)
if elem: if elem:
title = elem.get_text(strip=True) title = elem.get_text(strip=True)
break break
if not title: if not title:
# Последняя надежда: использовать последний сегмент URL
parsed = urlparse(url) parsed = urlparse(url)
if parsed.path and parsed.path != '/': if parsed.path and parsed.path != '/':
last_part = parsed.path.rstrip('/').split('/')[-1] last_part = parsed.path.rstrip('/').split('/')[-1]
@@ -142,19 +116,14 @@ def extract_text_from_html(html: str, url: str = "") -> Tuple[Optional[str], str
else: else:
title = parsed.netloc title = parsed.netloc
# ---- 2. Извлечение основного текста ----
if HAS_READABILITY: if HAS_READABILITY:
# Используем readability для выделения «читаемой» части
doc = Document(html) doc = Document(html)
text = doc.summary() text = doc.summary()
# Если readability дал заголовок, но мы его не получили ранее используем
if not title and doc.title(): if not title and doc.title():
title = doc.title() title = doc.title()
# Очищаем полученный HTML от лишних тегов
text_soup = BeautifulSoup(text, 'lxml') text_soup = BeautifulSoup(text, 'lxml')
text = text_soup.get_text(separator='\n', strip=True) text = text_soup.get_text(separator='\n', strip=True)
else: else:
# Удаляем скрипты, стили, навигацию, футеры, хедеры
for script in soup(["script", "style", "nav", "footer", "header"]): for script in soup(["script", "style", "nav", "footer", "header"]):
script.decompose() script.decompose()
text = soup.get_text(separator='\n', strip=True) text = soup.get_text(separator='\n', strip=True)
@@ -162,7 +131,6 @@ def extract_text_from_html(html: str, url: str = "") -> Tuple[Optional[str], str
logger.debug(f"Извлечён заголовок: {repr(title)} для {url}") logger.debug(f"Извлечён заголовок: {repr(title)} для {url}")
return title, text return title, text
async def crawl_url( async def crawl_url(
start_url: str, start_url: str,
max_depth: int, max_depth: int,