321 lines
12 KiB
Python
321 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
数据分析汇总引擎 v7.3.0 (data_aggregator.py)
|
||
|
||
增强数据分析与汇总能力:把多次爬取/舆情快照的结果做跨源聚合、去重、
|
||
统计与汇总报告输出。纯函数 + 标准库实现,零 pip 依赖。
|
||
|
||
能力:
|
||
- dedupe_articles : 跨快照模糊去重(标题字符二元组相似度)
|
||
- sentiment_statistics : 情感/严重度/时间/来源分布 + 热词统计
|
||
- cross_section_summary : 按目标/来源/类型截面汇总
|
||
- aggregate_snapshots : 聚合 data/sentiment_snapshots/ 全部快照
|
||
- build_institution_brief: 机构简报(名录信息 + 相关舆情统计)
|
||
- export_markdown : 汇总结果导出 Markdown / JSON
|
||
|
||
用法:
|
||
from scripts.data_aggregator import aggregate_snapshots, export_markdown
|
||
agg = aggregate_snapshots() # 默认 data/sentiment_snapshots
|
||
md = export_markdown(agg)
|
||
print(md)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from collections import Counter, defaultdict
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||
DATA_DIR = SKILL_DIR / "data"
|
||
SNAPSHOT_DIR = DATA_DIR / "sentiment_snapshots"
|
||
REGISTRY_FILE = DATA_DIR / "institution_registry.json"
|
||
|
||
_PUNCT_RE = re.compile(r"[\s\u3000||\-—_,,.。!!??::;;\"'“”‘’()()【】\[\]《》<>()]+")
|
||
|
||
|
||
def normalize_title(title: str) -> str:
|
||
"""标题规范化:去标点/空白、小写化,用于跨源去重。"""
|
||
return _PUNCT_RE.sub("", (title or "")).lower()
|
||
|
||
|
||
def _bigrams(text: str) -> set:
|
||
if len(text) < 2:
|
||
return {text} if text else set()
|
||
return {text[i:i + 2] for i in range(len(text) - 1)}
|
||
|
||
|
||
def title_similarity(a: str, b: str) -> float:
|
||
"""标题相似度(Jaccard 与包含度混合,字符二元组),0-1。
|
||
|
||
包含度分量让转发/转载类标题(仅追加“(转发)”等后缀)也能判重;
|
||
乘 0.9 衰减避免短标题被长标题无条件吞并。
|
||
"""
|
||
sa, sb = _bigrams(normalize_title(a)), _bigrams(normalize_title(b))
|
||
if not sa or not sb:
|
||
return 0.0
|
||
inter = len(sa & sb)
|
||
jaccard = inter / len(sa | sb)
|
||
containment = inter / min(len(sa), len(sb))
|
||
return max(jaccard, 0.9 * containment)
|
||
|
||
|
||
def dedupe_articles(articles: List[Dict[str, Any]],
|
||
threshold: float = 0.85) -> List[Dict[str, Any]]:
|
||
"""模糊去重:标题相似 > threshold 视为重复,保留先出现的。
|
||
|
||
复杂度 O(n·k)(k 为已保留数),对几百条快照足够快。
|
||
"""
|
||
kept: List[Dict[str, Any]] = []
|
||
kept_titles: List[str] = []
|
||
exact: set = set()
|
||
for art in articles:
|
||
title = art.get("title") or ""
|
||
key = normalize_title(title)
|
||
if not key or key in exact:
|
||
continue
|
||
dup = False
|
||
for prev in kept_titles:
|
||
if title_similarity(title, prev) > threshold:
|
||
dup = True
|
||
break
|
||
if dup:
|
||
continue
|
||
exact.add(key)
|
||
kept.append(art)
|
||
kept_titles.append(title)
|
||
return kept
|
||
|
||
|
||
def sentiment_statistics(articles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||
"""舆情统计:情感/严重度/时间/来源分布 + 高频关键词。"""
|
||
senti = Counter(a.get("sentiment", "neutral") for a in articles)
|
||
severity = Counter(a.get("severity", "中性") for a in articles)
|
||
source = Counter((a.get("source") or "未知") for a in articles)
|
||
|
||
by_day: Dict[str, int] = defaultdict(int)
|
||
for a in articles:
|
||
pt = (a.get("publish_time") or a.get("fetch_time") or "").strip()
|
||
m = re.match(r"(20\d{2}[-/]\d{1,2}[-/]\d{1,2})", pt)
|
||
if m:
|
||
by_day[m.group(1).replace("/", "-")] += 1
|
||
|
||
keywords: Counter = Counter()
|
||
for a in articles:
|
||
for k in (a.get("keywords_matched") or []):
|
||
keywords[k] += 1
|
||
|
||
scores = [float(a.get("sentiment_score") or 0) for a in articles]
|
||
neg = [s for s in scores if s < 0]
|
||
pos = [s for s in scores if s > 0]
|
||
return {
|
||
"total": len(articles),
|
||
"sentiment": dict(senti),
|
||
"severity": dict(severity),
|
||
"source_top": source.most_common(10),
|
||
"by_day": dict(sorted(by_day.items())),
|
||
"hot_keywords": keywords.most_common(20),
|
||
"avg_score": round(sum(scores) / len(scores), 3) if scores else 0.0,
|
||
"neg_ratio": round(len(neg) / len(articles), 3) if articles else 0.0,
|
||
"pos_ratio": round(len(pos) / len(articles), 3) if articles else 0.0,
|
||
}
|
||
|
||
|
||
def cross_section_summary(articles: List[Dict[str, Any]],
|
||
group_by: str = "target_name") -> List[Dict[str, Any]]:
|
||
"""截面汇总:按字段分组统计(默认按目标机构)。"""
|
||
groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||
for a in articles:
|
||
groups[(a.get(group_by) or "未知")].append(a)
|
||
out = []
|
||
for key, items in groups.items():
|
||
stats = sentiment_statistics(items)
|
||
out.append({
|
||
group_by: key,
|
||
"count": len(items),
|
||
"negative": stats["sentiment"].get("negative", 0),
|
||
"positive": stats["sentiment"].get("positive", 0),
|
||
"neg_ratio": stats["neg_ratio"],
|
||
"severity_top": max(stats["severity"], key=stats["severity"].get,
|
||
default="中性"),
|
||
})
|
||
out.sort(key=lambda x: (-x["neg_ratio"], -x["count"]))
|
||
return out
|
||
|
||
|
||
def load_snapshots(snapshot_dir: Optional[Path] = None) -> List[Dict[str, Any]]:
|
||
"""加载快照目录内全部 *.json(损坏文件跳过)。"""
|
||
d = Path(snapshot_dir) if snapshot_dir else SNAPSHOT_DIR
|
||
out = []
|
||
if not d.exists():
|
||
return out
|
||
for f in sorted(d.glob("*.json")):
|
||
if f.name.startswith("_"):
|
||
continue
|
||
try:
|
||
doc = json.loads(f.read_text(encoding="utf-8"))
|
||
if isinstance(doc, dict) and isinstance(doc.get("articles"), list):
|
||
out.append(doc)
|
||
except Exception:
|
||
continue
|
||
return out
|
||
|
||
|
||
def aggregate_snapshots(snapshot_dir: Optional[Path] = None,
|
||
days: int = 0) -> Dict[str, Any]:
|
||
"""聚合全部快照:跨快照去重 + 统计 + 截面汇总。
|
||
|
||
Args:
|
||
snapshot_dir: 快照目录(默认 data/sentiment_snapshots)
|
||
days: 仅统计最近 N 天(0=全部)
|
||
"""
|
||
snapshots = load_snapshots(snapshot_dir)
|
||
articles: List[Dict[str, Any]] = []
|
||
for snap in snapshots:
|
||
articles.extend(snap.get("articles") or [])
|
||
|
||
if days > 0:
|
||
cutoff = datetime.now().timestamp() - days * 86400
|
||
kept = []
|
||
for a in articles:
|
||
pt = (a.get("publish_time") or a.get("fetch_time") or "").strip()
|
||
try:
|
||
ts = datetime.strptime(pt[:19], "%Y-%m-%d %H:%M:%S").timestamp()
|
||
except Exception:
|
||
kept.append(a) # 无时间的保留
|
||
continue
|
||
if ts >= cutoff:
|
||
kept.append(a)
|
||
articles = kept
|
||
|
||
deduped = dedupe_articles(articles)
|
||
return {
|
||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"snapshot_count": len(snapshots),
|
||
"raw_count": len(articles),
|
||
"dedup_count": len(deduped),
|
||
"dedup_removed": len(articles) - len(deduped),
|
||
"statistics": sentiment_statistics(deduped),
|
||
"by_target": cross_section_summary(deduped, "target_name"),
|
||
"by_source": cross_section_summary(deduped, "source"),
|
||
"articles": deduped,
|
||
}
|
||
|
||
|
||
def build_institution_brief(name: str,
|
||
snapshot_dir: Optional[Path] = None) -> Dict[str, Any]:
|
||
"""机构简报:名录信息 + 该机构相关舆情统计。"""
|
||
brief: Dict[str, Any] = {"name": name, "registry": None,
|
||
"sentiment": None}
|
||
# 1) 名录信息(直接读紧凑 registry,避免重依赖)
|
||
try:
|
||
doc = json.loads(REGISTRY_FILE.read_text(encoding="utf-8"))
|
||
cols, rows = doc["c"], doc["d"]
|
||
i_name = cols.index("name")
|
||
for row in rows:
|
||
if name in (row[i_name] or ""):
|
||
brief["registry"] = dict(zip(cols, row))
|
||
break
|
||
except Exception:
|
||
pass
|
||
# 2) 相关舆情
|
||
agg = aggregate_snapshots(snapshot_dir)
|
||
related = [a for a in agg["articles"]
|
||
if name in (a.get("target_name") or "") or name in (a.get("title") or "")]
|
||
if related:
|
||
brief["sentiment"] = sentiment_statistics(related)
|
||
brief["recent_titles"] = [a.get("title", "") for a in related[:5]]
|
||
return brief
|
||
|
||
|
||
def export_markdown(agg: Dict[str, Any], path: Optional[Path] = None,
|
||
top_articles: int = 10) -> str:
|
||
"""把聚合结果导出为 Markdown 汇总报告。返回文本;给 path 则落盘。"""
|
||
st = agg.get("statistics") or {}
|
||
lines = [
|
||
"# 金融数据爬取汇总报告",
|
||
"",
|
||
f"- 生成时间:{agg.get('generated_at', '')}",
|
||
f"- 快照数:{agg.get('snapshot_count', 0)},"
|
||
f"原始条目 {agg.get('raw_count', 0)},去重后 {agg.get('dedup_count', 0)}"
|
||
f"(剔除重复 {agg.get('dedup_removed', 0)})",
|
||
"",
|
||
"## 情感分布",
|
||
"",
|
||
"| 情感 | 数量 |", "|---|---|",
|
||
]
|
||
for k, v in (st.get("sentiment") or {}).items():
|
||
lines.append(f"| {k} | {v} |")
|
||
lines += ["", f"负面占比 {st.get('neg_ratio', 0):.1%},"
|
||
f"正面占比 {st.get('pos_ratio', 0):.1%},"
|
||
f"平均分 {st.get('avg_score', 0)}", ""]
|
||
|
||
hot = st.get("hot_keywords") or []
|
||
if hot:
|
||
lines += ["## 高频关键词", "",
|
||
"、".join(f"{k}({v})" for k, v in hot[:15]), ""]
|
||
|
||
by_day = st.get("by_day") or {}
|
||
if by_day:
|
||
lines += ["## 时间分布", "", "| 日期 | 数量 |", "|---|---|"]
|
||
for d, n in by_day.items():
|
||
lines.append(f"| {d} | {n} |")
|
||
lines.append("")
|
||
|
||
by_target = agg.get("by_target") or []
|
||
if by_target:
|
||
lines += ["## 目标机构截面", "",
|
||
"| 机构 | 条数 | 负面 | 负面占比 | 最高等级 |",
|
||
"|---|---|---|---|---|"]
|
||
for row in by_target[:20]:
|
||
lines.append(f"| {row.get('target_name', '')} | {row['count']} | "
|
||
f"{row['negative']} | {row['neg_ratio']:.1%} | "
|
||
f"{row['severity_top']} |")
|
||
lines.append("")
|
||
|
||
articles = agg.get("articles") or []
|
||
if articles:
|
||
lines += ["## 重点条目(前 %d)" % top_articles, ""]
|
||
for a in articles[:top_articles]:
|
||
sev = a.get("severity", "")
|
||
lines.append(f"- **[{a.get('sentiment', '')}/{sev}]** "
|
||
f"{a.get('title', '')} \n"
|
||
f" 来源:{a.get('source', '')} "
|
||
f"时间:{a.get('publish_time', '')}")
|
||
lines.append("")
|
||
|
||
md = "\n".join(lines)
|
||
if path:
|
||
Path(path).write_text(md, encoding="utf-8")
|
||
return md
|
||
|
||
|
||
def export_json(agg: Dict[str, Any], path: Path) -> Path:
|
||
"""聚合结果落盘 JSON(含全部条目)。"""
|
||
p = Path(path)
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(json.dumps(agg, ensure_ascii=False, indent=1),
|
||
encoding="utf-8")
|
||
return p
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
ap = argparse.ArgumentParser(description="v7.3.0 数据分析汇总引擎")
|
||
ap.add_argument("--dir", default="", help="快照目录(默认 data/sentiment_snapshots)")
|
||
ap.add_argument("--days", type=int, default=0, help="仅统计最近 N 天")
|
||
ap.add_argument("--output", default="", help="Markdown 输出路径(默认仅打印)")
|
||
ap.add_argument("--brief", default="", help="生成某机构简报")
|
||
args = ap.parse_args()
|
||
|
||
if args.brief:
|
||
print(json.dumps(build_institution_brief(args.brief),
|
||
ensure_ascii=False, indent=1))
|
||
else:
|
||
agg = aggregate_snapshots(Path(args.dir) if args.dir else None,
|
||
days=args.days)
|
||
md = export_markdown(agg, Path(args.output) if args.output else None)
|
||
print(md)
|