File size: 7,369 Bytes
494c9e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | """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
|