| """ |
| 内容提取核心逻辑 |
| |
| 集成 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() |
|
|
| |
| 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}", |
| ) |
|
|
| |
| 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: |
| |
| 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)} 字符", |
| ) |
|
|
| |
| 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)} 张图片", |
| ) |
|
|
| |
| 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)} 字符", |
| ) |
|
|
| |
| 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: |
| |
| 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)} 字符", |
| ) |
|
|
| |
| 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)} 张图片", |
| ) |
|
|
| |
| 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)} 字符", |
| ) |
|
|
| |
| 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]): |
| 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: |
| |
| 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 # 错误信息(如果失败) |
| } |
| """ |
| |
| 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"] |
|
|
| |
| 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: |
| |
| 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"] |
| } |
|
|
| |
| 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"] |
| } |
|
|
| |
| 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]): |
| 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_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) |
|
|