import argparse import csv import hashlib from collections import Counter from datetime import datetime, timezone, timedelta from pathlib import Path CN_TZ = timezone(timedelta(hours=8)) def read_csv(path: Path): with path.open("r", encoding="utf-8-sig", newline="") as f: return list(csv.DictReader(f)) def write_csv(path: Path, fieldnames, rows): path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() writer.writerows(rows) def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def recovery_plan(source_type: str, archive_status: str): source_type = source_type or "" if archive_status != "HELD_BY_ENV_NETWORK": return ( "ARCHIVE_RECHECK", "review_existing_archive_then_extract_original_line", "archive path/hash, source timestamp, original line or table row, value consistency result", ) if "media" in source_type: return ( "REPLACE_SECONDARY_POINTER_WITH_PRIMARY_SOURCE", "use media page only as locator; obtain exchange filing, company announcement, or annual report as primary source", "primary filing/archive path/hash, source timestamp, original announcement or annual report line", ) if "pdf" in source_type or "annual_report" in source_type or "announcement" in source_type: return ( "MANUAL_DOWNLOAD_OR_BROWSER_ARCHIVE", "download or browser-archive PDF/filing; register local file path and hash before extraction", "local PDF/html path, sha256, source timestamp, operator, original page/table row", ) return ( "BROWSER_SNAPSHOT_OR_APPROVED_NETWORK_ARCHIVE", "retry with browser or approved network path; if still blocked, replace with exchange filing or company IR source", "snapshot/html/pdf path, sha256, source timestamp, operator, original line", ) def build_rows(input_rows, run_id: str, created_at: str): rows = [] for idx, row in enumerate(input_rows, start=1): recovery_type, next_action, required_artifacts = recovery_plan( row.get("source_type", ""), row.get("archive_status", "") ) rows.append( { "archive_recovery_queue_id": f"YS-COMPANY-ARCHIVE-RECOVERY-014-{idx:04d}", "case_id": row.get("case_id", ""), "batch_id": row.get("batch_id", ""), "run_id": run_id, "company_source_archive_review_id": row.get("company_source_archive_review_id", ""), "company_official_source_id": row.get("company_official_source_id", ""), "gap_priority_id": row.get("gap_priority_id", ""), "evidence_card_id": row.get("evidence_card_id", ""), "fact_id": row.get("fact_id", ""), "doc_id": row.get("doc_id", ""), "company_name": row.get("company_name", ""), "stock_code": row.get("stock_code", ""), "source_type": row.get("source_type", ""), "source_name": row.get("source_name", ""), "source_url": row.get("source_url", ""), "source_period_or_date": row.get("source_period_or_date", ""), "prior_archive_status": row.get("archive_status", ""), "prior_fetch_status": row.get("archive_fetch_status", ""), "prior_error": row.get("archive_error", ""), "archive_recovery_type": recovery_type, "queue_status": "QUEUE_ARCHIVE_RECOVERY", "queue_priority": "HIGH", "required_artifacts": required_artifacts, "next_action": next_action, "formal_upgrade_blockers": "archive_path_or_hash_missing; original_line_or_table_row_missing; value_consistency_not_verified", "evidence_status": "ARCHIVE_RECOVERY_DRAFT_QUEUE", "review_status": "DRAFT_FOR_REVIEW", "created_at": created_at, } ) return rows def write_summary(path: Path, output_path: Path, manifest_path: Path, rows, digest: str, created_at: str): by_company = Counter(r["company_name"] for r in rows) by_type = Counter(r["archive_recovery_type"] for r in rows) lines = [ "# 公司官方源归档恢复队列 PASS-014 摘要", "", "状态:DRAFT_FOR_REVIEW", f"生成时间:{created_at}", "", "## 输出", "", f"- 恢复队列表:`{output_path.as_posix()}`", f"- manifest:`{manifest_path.as_posix()}`", f"- 记录数:{len(rows)}", f"- sha256:`{digest}`", "", "## 恢复类型", "", "| 类型 | 数量 |", "|---|---:|", ] for key, count in by_type.items(): lines.append(f"| {key} | {count} |") lines += ["", "## 公司分布", "", "| 公司 | 数量 |", "|---|---:|"] for key, count in by_company.items(): lines.append(f"| {key} | {count} |") lines += [ "", "## 边界", "", "本轮只把 PASS-013 的公司官方源归档失败项拆成浏览器归档、人工下载或替代主源队列。队列不构成归档成功、官方数值核验通过或正式公司证据;正式升级前仍需补本地文件路径、hash、来源时间、操作者记录、原文页/表格行和数值一致性结果。", "", ] path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(lines), encoding="utf-8") def main(): parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) parser.add_argument("--output", required=True) parser.add_argument("--manifest", required=True) parser.add_argument("--summary", required=True) parser.add_argument("--run-id", required=True) args = parser.parse_args() created_at = datetime.now(CN_TZ).isoformat(timespec="seconds") input_path = Path(args.input) output_path = Path(args.output) manifest_path = Path(args.manifest) summary_path = Path(args.summary) rows = build_rows(read_csv(input_path), args.run_id, created_at) fieldnames = list(rows[0].keys()) if rows else [ "archive_recovery_queue_id", "case_id", "batch_id", "run_id", "review_status", ] write_csv(output_path, fieldnames, rows) digest = sha256_file(output_path) manifest_rows = [ { "case_id": rows[0]["case_id"] if rows else "", "batch_id": rows[0]["batch_id"] if rows else "", "run_id": args.run_id, "artifact_type": "company_source_archive_recovery_queue", "artifact_path": output_path.as_posix(), "row_count": str(len(rows)), "sha256": digest, "review_status": "DRAFT_FOR_REVIEW", "created_at": created_at, } ] write_csv(manifest_path, list(manifest_rows[0].keys()), manifest_rows) write_summary(summary_path, output_path, manifest_path, rows, digest, created_at) print(f"OK rows={len(rows)} output={output_path} sha256={digest}") if __name__ == "__main__": main()