"""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 + 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") # 提取 {...} 中的 JSON m = re.search(r"(.+?)", 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())