"""英文正文提取引擎 策略: 1. 优先 trafilatura 从原始 HTML 提取(除导航/广告/页脚) 2. 回退 Crawl4AI Markdown(如果 HTML 不可用) 3. 元数据提取(发布时间、作者) 所有业务参数从 configs/system.yaml 的 extractor 节读取。 """ import logging from pathlib import Path import trafilatura import yaml from htmldate import find_date from extractor.models import ProcessedArticle logger = logging.getLogger(__name__) def _load_extractor_config() -> dict: """从 system.yaml 读取 extractor 配置节""" config_path = Path("configs/system.yaml") if config_path.exists(): try: with open(config_path, encoding="utf-8") as f: raw = yaml.safe_load(f) return raw.get("extractor", {}) except Exception: logger.warning("读取 system.yaml 失败,使用默认值") return {} _cfg = _load_extractor_config() # 最小正文字数阈值 MIN_CONTENT_WORDS = int(_cfg.get("min_content_words", 50)) def extract_article( source_id: str, source_name: str, url: str, url_hash: str, html_path: str, md_path: str, crawl_publish_time: str = "", ) -> ProcessedArticle: """提取单篇文章的正文 Args: source_id: 新闻源 ID source_name: 新闻源名称 url: 文章 URL url_hash: URL SHA256 前 16 位 html_path: 原始 HTML 文件路径 md_path: Crawl4AI 生成的 Markdown 文件路径 crawl_publish_time: RSS/Crawler 提取的发布时间(回退用) Returns: ProcessedArticle """ article = ProcessedArticle( source_id=source_id, source_name=source_name, url=url, url_hash=url_hash, html_path=html_path, md_path=md_path, ) html_p = Path(html_path) if html_path else None md_p = Path(md_path) if md_path else None # ── 策略 1: trafilatura 从 HTML 提取 ────────────── if html_p and html_p.exists(): try: html_raw = html_p.read_text(encoding="utf-8") content = trafilatura.extract( html_raw, include_comments=False, include_tables=False, no_fallback=False, favor_precision=True, ) if content and _count_words(content) >= MIN_CONTENT_WORDS: article.content = content.strip() article.extractor = "trafilatura" article.word_count = _count_words(article.content) # 元数据 article.title = _extract_title(html_raw) or "" article.publish_time = ( _extract_publish_time(html_raw, url=url) or crawl_publish_time or "" ) article.author = _extract_author(html_raw) or "" article.status = "success" return article except Exception as e: logger.warning("[%s] trafilatura 提取失败: %s — %s", source_id, url_hash, e) # ── 策略 2: Crawl4AI Markdown 回退 ───────────────── if md_p and md_p.exists(): try: md_content = md_p.read_text(encoding="utf-8") if _count_words(md_content) >= MIN_CONTENT_WORDS: article.content = _clean_markdown(md_content) article.extractor = "crawl4ai_md" article.word_count = _count_words(article.content) # 从 Markdown 中提取元数据(RSS 源会写入) article.title = _extract_from_md( md_content, r"^#\s+(.+)$", article.title ) article.publish_time = ( _extract_from_md( md_content, r"\*\*发布时间\*\*:?\s*([^\n]+)", "" ) or _extract_publish_time("", url=url) or crawl_publish_time or "" ) article.status = "success" return article except Exception as e: logger.warning("[%s] Markdown 回退失败: %s — %s", source_id, url_hash, e) # ── 失败 ───────────────────────────────────────── article.status = "no_content" article.error = "No extractable content" return article # ── 辅助函数 ──────────────────────────────────────── def _count_words(text: str) -> int: """英文词数统计""" return len(text.split()) if text else 0 def _extract_title(html: str) -> str: """从 HTML