Initial commit
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user