laya-ad / code /scripts /build_channel_dataset.py
x6nux's picture
laya-ad v1.1:六精度权重 + 模型卡 + 项目代码快照
71dabc7 verified
Raw History Blame Contribute Delete
12.1 kB
#!/usr/bin/env python3
"""解析 Telegram Desktop HTML 导出的频道存档,产出带标签的数据集。
与 build_archive_dataset.py 的区别:那边是 bot 的 JSONL 存档,这边是 TG Desktop 的
分页 HTML(css/js/images 是界面资源,messages*.html 才是正文)。
频道内容整体即广告,因此标签默认取频道先验(label_source=channel_prior);
但仍要剥掉 service 消息(建频道/置顶/日期分隔)和缩略图,否则训练集会被界面噪音污染。
"""
from __future__ import annotations
import argparse
import collections
import datetime as dt
import hashlib
import html
import json
import os
import re
import sys
from html.parser import HTMLParser
VOID = {"br", "img", "meta", "link", "input", "hr", "source"}
DATE_TITLE_RE = re.compile(r"(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2}) UTC([+-]\d{2}):(\d{2})")
PHOTO_RE = re.compile(r"^photos/(.+?)@(\d{2})-(\d{2})-(\d{4})_(\d{2})-(\d{2})-(\d{2})", re.IGNORECASE)
CONTACT_RE = re.compile(r"(https?://|t\.me/|@[A-Za-z0-9_]{4,}|微信|vx|QQ|电报|飞机|私聊|加我|联系)", re.I)
class Node:
__slots__ = ("tag", "attrs", "children", "parent")
def __init__(self, tag: str, attrs: dict[str, str], parent: "Node | None" = None):
self.tag = tag
self.attrs = attrs
self.children: list["Node | str"] = []
self.parent = parent
@property
def classes(self) -> set[str]:
return set((self.attrs.get("class") or "").split())
def iter(self):
yield self
for ch in self.children:
if isinstance(ch, Node):
yield from ch.iter()
def find_all(self, pred) -> list["Node"]:
return [n for n in self.iter() if n is not self and pred(n)]
def text(self, br: str = "\n") -> str:
out: list[str] = []
for ch in self.children:
if isinstance(ch, str):
out.append(ch)
elif ch.tag == "br":
out.append(br)
else:
out.append(ch.text(br))
return "".join(out)
class DomBuilder(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root = Node("#root", {})
self.cur = self.root
def handle_starttag(self, tag, attrs):
node = Node(tag, {k: (v or "") for k, v in attrs}, self.cur)
self.cur.children.append(node)
if tag not in VOID:
self.cur = node
def handle_startendtag(self, tag, attrs):
self.cur.children.append(Node(tag, {k: (v or "") for k, v in attrs}, self.cur))
def handle_endtag(self, tag):
node = self.cur
while node is not self.root and node.tag != tag:
node = node.parent
if node is not self.root:
self.cur = node.parent
def handle_data(self, data):
if data:
self.cur.children.append(data)
def parse_date(title: str) -> int | None:
"""把 '01.04.2023 22:58:27 UTC+08:00' 换成 unix 时间戳。"""
m = DATE_TITLE_RE.search(title or "")
if not m:
return None
d, mo, y, hh, mm, ss, oh, om = m.groups()
offset = dt.timezone(dt.timedelta(hours=int(oh), minutes=int(om) * (1 if oh.startswith("+") else -1)))
try:
return int(dt.datetime(int(y), int(mo), int(d), int(hh), int(mm), int(ss), tzinfo=offset).timestamp())
except ValueError:
return None
def parse_file(path: str) -> list[dict]:
with open(path, encoding="utf-8", errors="replace") as fh:
builder = DomBuilder()
builder.feed(fh.read())
msgs = []
for node in builder.root.iter():
if node.tag != "div" or "message" not in node.classes:
continue
cls = node.classes
raw_id = node.attrs.get("id", "")
if "service" in cls:
# 建频道/置顶/日期分隔等系统消息,不是内容
msgs.append({"kind": "service", "message_id": raw_id,
"text": node.text().strip(), "service": True})
continue
mid = int(raw_id.replace("message", "")) if raw_id.startswith("message") else None
date_node = next((n for n in node.iter() if "date" in n.classes and n.attrs.get("title")), None)
ts = parse_date(date_node.attrs["title"]) if date_node else None
from_node = next((n for n in node.iter() if "from_name" 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:
href = n.attrs.get("href", "")
if href:
photos.append(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_", "")
msgs.append({
"kind": "message",
"message_id": mid,
"source_file": os.path.basename(path),
"date": ts,
"from_name": from_node.text().strip() if from_node else None,
"text": text_node.text().strip() if text_node else "",
"photos": photos,
"media_kind": media_kind if not photos else "photo",
})
return msgs
def collect_photos(base: str) -> dict[str, dict]:
"""photos/ 下按 photo_id 归组,丢掉 _thumb 缩略图(它们和原图同 id)。"""
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
pid = m.group(1)
thumb = "_thumb" in name
rec = out.setdefault(pid, {"pid": pid, "full": [], "thumbs": []})
(rec["thumbs"] if thumb else rec["full"]).append(name)
return out
def validate_image(path: str) -> tuple[int, int, str] | None:
from PIL import Image
try:
with Image.open(path) as im:
im.load()
return im.size[0], im.size[1], im.format or ""
except Exception:
return None
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--src", default="存档数据-1")
ap.add_argument("--out", default="data/processed/channel")
ap.add_argument("--default-kind", default="porn_bait", help="频道先验对应的 ad_kind")
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))
msgs: list[dict] = []
for name in htmls:
msgs.extend(parse_file(os.path.join(src, name)))
service = [m for m in msgs if m.get("service")]
msgs = [m for m in msgs if not m.get("service")]
print(f"[parse] {len(htmls)} 个 HTML,内容消息 {len(msgs)},service {len(service)}")
photos = collect_photos(src)
print(f"[photo] {len(photos)} 个 photo_id,"
f"原图 {sum(len(p['full']) for p in photos.values())},"
f"缩略图 {sum(len(p['thumbs']) for p in photos.values())}")
# 去重:同一 message_id 只保留一条
by_id: dict[int, dict] = {}
dup = 0
for m in msgs:
if m["message_id"] is None:
continue
if m["message_id"] in by_id:
dup += 1
continue
by_id[m["message_id"]] = m
print(f"[dedup] 消息去重 {dup},保留 {len(by_id)}")
rows, image_rows, stats = [], [], collections.Counter()
seen_hash: dict[str, str] = {}
for mid, m in sorted(by_id.items()):
text = m["text"]
# photo_wrap 的 href 指向 photos/photo_X@...; 映射回 pid
pids = []
for href in m["photos"]:
mm = re.match(r"^photos/photo_(\d+)@", href)
if mm:
pids.append(mm.group(1))
imgs = []
for pid in pids:
rec = photos.get(pid)
if not rec or not rec["full"]:
stats["photo_missing_file"] += 1
continue
fname = rec["full"][0]
path = os.path.join(src, "photos", fname)
checked = validate_image(path)
if not checked:
stats["photo_corrupt"] += 1
continue
w, h, fmt = checked
if w < 64 or h < 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_exact_dup"] += 1
continue
seen_hash[digest] = fname
imgs.append({"path": os.path.relpath(path, os.path.dirname(src)).replace("\\", "/"),
"sha256": digest, "width": w, "height": h, "format": fmt,
"bytes": os.path.getsize(path)})
stats["photo_kept"] += 1
# 频道先验:所有内容帖视为广告;仅把"纯运营/求助"类标记待复核
low_info = len(text) < 4
needs_review = low_info or ("置顶" in text and len(text) < 20)
rows.append({
"chat_id": "channel",
"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"],
"text": text,
"text_len": len(text),
"has_link": bool(CONTACT_RE.search(text)),
"media_kind": m["media_kind"],
"images": [i["path"] for i in imgs],
"label": "ad",
"ad_kind": args.default_kind,
"label_source": "channel_prior",
"label_trust": "prior",
"needs_review": needs_review,
"low_info": low_info,
"source_file": m["source_file"],
})
for i in imgs:
image_rows.append({**i, "chat_id": "channel", "message_id": mid,
"date": m["date"] or 0, "label": "ad",
"ad_kind": args.default_kind, "label_source": "channel_prior",
"text": text})
# 时间切分
rows.sort(key=lambda r: r["date"])
img_rows = sorted(image_rows, key=lambda r: r["date"])
for group in (rows, img_rows):
dates = [r["date"] for r in group if r["date"]]
if not dates:
continue
cutoff = dates[int(len(dates) * 0.9)]
for r in group:
r["split"] = "val" if r["date"] >= cutoff else "train"
out = args.out
os.makedirs(out, exist_ok=True)
for name, data in (("messages.jsonl", rows), ("images.jsonl", img_rows)):
with open(os.path.join(out, name), "w", encoding="utf-8") as fh:
for r in data:
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
report = {
"src": src,
"html_files": htmls,
"service_messages": len(service),
"service_samples": [s["text"][:60] for s in service[:5]],
"messages": len(rows),
"images": len(img_rows),
"image_join_stats": dict(stats),
"date_range": [dt.datetime.fromtimestamp(rows[0]["date"]).isoformat(timespec="seconds"),
dt.datetime.fromtimestamp(rows[-1]["date"]).isoformat(timespec="seconds")] if rows else None,
"with_text": sum(1 for r in rows if r["text"]),
"with_link": sum(1 for r in rows if r["has_link"]),
"with_image": sum(1 for r in rows if r["images"]),
"needs_review": sum(1 for r in rows if r["needs_review"]),
"split": dict(collections.Counter(r["split"] for r in rows)),
}
with open(os.path.join(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))
print(f"[out] {os.path.abspath(out)}")
if __name__ == "__main__":
sys.exit(main())