Text Classification
Transformers
Safetensors
Chinese
spam-detection
advertising-detection
ad-classification
chinese
modernbert
mmbert
multi-task
four-head
Instructions to use x6nux/laya-ad with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use x6nux/laya-ad with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="x6nux/laya-ad")# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("x6nux/laya-ad", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download code/scripts/build_tgexport_dataset.py from x6nux/laya-ad: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/x6nux/laya-ad/resolve/main/code/scripts/build_tgexport_dataset.py
- Command line
-
hf download hf://x6nux/laya-ad/code/scripts/build_tgexport_dataset.py
-
curl -L -o build_tgexport_dataset.py https://huggingface.co/x6nux/laya-ad/resolve/main/code/scripts/build_tgexport_dataset.py
11.1 kB
| #!/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()) | |