Files
news/scripts/run_extractor.py
2026-07-18 15:51:01 +08:00

357 lines
12 KiB
Python

"""M2 批量正文提取入口脚本。
输入: data/raw/{source}/{YYYYMMDD}/index.jsonl (M1 产物)
输出: data/processed/{source}/{YYYYMMDD}/{url_hash}.json (Article)
data/processed/{source}/{YYYYMMDD}/index.jsonl (扁平元数据,便于检索)
用法:
uv run python -m scripts.run_extractor # 处理今日所有源
uv run python -m scripts.run_extractor --date 20260616
uv run python -m scripts.run_extractor --source sina --date 20260616
uv run python -m scripts.run_extractor --raw-root data/raw --out-root data/processed
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import date, datetime
from pathlib import Path
from loguru import logger
from extractor import Article, ExtractError, extract_article
from extractor.parser import _url_hash
def _setup_logger(level: str) -> None:
logger.remove()
logger.add(
sys.stderr,
level=level,
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
)
log_path = Path("logs") / "extractor.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
logger.add(log_path, level="DEBUG", rotation="10 MB", retention=5, encoding="utf-8")
def _iter_article_records(raw_dir: Path) -> list[dict]:
"""读取 M1 产物的 index.jsonl,返回所有可处理的记录。
新闻源: stage=article + success + html_file
cninfo: 有 json_file 字段(CninfoItem 格式)
"""
index_path = raw_dir / "index.jsonl"
if not index_path.is_file():
return []
out: list[dict] = []
with index_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError as e:
logger.warning("跳过非法 jsonl 行 in {}: {}", index_path, e)
continue
# cninfo: 新格式(CninfoItem → json_file)
if rec.get("source_id") == "cninfo" or rec.get("json_file"):
if rec.get("json_file"):
out.append(rec)
continue
# 新闻源: 旧格式(CrawlResult → html_file)
if rec.get("stage") == "article" and rec.get("success") and rec.get("html_file"):
out.append(rec)
return out
def _process_one(rec: dict, raw_dir: Path, out_dir: Path,
body_xpath_map: dict[str, str] | None = None) -> Article | None:
"""处理单条记录,失败返回 None。cninfo 等结构化源跳过 GNE。"""
src_id = rec.get("source_id", "")
# cninfo: 新格式(json_file)或旧格式(html_file+meta JSON)直接解析
json_file = rec.get("json_file", "")
html_file = rec.get("html_file", "")
is_cninfo = (src_id == "cninfo" or bool(json_file))
if is_cninfo:
if json_file:
json_path = raw_dir / json_file
if json_path.is_file():
return _process_cninfo_v2(rec, json_path, out_dir)
else:
logger.warning("cninfo JSON 文件丢失: {}", json_path)
return None
# 旧格式兼容: html_file + <meta>JSON
if html_file:
html_path = raw_dir / html_file
if html_path.is_file():
return _process_cninfo(rec, html_path, out_dir)
return None
html_path = raw_dir / html_file
if not html_path or not html_path.is_file():
logger.warning("HTML 文件丢失: {}", html_path)
return None
html = html_path.read_text(encoding="utf-8", errors="ignore")
extra_config: dict[str, str] = {}
if body_xpath_map and src_id in body_xpath_map:
extra_config["body_xpath"] = body_xpath_map[src_id]
try:
article = extract_article(
html=html,
source_id=src_id,
url=rec["url"],
extra_config=extra_config or None,
)
except ExtractError as e:
logger.warning("提取失败 {} {}: {}", rec["source_id"], rec["url"], e.reason)
return None
return _save_article(article, out_dir)
def _process_cninfo(rec: dict, html_path: Path, out_dir: Path) -> Article | None:
"""处理 cninfo 公告记录:从 meta JSON 解析结构化数据。"""
import re
html = html_path.read_text(encoding="utf-8", errors="ignore")
# 提取 <meta>{...}</meta> 中的 JSON
m = re.search(r"<meta>(.+?)</meta>", html, re.DOTALL)
if not m:
logger.warning("cninfo HTML 不含 meta JSON: {}", html_path)
return None
try:
meta = json.loads(m.group(1))
except json.JSONDecodeError:
logger.warning("cninfo meta JSON 解析失败: {}", html_path)
return None
sec_code = (meta.get("secCode") or "").strip()
sec_name = (meta.get("secName") or "").strip()
ann_type = (meta.get("announcementType") or "").strip()
pdf_url = (meta.get("pdfUrl") or "").strip()
title = rec.get("title") or meta.get("title") or ""
# 构建正文:结构化摘要 + 公告类别翻译
content_parts = [f"公司: {sec_name}({sec_code})", f"公告标题: {title}"]
if ann_type:
content_parts.append(f"公告类别编码: {ann_type}")
if pdf_url:
content_parts.append(f"PDF: {pdf_url}")
content = "\n".join(content_parts)
# 时间
fetched = rec.get("fetched_at")
publish_time = None
if isinstance(fetched, str):
try:
from datetime import datetime as dt
publish_time = dt.fromisoformat(fetched)
except ValueError:
pass
article = Article(
source_id="cninfo",
url=rec.get("url") or "",
url_hash=_url_hash(rec.get("url") or ""),
title=title,
content=content,
author=sec_name,
source_name="巨潮资讯网",
publish_time=publish_time,
publish_time_raw=fetched,
word_count=len(content),
)
return _save_article(article, out_dir)
def _process_cninfo_v2(rec: dict, json_path: Path, out_dir: Path) -> Article | None:
"""处理 cninfo v2 格式: 直接读取 CninfoItem JSON 并转换为 Article。"""
try:
item_data = json.loads(json_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning("cninfo JSON 读取失败 {}: {}", json_path, e)
return None
stock_code = item_data.get("stock_code", "")
stock_name = item_data.get("stock_name", "")
title = item_data.get("title", "")
content = item_data.get("content", "")
publish_time_str = item_data.get("publish_time", "")
item_type = item_data.get("item_type", "announcement")
url = item_data.get("url", "")
extra = item_data.get("extra", {})
# 类型中文映射
type_map = {
"announcement": "公告",
"research": "投资者调研",
"irm": "互动问答",
}
type_cn = type_map.get(item_type, item_type)
# 构建正文
content_parts = [
f"公司: {stock_name}({stock_code})",
f"类型: {type_cn}",
f"标题: {title}",
]
if extra.get("announcement_type"):
content_parts.append(f"公告类别: {extra['announcement_type']}")
if url:
content_parts.append(f"原文链接: {url}")
if content:
content_parts.append(f"\n正文:\n{content}")
full_content = "\n".join(content_parts)
# 发布时间解析
publish_time = None
publish_time_raw = publish_time_str
if publish_time_str:
try:
from datetime import datetime as dt
publish_time = dt.strptime(publish_time_str, "%Y-%m-%d")
except ValueError:
try:
publish_time = dt.fromisoformat(publish_time_str)
except ValueError:
pass
article = Article(
source_id="cninfo",
url=url,
url_hash=_url_hash(url or title),
title=title,
content=full_content,
author=stock_name,
source_name="巨潮资讯网",
publish_time=publish_time,
publish_time_raw=publish_time_raw,
word_count=len(full_content),
item_type=item_type,
)
return _save_article(article, out_dir)
def _save_article(article: Article, out_dir: Path) -> Article:
"""保存 Article JSON 并追加 index。"""
out_dir.mkdir(parents=True, exist_ok=True)
article_path = out_dir / f"{article.url_hash}.json"
article_path.write_text(article.model_dump_json(indent=2), encoding="utf-8")
flat = article.model_dump(exclude={"content", "images"}, mode="json")
flat["article_file"] = article_path.name
flat["content_preview"] = article.content[:80]
with (out_dir / "index.jsonl").open("a", encoding="utf-8") as f:
f.write(json.dumps(flat, ensure_ascii=False) + "\n")
return article
def _process_source_day(
source_id: str,
day: str,
raw_root: Path,
out_root: Path,
body_xpath_map: dict[str, str] | None = None,
) -> tuple[int, int]:
"""处理单个源单日。返回 (成功数, 总数)。"""
raw_dir = raw_root / source_id / day
out_dir = out_root / source_id / day
records = _iter_article_records(raw_dir)
if not records:
logger.info("源 {} 日期 {} 无可处理记录", source_id, day)
return 0, 0
# 清理同日旧的 index.jsonl,避免重复追加
old_index = out_dir / "index.jsonl"
if old_index.exists():
old_index.unlink()
succ = 0
for rec in records:
article = _process_one(rec, raw_dir, out_dir, body_xpath_map)
if article is not None:
succ += 1
total = len(records)
rate = succ / max(total, 1)
logger.info(
"源 {} 日期 {} 提取完成: {}/{} 成功率 {:.0%}",
source_id,
day,
succ,
total,
rate,
)
return succ, total
def _list_source_dirs(raw_root: Path) -> list[str]:
"""列出 raw_root 下所有源 id(子目录名)。"""
if not raw_root.is_dir():
return []
return sorted(p.name for p in raw_root.iterdir() if p.is_dir())
def main() -> int:
parser = argparse.ArgumentParser(description="A 股新闻正文提取 (M2)")
parser.add_argument("--raw-root", default="data/raw", help="M1 抓取产物根目录")
parser.add_argument("--out-root", default="data/processed", help="M2 提取结果根目录")
parser.add_argument("--source", default=None, help="只处理单个源 id,默认全部")
parser.add_argument(
"--date",
default=date.today().strftime("%Y%m%d"),
help="处理日期 YYYYMMDD,默认今日",
)
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
_setup_logger(args.log_level)
raw_root = Path(args.raw_root)
out_root = Path(args.out_root)
sources = [args.source] if args.source else _list_source_dirs(raw_root)
if not sources:
logger.error("{} 下未发现任何源目录", raw_root)
return 2
# 加载源配置,构建 source_id → body_xpath 映射
body_xpath_map: dict[str, str] = {}
try:
from crawler.config import load_crawler_config
cfg = load_crawler_config()
for s in cfg.sources:
if s.body_xpath:
body_xpath_map[s.id] = s.body_xpath
if body_xpath_map:
logger.info("已加载 body_xpath 配置: {}", dict(body_xpath_map))
except Exception:
pass
started = datetime.now()
total_succ = 0
total_all = 0
for src in sources:
succ, total = _process_source_day(src, args.date, raw_root, out_root, body_xpath_map)
total_succ += succ
total_all += total
elapsed = (datetime.now() - started).total_seconds()
rate = total_succ / max(total_all, 1)
logger.info(
"全部完成: {}/{} 成功率 {:.0%} 用时 {:.1f}s",
total_succ,
total_all,
rate,
elapsed,
)
return 0 if rate >= 0.9 or total_all == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())