初始化

This commit is contained in:
2026-07-18 16:13:52 +08:00
parent c0070f0a5c
commit fe8b417ab6
75 changed files with 12898 additions and 1 deletions
+258
View File
@@ -0,0 +1,258 @@
"""英文正文提取引擎
策略:
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 <title> 标签提取标题"""
import re
m = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
if m:
title = re.sub(r"<[^>]+>", "", m.group(1))
return title.strip()
return ""
def _extract_publish_time(html: str, url: str = "") -> str:
"""从 HTML 提取发布时间(含回退策略)。
优先级:
1. htmldate 提取(元数据/标签/正文)
2. <meta property="article:published_time"> 等常见标签
3. URL 中的日期(/YYYY/MM/DD/ 或 /YYYYMMDD/
"""
# 策略 1: htmldate
try:
result = find_date(html)
if result:
return result
except Exception:
pass
# 策略 2: 常见 meta 标签
if html:
import re
meta_patterns = [
r'<meta[^>]+property=["\']article:published_time["\'][^>]+content=["\']([^"\']+)',
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']article:published_time',
r'<meta[^>]+name=["\']pubdate["\'][^>]+content=["\']([^"\']+)',
r'<meta[^>]+name=["\']publish_date["\'][^>]+content=["\']([^"\']+)',
r'<meta[^>]+name=["\']date["\'][^>]+content=["\']([^"\']+)',
r'<time[^>]+datetime=["\']([^"\']+)',
]
for pat in meta_patterns:
m = re.search(pat, html, re.IGNORECASE)
if m:
date_str = m.group(1).strip()
if date_str:
return date_str
# 策略 3: URL 中的日期
if url:
from datetime import datetime as dt
import re
url_patterns = [
r"/(\d{4})/(\d{2})/(\d{2})/",
r"/(\d{4})(\d{2})(\d{2})/",
r"-(\d{4})(\d{2})(\d{2})(?:[/-]|$)",
r"-(\d{4})-(\d{2})-(\d{2})[/-]",
]
for pat in url_patterns:
m = re.search(pat, url)
if m:
try:
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
return dt(y, mo, d).isoformat()
except ValueError:
continue
return ""
def _extract_author(html: str) -> str:
"""从 HTML meta 标签提取作者"""
import re
for pat in [
r'<meta[^>]+name=["\']author["\'][^>]+content=["\']([^"\']+)',
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']author',
]:
m = re.search(pat, html, re.IGNORECASE)
if m:
return m.group(1).strip()
return ""
def _extract_from_md(md: str, pattern: str, default: str) -> str:
"""从 Markdown 内容中按正则提取第一个捕获组,失败返回 default。"""
import re
m = re.search(pattern, md)
return m.group(1).strip() if m else default
def _clean_markdown(md: str) -> str:
"""清理 Crawl4AI MD 中的导航/广告噪音
规则:移除明显非正文行(短链接行、纯导航行等)
"""
lines = md.split("\n")
cleaned: list[str] = []
skip_patterns = [
"ADVERTISEMENT",
"Continue Reading Below",
"Sign in",
"Log in",
"Subscribe",
"Advertisement",
]
for line in lines:
stripped = line.strip()
# 跳过明显广告/导航行
if any(p in stripped for p in skip_patterns):
continue
# 跳过纯导航链接行([text](url) 且整行只有链接)
if stripped.startswith("[") and stripped.endswith(")") and stripped.count("[") <= 3:
continue
cleaned.append(line)
return "\n".join(cleaned)