#!/usr/bin/env python3 """通用 Telegram Desktop HTML 群聊导出解析器(区别于单发送者的频道导出)。 群导出与频道导出的关键差异: - 多发送者:同发送者连续消息会折叠成 class 含 joined 的条目,没有 from_name, 需要沿用上一个显示的发送者,否则发送者级信息全部错位; - 回复结构 reply_to; - 标签不能走"频道先验=全广告",改用本地模型打分 + 置信度分层, 高置信两端的可直接当伪标签,中间带标 needs_review 等人工裁决。 """ from __future__ import annotations import argparse import collections import datetime as dt import hashlib import json import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from build_channel_dataset import DomBuilder, parse_date # noqa: E402 PHOTO_RE = re.compile(r"^photos/photo_(\d+)@", re.IGNORECASE) def parse_file(path: str, last_sender: dict) -> list[dict]: with open(path, encoding="utf-8", errors="replace") as fh: builder = DomBuilder() builder.feed(fh.read()) out = [] for node in builder.root.iter(): if node.tag != "div" or "message" not in node.classes: continue if "service" in node.classes: out.append({"kind": "service", "text": node.text().strip(), "service": True}) continue raw_id = node.attrs.get("id", "") mid = int(raw_id.replace("message", "")) if raw_id.startswith("message") else None joined = "joined" in node.classes date_node = next((n for n in node.iter() if "date" in n.classes and n.attrs.get("title")), None) from_node = next((n for n in node.iter() if "from_name" in n.classes), None) sender = from_node.text().strip() if from_node else (last_sender.get("name") if joined else None) if from_node: last_sender["name"] = sender fwd = next((n for n in node.iter() if "forwarded" in n.classes), None) reply = next((n for n in node.iter() if "reply_to" in n.classes), None) text_node = next((n for n in node.iter() if "text" in n.classes and n.tag == "div"), None) photos, media_kind = [], "none" for n in node.iter(): if n.tag == "a" and "photo_wrap" in n.classes and n.attrs.get("href"): photos.append(n.attrs["href"]) for mk in ("media_photo", "media_video", "media_file", "media_music", "media_sticker"): if mk in n.classes and media_kind == "none": media_kind = mk.replace("media_", "") out.append({ "kind": "message", "message_id": mid, "source_file": os.path.basename(path), "date": parse_date(date_node.attrs["title"]) if date_node else None, "from_name": sender, "joined": joined, "forwarded": fwd.text().strip()[:120] if fwd else None, "is_reply": reply is not None, "text": text_node.text().strip() if text_node else "", "photos": photos, "media_kind": "photo" if photos else media_kind, }) return out def collect_photos(base: str) -> dict[str, dict]: out: dict[str, dict] = {} pdir = os.path.join(base, "photos") if not os.path.isdir(pdir): return out for name in sorted(os.listdir(pdir)): m = re.match(r"^photo_(\d+)@", name) if not m: continue rec = out.setdefault(m.group(1), {"full": [], "thumbs": []}) (rec["thumbs"] if "_thumb" in name else rec["full"]).append(name) return out def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--src", default="ChatExport_2026-10-27") ap.add_argument("--out", default="data/processed/chatexport_2026-10-27") ap.add_argument("--model", default="outputs/ad-clf/best") ap.add_argument("--batch", type=int, default=128) ap.add_argument("--max-len", type=int, default=512) ap.add_argument("--low", type=float, default=0.3, help="低于此判 normal 的高置信伪标签") ap.add_argument("--high", type=float, default=0.7, help="高于此判 ad 的高置信伪标签") args = ap.parse_args() src = os.path.abspath(args.src) htmls = sorted((p for p in os.listdir(src) if re.match(r"^messages\d*\.html$", p)), key=lambda p: int(re.search(r"(\d+)", p).group(1)) if re.search(r"\d+", p) else 0) # 按导出编号顺序解析,joined 的发送者归属才正确 msgs, service = [], 0 last_sender = {"name": None} for name in htmls: parsed = parse_file(os.path.join(src, name), last_sender) service += sum(1 for m in parsed if m.get("service")) msgs.extend(m for m in parsed if not m.get("service")) print(f"[parse] {len(htmls)} 个 HTML:消息 {len(msgs)},service {service}") # 去重 + 日期修正(同 id 的编辑版本取日期更晚的) by_id: dict[int, dict] = {} for m in msgs: if m["message_id"] is None: continue prev = by_id.get(m["message_id"]) if prev is None or (m["date"] or 0) >= (prev["date"] or 0): by_id[m["message_id"]] = m print(f"[dedup] 保留 {len(by_id)}(重复 {len(msgs) - len(by_id)})") photos = collect_photos(src) # ---------- 打分 ---------- import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer device = "cuda" if torch.cuda.is_available() else "cpu" tok = AutoTokenizer.from_pretrained(args.model) model = AutoModelForSequenceClassification.from_pretrained(args.model).eval().to(device) if device == "cuda": model = model.to(torch.bfloat16) ad_id = int(model.config.label2id["ad"]) items = sorted((m for m in by_id.values() if m["text"]), key=lambda m: len(m["text"])) print(f"[score] 有文本 {len(items)} 条,设备 {device}") probs: dict[int, float] = {} t0 = dt.datetime.now() with torch.inference_mode(): for i in range(0, len(items), args.batch): chunk = items[i:i + args.batch] enc = tok([m["text"] for m in chunk], truncation=True, max_length=args.max_len, padding=True, return_tensors="pt").to(device) p = torch.softmax(model(**enc).logits.float(), dim=-1)[:, ad_id].tolist() for m, v in zip(chunk, p): probs[m["message_id"]] = v if (i // args.batch) % 20 == 0: print(f" {i + len(chunk)}/{len(items)} {(dt.datetime.now() - t0).total_seconds():.0f}s") # ---------- 落盘 ---------- os.makedirs(args.out, exist_ok=True) rows, img_rows = [], [] seen_hash: set[str] = set() stats = collections.Counter() for mid, m in sorted(by_id.items()): p_ad = probs.get(mid) if p_ad is None: label, source, conf = None, "unlabeled", None elif p_ad >= args.high: label, source, conf = "ad", "laya_pseudo", round(p_ad, 4) elif p_ad <= args.low: label, source, conf = "normal", "laya_pseudo", round(1 - p_ad, 4) else: label, source, conf = None, "needs_review", round(p_ad, 4) imgs = [] for href in m["photos"]: mm = PHOTO_RE.match(href) rec = photos.get(mm.group(1)) if mm else None if not rec or not rec["full"]: stats["photo_missing"] += 1 continue fname, path = rec["full"][0], os.path.join(src, "photos", rec["full"][0]) try: from PIL import Image with Image.open(path) as im: im.load() w, hgt, fmt = im.size[0], im.size[1], im.format or "" except Exception: stats["photo_corrupt"] += 1 continue if w < 64 or hgt < 64: stats["photo_too_small"] += 1 continue with open(path, "rb") as fh: digest = hashlib.sha256(fh.read()).hexdigest() if digest in seen_hash: stats["photo_dup"] += 1 continue seen_hash.add(digest) imgs.append({"path": os.path.relpath(path, os.path.dirname(src)).replace("\\", "/"), "sha256": digest, "width": w, "height": hgt, "format": fmt, "bytes": os.path.getsize(path)}) stats["photo_kept"] += 1 rows.append({ "chat_id": "TGCN机场联盟", "message_id": mid, "date": m["date"] or 0, "iso_time": dt.datetime.fromtimestamp(m["date"]).isoformat(timespec="seconds") if m["date"] else None, "from_name": m["from_name"], "forwarded": m["forwarded"], "is_reply": m["is_reply"], "text": m["text"], "text_len": len(m["text"]), "media_kind": m["media_kind"], "images": [i["path"] for i in imgs], "p_ad": round(p_ad, 6) if p_ad is not None else None, "label": label, "label_source": source, "confidence": conf, "needs_review": source == "needs_review", }) for i in imgs: img_rows.append({**i, "message_id": mid, "date": m["date"] or 0, "p_ad": round(p_ad, 6) if p_ad is not None else None, "label": label, "label_source": source, "text": m["text"][:300]}) for name, data in (("messages.jsonl", rows), ("images.jsonl", img_rows)): with open(os.path.join(args.out, name), "w", encoding="utf-8") as fh: for r in data: fh.write(json.dumps(r, ensure_ascii=False) + "\n") lab = [r for r in rows if r["label"]] report = { "src": src, "html_files": len(htmls), "service": service, "messages": len(rows), "with_text": sum(1 for r in rows if r["text"]), "media_kind": dict(collections.Counter(r["media_kind"] for r in rows)), "images": len(img_rows), "image_stats": dict(stats), "label_dist": dict(collections.Counter(r["label_source"] for r in rows)), "label_value": dict(collections.Counter(r["label"] for r in lab)), "scored_by_threshold": { "ad>=0.7": sum(1 for r in rows if r["p_ad"] is not None and r["p_ad"] >= args.high), "normal<=0.3": sum(1 for r in rows if r["p_ad"] is not None and r["p_ad"] <= args.low), "review_0.3-0.7": sum(1 for r in rows if r["p_ad"] is not None and args.low < r["p_ad"] < args.high), }, "date_range": [dt.datetime.fromtimestamp(min(r["date"] for r in rows if r["date"])).isoformat(timespec="seconds"), dt.datetime.fromtimestamp(max(r["date"] for r in rows if r["date"])).isoformat(timespec="seconds")], "senders": len({r["from_name"] for r in rows if r["from_name"]}), "out": os.path.abspath(args.out), } with open(os.path.join(args.out, "report.json"), "w", encoding="utf-8") as fh: json.dump(report, fh, ensure_ascii=False, indent=2) print(json.dumps(report, ensure_ascii=False, indent=2)) if __name__ == "__main__": sys.exit(main())