# -*- coding: utf-8 -*- """ 数据完整性验证工具 验证机构名单、注册表等数据文件的完整性和格式正确性 使用方式: python scripts/data_validator.py 功能: 1. 验证机构注册表(institution_registry.json) 2. 验证各类机构名单文件(*_list.json) 3. 输出详细的验证报告 """ import json import sys from pathlib import Path from typing import Dict, List, Tuple, Any # 数据目录 SKILL_DATA_DIR = Path(__file__).parent.parent / "data" def validate_institution_registry() -> Tuple[bool, str, Dict[str, Any]]: """ 验证机构注册表 Returns: (是否通过, 消息, 详细信息) """ registry_file = SKILL_DATA_DIR / "institution_registry.json" if not registry_file.exists(): return False, "文件不存在", {"file": "institution_registry.json"} try: with open(registry_file, 'r', encoding='utf-8') as f: data = json.load(f) # 检查格式 if '_f' not in data: return False, "文件格式不正确,缺少 _f 字段", {"file": "institution_registry.json"} if 'd' not in data: return False, "文件格式不正确,缺少 d 字段", {"file": "institution_registry.json"} # 检查列定义 expected_columns = ['id', 'name', 'code', 'type', 'data_source', 'update_time', 'website', 'url_source'] actual_columns = data.get('c', []) if actual_columns != expected_columns: return False, f"列定义不匹配", { "expected": expected_columns, "actual": actual_columns } # 检查数据行数 institutions = data['d'] if len(institutions) == 0: return False, "机构数据为空", {"file": "institution_registry.json"} # 统计各类型机构 type_counts = {} for row in institutions: if len(row) >= 4: inst_type = row[3] type_counts[inst_type] = type_counts.get(inst_type, 0) + 1 return True, f"验证通过,共 {len(institutions)} 家机构", { "file": "institution_registry.json", "total": len(institutions), "types": type_counts } except json.JSONDecodeError as e: return False, f"JSON 解析错误: {e}", {"file": "institution_registry.json"} except Exception as e: return False, f"验证失败: {e}", {"file": "institution_registry.json"} def validate_list_file(filepath: Path) -> Tuple[bool, str, Dict[str, Any]]: """ 验证单个机构名单文件 Args: filepath: 文件路径 Returns: (是否通过, 消息, 详细信息) """ filename = filepath.name try: with open(filepath, 'r', encoding='utf-8') as f: data = json.load(f) # 检查基本结构 if not isinstance(data, dict): return False, "文件格式不正确,应为 JSON 对象", {"file": filename} # 检查 type 字段 if 'type' not in data: return False, "缺少 type 字段", {"file": filename} # 检查 institutions 字段 if 'institutions' not in data: return False, "缺少 institutions 字段", {"file": filename} institutions = data['institutions'] if not isinstance(institutions, list): return False, "institutions 字段应为数组", {"file": filename} if len(institutions) == 0: return False, "机构列表为空", {"file": filename} # 检查每条记录 invalid_records = [] for i, inst in enumerate(institutions): if not isinstance(inst, dict): invalid_records.append(f"记录 {i}: 不是对象") continue if 'name' not in inst: invalid_records.append(f"记录 {i}: 缺少 name 字段") if invalid_records: return False, f"数据格式错误", { "file": filename, "errors": invalid_records[:5] # 只显示前5个错误 } # 提取元数据 metadata = { "file": filename, "type": data.get('type', '未知'), "count": len(institutions), "data_source": data.get('data_source', '未知'), "update_time": data.get('update_time', '未知') } return True, f"验证通过,共 {len(institutions)} 家机构", metadata except json.JSONDecodeError as e: return False, f"JSON 解析错误: {e}", {"file": filename} except Exception as e: return False, f"验证失败: {e}", {"file": filename} def validate_all_list_files() -> Dict[str, Tuple[bool, str, Dict[str, Any]]]: """ 验证所有机构名单文件 Returns: {文件名: (是否通过, 消息, 详细信息)} """ results = {} # 查找所有 *_list.json 文件 list_files = sorted(SKILL_DATA_DIR.glob("*_list.json")) if not list_files: return {"_error": (False, "未找到 *_list.json 文件", {"dir": str(SKILL_DATA_DIR)})} for filepath in list_files: results[filepath.name] = validate_list_file(filepath) return results def run_full_validation() -> Dict[str, Any]: """ 运行完整验证 Returns: 验证结果字典 """ results = { "registry": None, "list_files": {}, "summary": { "total": 0, "passed": 0, "failed": 0, "total_institutions": 0, "types": {} } } # 验证注册表 registry_ok, registry_msg, registry_details = validate_institution_registry() results["registry"] = { "passed": registry_ok, "message": registry_msg, "details": registry_details } results["summary"]["total"] += 1 if registry_ok: results["summary"]["passed"] += 1 results["summary"]["total_institutions"] += registry_details.get("total", 0) else: results["summary"]["failed"] += 1 # 验证名单文件 list_results = validate_all_list_files() for filename, (passed, message, details) in list_results.items(): results["list_files"][filename] = { "passed": passed, "message": message, "details": details } results["summary"]["total"] += 1 if passed: results["summary"]["passed"] += 1 results["summary"]["total_institutions"] += details.get("count", 0) inst_type = details.get("type", "未知") results["summary"]["types"][inst_type] = details.get("count", 0) else: results["summary"]["failed"] += 1 return results def print_validation_report(results: Dict[str, Any]): """ 打印验证报告 Args: results: 验证结果 """ print("\n" + "=" * 60) print(" 数据完整性验证报告") print("=" * 60) # 注册表 registry = results["registry"] status = "✓" if registry["passed"] else "✗" print(f"\n{status} institution_registry.json: {registry['message']}") if registry["passed"] and "types" in registry.get("details", {}): types = registry["details"]["types"] for inst_type, count in sorted(types.items(), key=lambda x: -x[1])[:5]: print(f" - {inst_type}: {count} 家") # 名单文件 print("\n机构名单文件:") for filename, result in sorted(results["list_files"].items()): status = "✓" if result["passed"] else "✗" print(f" {status} {filename}: {result['message']}") # 汇总 summary = results["summary"] print(f"\n{'=' * 60}") print(f" 汇总") print(f"{'=' * 60}") print(f" 文件验证: {summary['passed']}/{summary['total']} 通过") print(f" 机构总数: {summary['total_institutions']} 家") if summary.get("types"): print(f"\n 机构类型分布:") for inst_type, count in sorted(summary["types"].items(), key=lambda x: -x[1]): print(f" - {inst_type}: {count} 家") # 结论 print(f"\n{'=' * 60}") if summary["failed"] == 0: print(" ✓ 所有数据文件验证通过!") else: print(f" ⚠ 有 {summary['failed']} 个文件验证失败") print(" 请检查 data/ 目录下的 JSON 文件是否完整") print("=" * 60) def main(): """主函数""" print("开始数据完整性验证...") # 运行验证 results = run_full_validation() # 打印报告 print_validation_report(results) # 返回状态码 if results["summary"]["failed"] == 0: sys.exit(0) else: sys.exit(1) # ── v4.6: 日期窗口核验 ────────────────────────────────────── def validate_date_window(articles: List[Dict[str, Any]], requested_days: int = 7, cutoff_date: str = "") -> Dict[str, Any]: """验证文章列表是否在请求的日期窗口内。 Args: articles: 文章列表 [{"title": ..., "publish_time": "2026-07-28", ...}, ...] requested_days: 请求的时间窗口(天) cutoff_date: 截止日期 "YYYY-MM-DD"(留空=按 requested_days 自动计算) Returns: { "total": 50, "in_window": 48, "out_of_window": 2, "unparseable": 0, "out_articles": [{"title": "...", "publish_time": "2026-03-15", "age_days": 137}, ...], "date_range_actual": {"earliest": "2026-07-24", "latest": "2026-07-30"}, "requested_window": "2026-07-23 ~ 2026-07-30", "pass": True/False, # True 表示所有文章在窗内 } """ from datetime import datetime, timedelta if cutoff_date: cutoff = datetime.strptime(cutoff_date, "%Y-%m-%d") else: cutoff = datetime.now() - timedelta(days=requested_days) total = len(articles) in_window = 0 out_of_window = 0 unparseable = 0 out_articles: List[Dict[str, Any]] = [] dates_found: List[str] = [] for a in articles: pub = a.get("publish_time", "") if not pub: unparseable += 1 continue try: pub_str = str(pub)[:10] pub_dt = datetime.strptime(pub_str, "%Y-%m-%d") dates_found.append(pub_str) if pub_dt >= cutoff: in_window += 1 else: out_of_window += 1 out_articles.append({ "title": (a.get("title", "") or "")[:60], "publish_time": pub, "age_days": (datetime.now() - pub_dt).days, "source": a.get("source", ""), }) except (ValueError, IndexError): unparseable += 1 actual_range = {} if dates_found: dates_found.sort() actual_range = {"earliest": dates_found[0], "latest": dates_found[-1]} return { "total": total, "in_window": in_window, "out_of_window": out_of_window, "unparseable": unparseable, "out_articles": out_articles[:10], "date_range_actual": actual_range, "requested_window": f"{cutoff.strftime('%Y-%m-%d')} ~ {datetime.now().strftime('%Y-%m-%d')}", "pass": out_of_window == 0, } if __name__ == "__main__": main()