Files
fckbot/rag/utils/web_utils.py
Markov Andrey 63900feece Update 86 files
- /rag/commands/expert.py
- /core/services/__init__.py
- /core/services/embedding_service.py
- /core/services/file_service.py
- /core/services/giga_client.py
- /core/services/kb_service.py
- /core/services/postgres_service.py
- /core/services/qdrant_service.py
- /core/services/reranker_service.py
- /core/functions/__init__.py
- /core/functions/check_consistency.py
- /core/functions/check_spelling.py
- /core/functions/critique_answer.py
- /core/functions/expand_query.py
- /core/functions/extract_metrics.py
- /core/functions/file_processor.py
- /core/functions/generate_document.py
- /core/functions/intent_classify.py
- /core/functions/rerank_context.py
- /core/functions/summarize_document.py
- /core/utils/__init__.py
- /core/utils/arg_parser.py
- /core/utils/config_loader.py
- /core/utils/layout_converter.py
- /core/utils/logger.py
- /core/utils/text_utils.py
- /core/utils/web_utils.py
- /bots/commands/__init__.py
- /bots/commands/base.py
- /bots/commands/create.py
- /core/commands/global_remove.py
- /core/commands/help.py
- /core/commands/info.py
- /core/commands/kb.py
- /core/commands/learn.py
- /core/commands/other.py
- /core/commands/registry.py
- /core/commands/stats.py
- /core/commands/template.py
- /core/handlers/__init__.py
- /core/handlers/message_handler.py
- /core/workers/__init__.py
- /core/workers/indexing_worker.py
- /core/xmpp/__init__.py
- /core/xmpp/client.py
- /bots/commands/global_remove.py
- /bots/commands/help.py
- /bots/commands/info.py
- /bots/commands/kb.py
- /bots/commands/registry.py
- /bots/commands/other.py
- /bots/commands/template.py
- /bots/commands/learn.py
- /bots/commands/stats.py
- /bots/handlers/message_handler.py
- /bots/handlers/__init__.py
- /bots/workers/__init__.py
- /bots/workers/indexing_worker.py
- /bots/xmpp/__init__.py
- /bots/xmpp/client.py
- /rag/services/qdrant_service.py
- /rag/services/giga_client.py
- /rag/services/embedding_service.py
- /rag/services/reranker_service.py
- /rag/services/__init__.py
- /rag/services/file_service.py
- /rag/services/postgres_service.py
- /rag/services/kb_service.py
- /rag/functions/check_spelling.py
- /rag/functions/__init__.py
- /rag/functions/extract_metrics.py
- /rag/functions/generate_document.py
- /rag/functions/expand_query.py
- /rag/functions/critique_answer.py
- /rag/functions/file_processor.py
- /rag/functions/check_consistency.py
- /rag/functions/summarize_document.py
- /rag/functions/rerank_context.py
- /rag/functions/intent_classify.py
- /rag/utils/__init__.py
- /rag/utils/layout_converter.py
- /rag/utils/text_utils.py
- /rag/utils/arg_parser.py
- /rag/utils/config_loader.py
- /rag/utils/logger.py
- /rag/utils/web_utils.py
2026-06-30 10:33:28 +00:00

242 lines
10 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.
# -*- coding: utf-8 -*-
"""
Утилиты для загрузки веб-страниц и PDF, извлечения основного текста,
рекурсивного обхода ссылок.
Использует aiohttp для асинхронных запросов, BeautifulSoup для парсинга,
readability-lxml для извлечения контента (опционально).
Параметры (max_pages, max_depth) берутся из конфига при вызове из xmpp_client.
Импортируется в:
- indexing_worker.py (fetch_any_url, crawl_url)
- возможно, в других функциях при обработке URL.
"""
import asyncio
import aiohttp
import logging
from typing import Optional, List, Tuple
from urllib.parse import urljoin, urlparse
from bs4 import BeautifulSoup
import aiofiles
import tempfile
import os
# Опциональная библиотека readability-lxml улучшает извлечение основного текста
try:
from readability import Document
HAS_READABILITY = True
except ImportError:
HAS_READABILITY = False
logging.warning("readability-lxml не установлен, используем BeautifulSoup без очистки")
logger = logging.getLogger(__name__)
async def fetch_any_url(url: str, timeout: int = 30) -> Tuple[Optional[str], Optional[str]]:
"""
Универсальная загрузка контента по URL.
Аргументы:
url (str): целевой адрес.
timeout (int): таймаут запроса в секундах.
Возвращает:
Tuple[Optional[str], Optional[str]]: (заголовок, текст).
Для HTML: заголовок страницы и очищенный основной текст.
Для PDF: имя файла (базовая часть URL) и извлечённый текст.
В случае ошибки возвращает (None, None).
Примечания:
- Для PDF используется временный файл, который удаляется после чтения.
- Поддерживаются только HTTP/HTTPS.
- Таймаут применяется к запросу в целом (чтение + соединение).
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=timeout) as resp:
if resp.status != 200:
logger.error(f"HTTP {resp.status} для {url}")
return None, None
content_type = resp.headers.get('Content-Type', '').lower()
# Обработка PDF
if 'application/pdf' in content_type or url.lower().endswith('.pdf'):
# Сохраняем PDF во временный файл
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
tmp_path = tmp.name
async with aiofiles.open(tmp_path, 'wb') as f:
await f.write(await resp.read())
try:
from pypdf import PdfReader
reader = PdfReader(tmp_path)
text = "\n".join([page.extract_text() or "" for page in reader.pages])
title = os.path.basename(url)
return title, text
except ImportError:
logger.error("pypdf не установлен")
return None, None
finally:
os.unlink(tmp_path)
else:
# Обычная HTML-страница
html = await resp.text()
title, text = extract_text_from_html(html, url)
return title, text
except asyncio.TimeoutError:
logger.error(f"Таймаут при загрузке {url}")
return None, None
except Exception as e:
logger.error(f"Ошибка загрузки {url}: {e}")
return None, None
def extract_text_from_html(html: str, url: str = "") -> Tuple[Optional[str], str]:
"""
Извлекает заголовок и основной текст из 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')
# ---- 1. Извлечение заголовка ----
title = None
if soup.title and soup.title.string:
title = soup.title.string.strip()
if not title:
og_title = soup.find('meta', property='og:title')
if og_title and og_title.get('content'):
title = og_title['content'].strip()
if not title:
h1 = soup.find('h1')
if h1:
title = h1.get_text(strip=True)
if not title:
# Поиск по распространённым классам
for cls in ['title', 'article-title', 'page-title', 'document-title']:
elem = soup.find(class_=cls)
if elem:
title = elem.get_text(strip=True)
break
if not title:
# Последняя надежда: использовать последний сегмент URL
parsed = urlparse(url)
if parsed.path and parsed.path != '/':
last_part = parsed.path.rstrip('/').split('/')[-1]
title = last_part.replace('-', ' ').replace('_', ' ').title()
else:
title = parsed.netloc
# ---- 2. Извлечение основного текста ----
if HAS_READABILITY:
# Используем readability для выделения «читаемой» части
doc = Document(html)
text = doc.summary()
# Если readability дал заголовок, но мы его не получили ранее используем
if not title and doc.title():
title = doc.title()
# Очищаем полученный HTML от лишних тегов
text_soup = BeautifulSoup(text, 'lxml')
text = text_soup.get_text(separator='\n', strip=True)
else:
# Удаляем скрипты, стили, навигацию, футеры, хедеры
for script in soup(["script", "style", "nav", "footer", "header"]):
script.decompose()
text = soup.get_text(separator='\n', strip=True)
logger.debug(f"Извлечён заголовок: {repr(title)} для {url}")
return title, text
async def crawl_url(
start_url: str,
max_depth: int,
max_pages: int,
domain_restrict: bool = True,
) -> List[Tuple[str, str, str]]:
"""
Рекурсивно обходит ссылки на том же домене (или не ограничивая, если domain_restrict=False).
Аргументы:
start_url (str): начальная страница.
max_depth (int): максимальная глубина перехода по ссылкам.
max_pages (int): максимальное количество страниц для обработки.
domain_restrict (bool): ограничиваться ли тем же доменом (по умолчанию True).
Возвращает:
List[Tuple[str, str, str]]: список кортежей (url, title, text).
"""
visited = set()
to_visit = [(start_url, 0)]
results = []
visited.add(start_url)
while to_visit and len(results) < max_pages:
url, depth = to_visit.pop(0)
if depth > max_depth:
continue
logger.info(f"Обработка {url}, глубина {depth}")
title, text = await fetch_any_url(url)
if text:
results.append((url, title or url, text))
else:
continue
if depth < max_depth:
# Извлекаем ссылки из текущей страницы
links = await extract_links_from_url(url, domain_restrict)
for link in links:
if link not in visited:
visited.add(link)
to_visit.append((link, depth + 1))
return results
async def extract_links_from_url(url: str, domain_restrict: bool = True) -> List[str]:
"""
Извлекает все ссылки (href) из HTML-страницы по заданному URL.
Аргументы:
url (str): адрес страницы.
domain_restrict (bool): если True, оставлять только ссылки на том же домене.
Возвращает:
List[str]: список абсолютных URL.
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=10) as resp:
if resp.status != 200:
return []
html = await resp.text()
soup = BeautifulSoup(html, 'lxml')
base_domain = urlparse(url).netloc
links = set()
for a in soup.find_all('a', href=True):
href = a['href']
full_url = urljoin(url, href)
parsed = urlparse(full_url)
if domain_restrict and parsed.netloc != base_domain:
continue
if parsed.scheme in ('http', 'https'):
# Удаляем фрагмент (#)
full_url = parsed._replace(fragment='').geturl()
links.add(full_url)
return list(links)
except Exception as e:
logger.error(f"Ошибка извлечения ссылок с {url}: {e}")
return []