初始化

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
View File
+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)
+22
View File
@@ -0,0 +1,22 @@
"""正文提取数据模型"""
from pydantic import BaseModel
class ProcessedArticle(BaseModel):
"""经过正文提取清洗后的文章"""
source_id: str
source_name: str
url: str
url_hash: str
title: str = ""
content: str = "" # 清洗后的正文(英文)
publish_time: str = "" # ISO 8601
author: str = ""
word_count: int = 0
md_path: str = "" # 原始 Markdown 路径
html_path: str = "" # 原始 HTML 路径
status: str = "success" # success | no_content | failed
extractor: str = "trafilatura" # trafilatura | crawl4ai_md | none
error: str = ""
+175
View File
@@ -0,0 +1,175 @@
"""正文提取管道:批量处理 raw 目录下的文章"""
import json
import logging
from datetime import datetime
from pathlib import Path
from crawler.storage import load_index
from crawler.utils import get_news_day
from extractor.extractor import extract_article
from extractor.models import ProcessedArticle
logger = logging.getLogger(__name__)
def get_raw_data_sources(base_dir: str = "data/raw") -> list[str]:
"""扫描 data/raw/ 下所有源 ID
Args:
base_dir: raw 数据根目录
Returns:
源 ID 列表
"""
raw_path = Path(base_dir)
if not raw_path.exists():
return []
return sorted([
d.name for d in raw_path.iterdir()
if d.is_dir() and not d.name.startswith(".")
])
def process_source(
source_id: str,
date_str: str | None = None,
) -> list[ProcessedArticle]:
"""处理单个源的文章正文提取
Args:
source_id: 新闻源 ID
date_str: 日期 YYYYMMDD,默认当前新闻日
Returns:
ProcessedArticle 列表
"""
if date_str is None:
date_str = get_news_day()
logger.info("━━━ 正文提取 [%s] %s ━━━", source_id, date_str)
# 读取 raw index
articles = load_index(source_id, date_str)
if not articles:
logger.warning("[%s] %s 无待处理文章", source_id, date_str)
return []
# 输出目录
out_dir = Path(f"data/processed/{source_id}/{date_str}")
out_dir.mkdir(parents=True, exist_ok=True)
results: list[ProcessedArticle] = []
success = 0
no_content = 0
failed = 0
# 增量:跳过已处理的文章
skipped = 0
for article in articles[:]:
out_file = out_dir / f"{article.url_hash}.json"
if out_file.exists():
articles.remove(article)
skipped += 1
if skipped > 0:
logger.info("[%s] 增量跳过 %d 篇已处理,剩余 %d",
source_id, skipped, len(articles))
for article in articles:
# 确保 html_path / md_path 是绝对路径(相对于 data/raw/)
html_path = str(Path("data/raw") / source_id / date_str / f"{article.url_hash}.html")
md_path = str(Path("data/raw") / source_id / date_str / f"{article.url_hash}.md")
# 如果 index 中已有路径,优先使用
if article.html_path:
html_path = article.html_path
if article.md_path:
md_path = article.md_path
processed = extract_article(
source_id=source_id,
source_name=article.source_name,
url=article.url,
url_hash=article.url_hash,
html_path=html_path,
md_path=md_path,
crawl_publish_time=article.publish_time,
)
# 写入 JSON 输出
out_file = out_dir / f"{article.url_hash}.json"
out_file.write_text(
processed.model_dump_json(indent=2, ensure_ascii=False),
encoding="utf-8",
)
results.append(processed)
if processed.status == "success":
success += 1
logger.debug("[%s] ✅ %s (%d words, %s)",
source_id, processed.title[:40], processed.word_count, processed.extractor)
elif processed.status == "no_content":
no_content += 1
logger.warning("[%s] ⚠️ 无正文: %s", source_id, processed.url[:80])
else:
failed += 1
logger.info("[%s] 完成: 成功 %d / 无内容 %d / 失败 %d",
source_id, success, no_content, failed)
# 写入处理索引
index_path = out_dir / "index.jsonl"
with open(index_path, "a", encoding="utf-8") as f:
for r in results:
if r.status == "success":
f.write(json.dumps({
"url_hash": r.url_hash,
"title": r.title,
"word_count": r.word_count,
"extractor": r.extractor,
"publish_time": r.publish_time,
}, ensure_ascii=False) + "\n")
return results
def process_all_sources(
source_filter: str | None = None,
date_str: str | None = None,
) -> dict:
"""处理所有源的文章正文提取
Args:
source_filter: 可选,只处理指定源
date_str: 日期,默认当前新闻日
Returns:
统计 dict
"""
if date_str is None:
date_str = get_news_day()
start_time = datetime.now()
if source_filter:
sources = [source_filter] if source_filter in get_raw_data_sources() else []
else:
sources = get_raw_data_sources()
logger.info("══════ 开始正文提取 %d 个源,日期: %s ══════", len(sources), date_str)
total = 0
for src in sources:
results = process_source(src, date_str)
total += len([r for r in results if r.status == "success"])
elapsed = (datetime.now() - start_time).total_seconds()
logger.info("══════ 提取完成: %d 篇文章,耗时 %.1f 秒 ══════", total, elapsed)
return {
"sources_processed": len(sources),
"total_articles": total,
"elapsed_sec": elapsed,
"date": date_str,
}