message / plugins /content /core.py
hunian
refactor(plugins): 插件短名并统一 MCP tool 为 {plugin}-{tool}
cc826a1
Raw
History Blame Contribute Delete
25 kB
"""
内容提取核心逻辑
集成 run/log 服务,记录处理过程日志。
"""
import logging
import tempfile
from pathlib import Path
from typing import Optional, Dict, Any
from .extractors.xiaohongshu import XiaohongshuExtractor
from .extractors.weibo import WeiboExtractor
from plugins.ocr.core import get_ocr_service
from app.plugins.run_log import get_run_log_service, RunStatus
logger = logging.getLogger(__name__)
class ContentExtractorCore:
"""内容提取核心逻辑"""
def __init__(self):
self.xhs_extractor = XiaohongshuExtractor()
self.weibo_extractor = WeiboExtractor()
self.ocr_service = get_ocr_service()
def validate_url(self, url: str) -> dict:
"""
验证URL
Args:
url: 待验证的URL
Returns:
{
"valid": bool,
"source_type": str,
"error": str # 错误信息(如果无效)
}
"""
if not url or not url.strip():
return {
"valid": False,
"source_type": "unknown",
"error": "链接不能为空"
}
# 按平台逐个解析,避免把其他平台链接误判成小红书错误。
for extractor in (self.xhs_extractor, self.weibo_extractor):
result = extractor.parse_url(url)
if result["valid"]:
return {
"valid": True,
"source_type": result["source_type"],
"error": None
}
return {
"valid": False,
"source_type": "unknown",
"error": "无效或暂不支持的链接"
}
async def extract_with_run(
self,
url: str,
run_id: str,
include_ocr: bool = True,
cookies: Optional[dict] = None
) -> dict:
"""
提取内容,记录 run 事件
Args:
url: 内容链接
run_id: 运行 ID
include_ocr: 是否对图片进行OCR识别
cookies: Cookie(可选,用于应对反爬)
Returns:
{
"success": bool,
"title": str,
"content": str,
"raw_html": str,
"raw_text": str,
"normalized_content": str,
"images": list,
"images_text": str,
"source_type": str,
"error": str
}
"""
run_service = get_run_log_service()
# 1. 验证URL
run_service.add_event(
run_id=run_id,
stage="validate_url",
message=f"验证 URL: {url}",
)
validation = self.validate_url(url)
if not validation["valid"]:
run_service.add_event(
run_id=run_id,
stage="validate_url",
message=f"URL 验证失败: {validation['error']}",
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": "",
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": validation["source_type"],
"error": validation["error"]
}
source_type = validation["source_type"]
run_service.add_event(
run_id=run_id,
stage="validate_url",
message=f"URL 验证通过: {source_type}",
)
# 2. 根据来源类型选择提取器
if source_type == "xiaohongshu":
return await self._extract_xiaohongshu_with_run(url, run_id, include_ocr, cookies)
if source_type == "weibo":
return await self._extract_weibo_with_run(url, run_id, include_ocr, cookies)
else:
error_msg = f"不支持的内容来源: {source_type}"
run_service.add_event(
run_id=run_id,
stage="extract",
message=error_msg,
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": "",
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": source_type,
"error": error_msg
}
async def _extract_xiaohongshu_with_run(
self,
url: str,
run_id: str,
include_ocr: bool,
cookies: Optional[dict] = None
) -> dict:
"""提取小红书内容,记录 run 事件"""
run_service = get_run_log_service()
try:
# 1. 获取页面内容
run_service.add_event(
run_id=run_id,
stage="fetch_page",
message="获取页面内容",
)
page_result = await self.xhs_extractor.fetch_page(url, cookies)
if not page_result["success"]:
run_service.add_event(
run_id=run_id,
stage="fetch_page",
message=f"获取页面失败: {page_result['error']}",
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": "",
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": "xiaohongshu",
"error": page_result["error"]
}
raw_html = page_result.get("html", "")
run_service.add_event(
run_id=run_id,
stage="fetch_page",
message=f"获取页面成功: {len(raw_html)} 字符",
)
# 2. 解析页面内容
run_service.add_event(
run_id=run_id,
stage="parse_content",
message="解析页面内容",
)
content_result = self.xhs_extractor.parse_content(raw_html)
if not content_result["success"]:
run_service.add_event(
run_id=run_id,
stage="parse_content",
message=f"解析内容失败: {content_result['error']}",
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": raw_html,
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": "xiaohongshu",
"error": content_result["error"]
}
images = content_result.get("images", [])
run_service.add_event(
run_id=run_id,
stage="parse_content",
message=f"解析内容成功: {len(images)} 张图片",
)
# 3. OCR识别图片(如果需要)
images_text = ""
if include_ocr and images:
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"开始 OCR 识别: {len(images)} 张图片",
)
images_text = await self._ocr_images_with_run(
images,
self.xhs_extractor.download_image,
run_id,
)
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"OCR 识别完成: {len(images_text)} 字符",
)
# 4. 格式化结果
run_service.add_event(
run_id=run_id,
stage="format",
message="格式化结果",
)
return {
"success": True,
"title": content_result.get("title", ""),
"content": content_result.get("content", ""),
"raw_html": raw_html,
"raw_text": content_result.get("raw_text", ""),
"normalized_content": content_result.get("content", ""),
"images": images,
"images_text": images_text,
"source_type": "xiaohongshu",
"error": None
}
except Exception as e:
logger.error(f"提取小红书内容失败: {e}", exc_info=True)
run_service.add_event(
run_id=run_id,
stage="extract",
message=f"提取异常: {str(e)}",
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": "",
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": "xiaohongshu",
"error": str(e)
}
async def _extract_weibo_with_run(
self,
url: str,
run_id: str,
include_ocr: bool,
cookies: Optional[dict] = None
) -> dict:
"""提取微博内容,记录 run 事件"""
run_service = get_run_log_service()
try:
# 1. 获取页面内容
run_service.add_event(
run_id=run_id,
stage="fetch_page",
message="获取页面内容",
)
page_result = await self.weibo_extractor.fetch_page(url, cookies)
if not page_result["success"]:
run_service.add_event(
run_id=run_id,
stage="fetch_page",
message=f"获取页面失败: {page_result['error']}",
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": "",
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": "weibo",
"error": page_result["error"]
}
raw_html = page_result.get("html", "")
run_service.add_event(
run_id=run_id,
stage="fetch_page",
message=f"获取页面成功: {len(raw_html)} 字符",
)
# 2. 解析页面内容
run_service.add_event(
run_id=run_id,
stage="parse_content",
message="解析页面内容",
)
content_result = self.weibo_extractor.parse_content(raw_html)
if not content_result["success"]:
run_service.add_event(
run_id=run_id,
stage="parse_content",
message=f"解析内容失败: {content_result['error']}",
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": raw_html,
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": "weibo",
"error": content_result["error"]
}
images = content_result.get("images", [])
run_service.add_event(
run_id=run_id,
stage="parse_content",
message=f"解析内容成功: {len(images)} 张图片",
)
# 3. OCR识别图片(如果需要)
images_text = ""
if include_ocr and images:
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"开始 OCR 识别: {len(images)} 张图片",
)
images_text = await self._ocr_images_with_run(
images,
self.weibo_extractor.download_image,
run_id,
)
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"OCR 识别完成: {len(images_text)} 字符",
)
# 4. 格式化结果
run_service.add_event(
run_id=run_id,
stage="format",
message="格式化结果",
)
return {
"success": True,
"title": content_result.get("title", ""),
"content": content_result.get("content", ""),
"raw_html": raw_html,
"raw_text": content_result.get("raw_text", ""),
"normalized_content": content_result.get("content", ""),
"images": images,
"images_text": images_text,
"source_type": "weibo",
"error": None
}
except Exception as e:
logger.error(f"提取微博内容失败: {e}", exc_info=True)
run_service.add_event(
run_id=run_id,
stage="extract",
message=f"提取异常: {str(e)}",
level="error",
)
return {
"success": False,
"title": "",
"content": "",
"raw_html": "",
"raw_text": "",
"normalized_content": "",
"images": [],
"images_text": "",
"source_type": "weibo",
"error": str(e)
}
async def _ocr_images_with_run(self, image_urls: list, download_image, run_id: str) -> str:
"""对图片进行OCR识别,记录 run 事件"""
run_service = get_run_log_service()
if not image_urls:
run_service.add_event(
run_id=run_id,
stage="ocr",
message="跳过 OCR:页面未解析到可下载图片",
)
return ""
all_text = []
for idx, image_url in enumerate(image_urls[:5]): # 限制最多5张图片
try:
# 下载图片
run_service.add_event(
run_id=run_id,
stage="download_image",
message=f"下载图片 {idx + 1}: {image_url[:50]}...",
)
download_result = await download_image(image_url)
if not download_result["success"]:
run_service.add_event(
run_id=run_id,
stage="download_image",
message=f"下载图片失败: {download_result['error']}",
level="warning",
)
continue
# 保存到临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp_file:
tmp_file.write(download_result["data"])
tmp_path = tmp_file.name
try:
# 执行OCR识别
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"OCR 识别图片 {idx + 1}",
)
ocr_result = self.ocr_service.extract_text_from_file(tmp_path)
if ocr_result["success"] and ocr_result["text"]:
all_text.append(f"[图片{idx + 1}]\n{ocr_result['text']}")
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"OCR 识别成功: {len(ocr_result['text'])} 字符",
)
elif ocr_result["success"]:
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"OCR 未识别到文字: {image_url[:50]}...",
)
else:
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"OCR 识别失败: {ocr_result.get('error')}",
level="warning",
)
finally:
# 清理临时文件
Path(tmp_path).unlink(missing_ok=True)
except Exception as e:
logger.error(f"OCR识别图片失败: {e}")
run_service.add_event(
run_id=run_id,
stage="ocr",
message=f"OCR 识别异常: {str(e)}",
level="error",
)
continue
return "\n\n".join(all_text)
async def extract(
self,
url: str,
include_ocr: bool = True,
cookies: Optional[dict] = None
) -> dict:
"""
提取内容(兼容旧接口)
Args:
url: 内容链接
include_ocr: 是否对图片进行OCR识别
cookies: Cookie(可选,用于应对反爬)
Returns:
{
"success": bool,
"title": str,
"content": str,
"images_text": str, # 图片OCR识别的文本
"source_type": str,
"error": str # 错误信息(如果失败)
}
"""
# 1. 验证URL
validation = self.validate_url(url)
if not validation["valid"]:
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": validation["source_type"],
"error": validation["error"]
}
source_type = validation["source_type"]
# 2. 根据来源类型选择提取器
if source_type == "xiaohongshu":
return await self._extract_xiaohongshu(url, include_ocr, cookies)
if source_type == "weibo":
return await self._extract_weibo(url, include_ocr, cookies)
else:
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": source_type,
"error": f"不支持的内容来源: {source_type}"
}
async def _extract_xiaohongshu(
self,
url: str,
include_ocr: bool,
cookies: Optional[dict] = None
) -> dict:
"""提取小红书内容"""
try:
# 1. 获取页面内容
page_result = await self.xhs_extractor.fetch_page(url, cookies)
if not page_result["success"]:
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": "xiaohongshu",
"error": page_result["error"]
}
# 2. 解析页面内容
content_result = self.xhs_extractor.parse_content(page_result["html"])
if not content_result["success"]:
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": "xiaohongshu",
"error": content_result["error"]
}
# 3. OCR识别图片(如果需要)
images_text = ""
if include_ocr and content_result["images"]:
images_text = await self._ocr_images(
content_result["images"],
self.xhs_extractor.download_image,
)
return {
"success": True,
"title": content_result["title"],
"content": content_result["content"],
"images_text": images_text,
"source_type": "xiaohongshu",
"error": None
}
except Exception as e:
logger.error(f"提取小红书内容失败: {e}", exc_info=True)
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": "xiaohongshu",
"error": str(e)
}
async def _extract_weibo(
self,
url: str,
include_ocr: bool,
cookies: Optional[dict] = None
) -> dict:
"""提取微博内容。"""
try:
page_result = await self.weibo_extractor.fetch_page(url, cookies)
if not page_result["success"]:
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": "weibo",
"error": page_result["error"]
}
content_result = self.weibo_extractor.parse_content(page_result["html"])
if not content_result["success"]:
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": "weibo",
"error": content_result["error"]
}
images_text = ""
if include_ocr and content_result["images"]:
images_text = await self._ocr_images(
content_result["images"],
self.weibo_extractor.download_image,
)
return {
"success": True,
"title": content_result["title"],
"content": content_result["content"],
"images_text": images_text,
"source_type": "weibo",
"error": None
}
except Exception as e:
logger.error(f"提取微博内容失败: {e}", exc_info=True)
return {
"success": False,
"title": "",
"content": "",
"images_text": "",
"source_type": "weibo",
"error": str(e)
}
async def _ocr_images(self, image_urls: list, download_image) -> str:
"""对图片进行OCR识别"""
if not image_urls:
logger.info("跳过 OCR:页面未解析到可下载图片")
return ""
all_text = []
for idx, image_url in enumerate(image_urls[:5]): # 限制最多5张图片
try:
# 下载图片
download_result = await download_image(image_url)
if not download_result["success"]:
logger.warning(f"下载图片失败: {download_result['error']}")
continue
# 保存到临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp_file:
tmp_file.write(download_result["data"])
tmp_path = tmp_file.name
try:
# 执行OCR识别
ocr_result = self.ocr_service.extract_text_from_file(tmp_path)
if ocr_result["success"] and ocr_result["text"]:
all_text.append(f"[图片{idx + 1}]\n{ocr_result['text']}")
elif ocr_result["success"]:
logger.info(f"OCR 未识别到文字: {image_url}")
else:
logger.warning(f"OCR 识别失败: {ocr_result.get('error')}")
finally:
# 清理临时文件
Path(tmp_path).unlink(missing_ok=True)
except Exception as e:
logger.error(f"OCR识别图片失败: {e}")
continue
return "\n\n".join(all_text)