InfoRadar / backend /api /fetch_url.py
dqy08's picture
增强请求日志记录
f18b50b
"""URL 文本提取 API"""
import json
import re
from urllib.parse import urlparse
import trafilatura
import requests
from backend.api.utils import handle_api_error
# 单次提取的最大字符数上限(防止异常大页面影响性能)
MAX_EXTRACTED_TEXT_LENGTH = 20000
def _is_valid_url(url: str) -> bool:
"""验证 URL 格式"""
try:
result = urlparse(url)
return all([result.scheme in ['http', 'https'], result.netloc])
except Exception:
return False
def _is_local_or_private(url: str) -> bool:
"""检查是否为本地或私有网络地址(防止 SSRF 攻击)"""
try:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
return True
# 检查是否为 localhost
if hostname in ['localhost', '127.0.0.1', '::1']:
return True
# 检查是否为私有 IP 地址
private_patterns = [
r'^10\.', # 10.0.0.0/8
r'^172\.(1[6-9]|2[0-9]|3[0-1])\.', # 172.16.0.0/12
r'^192\.168\.', # 192.168.0.0/16
r'^169\.254\.', # 169.254.0.0/16 (link-local)
]
for pattern in private_patterns:
if re.match(pattern, hostname):
return True
return False
except Exception:
return True # 解析失败时保守处理,拒绝访问
def _format_article_text(metadata: dict) -> str:
"""
将元数据和正文格式化为类似网页显示的纯文本
Args:
metadata: trafilatura 提取的 JSON 数据(已解析为字典)
Returns:
格式化后的文章文本
"""
lines = []
# 标题
if metadata.get('title'):
lines.append(metadata['title'])
lines.append('')
# 元数据信息(无标签,直接显示内容)
meta_parts = []
if metadata.get('author'):
meta_parts.append(metadata['author'])
if metadata.get('date'):
meta_parts.append(metadata['date'])
# if metadata.get('hostname'):
# meta_parts.append(metadata['hostname'])
if metadata.get('source-hostname'):
meta_parts.append(metadata['source-hostname'])
# if metadata.get('filedate'):
# meta_parts.append(metadata['filedate'])
if meta_parts:
lines.append(' | '.join(meta_parts))
lines.append('')
# 正文
if metadata.get('text'):
lines.append(metadata['text'])
return '\n'.join(lines)
def fetch_url(fetch_request):
"""
从 URL 提取文本内容
Args:
fetch_request: 包含 url 字段的字典
Returns:
(响应字典, 状态码) 元组
"""
url = fetch_request.get('url', '').strip()
# 验证 URL
if not url:
return {
'success': False,
'message': '缺少 URL 参数,请提供 url 字段'
}, 400
if not _is_valid_url(url):
return {
'success': False,
'message': f'无效的 URL 格式: {url}'
}, 400
# 安全检查:防止 SSRF 攻击
if _is_local_or_private(url):
return {
'success': False,
'message': '不允许访问本地或私有网络地址'
}, 400
# 提取文本和元数据
try:
from backend.access_log import log_fetch_url
log_fetch_url(url)
# 使用 requests 下载网页,设置浏览器 User-Agent 和请求头
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
# 下载网页内容(设置超时和请求头)
response = requests.get(url, headers=headers, timeout=10, allow_redirects=True)
response.raise_for_status()
# 检查响应内容类型
content_type = response.headers.get('Content-Type', '').lower()
if 'text/html' not in content_type and 'text/xml' not in content_type:
return {
'success': False,
'message': f'不支持的内容类型: {content_type},仅支持 HTML/XML 页面'
}, 400
# 使用 trafilatura 提取结构化数据(包含元数据和正文)
result_json = trafilatura.extract(
response.text,
url=url,
with_metadata=True,
output_format='json'
)
if not result_json:
print("⚠️ 无法提取页面内容")
return {
'success': False,
'message': '无法从网页中提取文本内容,可能不是文章页面或页面需要验证'
}, 400
# 解析 JSON 数据
metadata = json.loads(result_json)
# 检查是否有正文内容
if not metadata.get('text') or not metadata['text'].strip():
print("⚠️ 提取到元数据但无正文内容")
print("元数据:", json.dumps(metadata, ensure_ascii=False, indent=2))
return {
'success': False,
'message': '无法从网页中提取正文内容'
}, 400
# 格式化文本(元数据 + 正文)
formatted_text = _format_article_text(metadata)
original_char_count = len(formatted_text)
# 构建返回消息(如果截断了,添加提示)
message = None
# 检查并截断超长文本
if original_char_count > MAX_EXTRACTED_TEXT_LENGTH:
formatted_text = formatted_text[:MAX_EXTRACTED_TEXT_LENGTH]
message = f'内容较长,已截断为前 {MAX_EXTRACTED_TEXT_LENGTH} 字符(原始长度: {original_char_count} 字符)'
char_count = len(formatted_text)
# 打印提取结果
# print(formatted_text.split('\n')[:4])
# print(f"✓ 提取成功: {char_count} 字符" + (f" (截断前: {original_char_count} 字符)" if original_char_count > char_count else ""))
# 打印除正文外的metadata内容
metadata_less = metadata.copy()
metadata_less['raw_text'] = ''
metadata_less['text'] = ''
# print(json.dumps(metadata_less, ensure_ascii=False, indent=2))
return {
'success': True,
'text': formatted_text,
'url': url,
'char_count': char_count,
'message': message
}, 200
except requests.exceptions.Timeout:
return {
'success': False,
'message': '请求超时,请检查网络连接或稍后重试'
}, 400
except requests.exceptions.RequestException as e:
return {
'success': False,
'message': f'无法访问 URL: {str(e)}'
}, 400
except Exception as e: # noqa: BLE001
error_response = handle_api_error('URL 文本提取失败', e)
return error_response, 500