#!/usr/bin/env python3 """Merge Jev verdicts into the final gmeow ad corpus + stats/report. Inputs : data/cleaned/gmeow/ads_jev.jsonl (one row per unique text) Outputs: data/cleaned/gmeow/ads_final.jsonl label=ad, kind != none data/cleaned/gmeow/rejected.jsonl Jev said clean / kind none data/cleaned/gmeow/campaigns.jsonl near-dup clusters data/cleaned/gmeow/stats.json data/cleaned/gmeow/report.md """ from __future__ import annotations import collections import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from clean_gmeow_ads import normalize, parse_source_msg, signature # noqa: E402 OUT = Path("data/cleaned/gmeow") STORE = Path("data/raw/gmeow/bans.jsonl") def load(p: Path) -> list[dict]: if not p.exists(): return [] return [json.loads(l) for l in p.open(encoding="utf-8") if l.strip()] def main() -> int: rows = load(OUT / "ads_jev.jsonl") latest: dict[str, dict] = {} for r in rows: # last judgement wins if a text was re-judged latest[r["text"]] = r rows = list(latest.values()) ads = [r for r in rows if r["label"] == "ad" and r["kind"] != "none"] rejected = [r for r in rows if r not in ads] ads.sort(key=lambda r: (-r["n_accounts"], -r["n_records"], r["text"])) # account/chat coverage measured on raw records, not summed per text accounts: set[int] = set() chats: set[int] = set() ad_texts = {r["text"] for r in ads} if STORE.exists(): for line in STORE.open(encoding="utf-8"): try: rec = json.loads(line) except Exception: # noqa: BLE001 continue _, msg = parse_source_msg(rec["info"]) if normalize(msg) in ad_texts: accounts.add(rec["tgid"]) if rec["chat_id"] is not None: chats.add(rec["chat_id"]) with (OUT / "ads_final.jsonl").open("w", encoding="utf-8") as fh: for r in ads: fh.write(json.dumps({ "text": r["text"], "label": "ad", "kind": r["kind"], "scope": r["scope"], "severity": r["severity"], "conf": r["jev_conf"], "quality": r["quality"], "rule_kind": r["rule_kind"], "n_records": r["n_records"], "n_accounts": r["n_accounts"], "n_chats": r["n_chats"], "reason": r["reason"], "channel": r["channel"], "first_seen": r["first_seen"], "last_seen": r["last_seen"], }, ensure_ascii=False) + "\n") with (OUT / "rejected.jsonl").open("w", encoding="utf-8") as fh: for r in rejected: fh.write(json.dumps({ "text": r["text"], "label": r["label"], "kind": r["kind"], "reason": r["reason"], "n_records": r["n_records"], "rule_kind": r["rule_kind"], "jev_conf": r["jev_conf"], }, ensure_ascii=False) + "\n") campaigns: dict[str, list[dict]] = collections.defaultdict(list) for r in ads: campaigns[signature(r["text"])].append(r) camp_rows = [] for sig, members in campaigns.items(): members.sort(key=lambda r: -r["n_accounts"]) camp_rows.append({ "campaign": sig, "variants": len(members), "accounts": sum(m["n_accounts"] for m in members), "kind": collections.Counter(m["kind"] for m in members).most_common(1)[0][0], "canonical": members[0]["text"], }) camp_rows.sort(key=lambda c: (-c["accounts"], -c["variants"])) with (OUT / "campaigns.jsonl").open("w", encoding="utf-8") as fh: for c in camp_rows: fh.write(json.dumps(c, ensure_ascii=False) + "\n") kind_dist = collections.Counter(r["kind"] for r in ads) stats = { "unique_texts_judged": len(rows), "ads": len(ads), "rejected": len(rejected), "rejected_detail": dict(collections.Counter( f"{r['label']}/{r['kind']}" for r in rejected)), "kind_dist": dict(kind_dist), "scope_dist": dict(collections.Counter(r["scope"] for r in ads)), "severity_avg": round(sum(r["severity"] or 0 for r in ads) / max(1, len(ads)), 2), "severity_hist": dict(collections.Counter( round(r["severity"] or 0) for r in ads)), "accounts_covered": len(accounts), "chats_covered": len(chats), "raw_records_covered": sum(r["n_records"] for r in ads), "campaigns": len(camp_rows), "rule_agreement": round( sum(1 for r in ads if r["rule_kind"] == r["kind"]) / max(1, len(ads)), 3), "quality_flags": dict(collections.Counter( f for r in ads for f in r["quality"])), "time_span": [min(r["first_seen"] for r in ads), max(r["last_seen"] for r in ads)] if ads else [], } (OUT / "stats.json").write_text( json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8") lines = [ "# gmeow 封禁广告语料 · 初次清理(Jev 实时判定)", "", f"- 去重文本:{stats['unique_texts_judged']},Jev 判为广告 {stats['ads']}," f"剔除 {stats['rejected']}({stats['rejected_detail']})", f"- 覆盖封禁记录 {stats['raw_records_covered']} 条 / 账号 " f"{stats['accounts_covered']} 个 / 时间跨度 {stats['time_span']}", f"- 类别分布:{stats['kind_dist']}", f"- 载体:{stats['scope_dist']};严重度均值 {stats['severity_avg']}" f"(分布 {stats['severity_hist']})", f"- 近重复战役簇 {stats['campaigns']} 个;规则与 Jev 一致率 " f"{stats['rule_agreement']}", "", "## 每类样例(按覆盖账号数排序)", "", ] for kind in ("scam", "promo", "porn", "crypto", "gambling", "porn_bait", "spam_flood"): top = [r for r in ads if r["kind"] == kind][:5] if not top: continue lines += [f"### {kind}({kind_dist[kind]} 条)", ""] for r in top: text = r["text"].replace("\n", " ⏎ ") lines.append(f"- `{r['n_accounts']}号` {text[:110]}") lines.append("") lines += ["## 最大战役簇", "", "| 账号数 | 变体 | 类别 | 代表文本 |", "|---:|---:|---|---|"] for c in camp_rows[:10]: text = c["canonical"].replace("|", "\\|").replace("\n", " ") lines.append(f"| {c['accounts']} | {c['variants']} | {c['kind']} | {text[:70]} |") lines += [ "", "## 文件与复跑", "", "| 文件 | 内容 |", "|---|---|", "| `data/raw/gmeow/bans.jsonl` | 原始封禁记录(采集器增量去重写入) |", "| `data/cleaned/gmeow/ads_jev.jsonl` | 每条唯一文本的 Jev 四问判定(实时追加) |", "| `data/cleaned/gmeow/ads_final.jsonl` | 最终广告语料(label=ad 且类别非 none) |", "| `data/cleaned/gmeow/rejected.jsonl` | Jev 判非广告/无类别的剔除项(含禁言次数,供复核) |", "| `data/cleaned/gmeow/campaigns.jsonl` | 联系人/链接/数字归一后的近重复战役簇 |", "", "```bash", "# 持续采集(页面只保留最近 100 条,约 4 条/分钟)", "python scripts/collect_gmeow_bans.py --interval 45 --duration 7200", "# 实时 Jev 清洗(新增文本自动判定,已判过的跳过)", "python scripts/jev_clean_gmeow.py --watch --interval 20", "# 重新生成最终语料与统计", "python scripts/report_gmeow.py", "```", ] (OUT / "report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") print(json.dumps(stats, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())