Files
AI少女宇宙计划组 220d2a410b 开源分享版初始化: 赛博小雅·弹幕采集器(抖音/B站/YouTube/视频号 → 数字人桥) · AGPL-3.0
剥离: B站 Cookie 与全部 config.bak、抖音采集器二进制、日志、浏览器 profile、个人房间号/WSL IP。
2026-10-25 01:10:43 +08:00

247 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""wechat_client.py — 视频号直播评论采集(经 Chrome DevTools 协议读中控台 DOM)。
原理:
视频号直播没有公开弹幕接口, 但「视频号助手」网页的直播中控台
(channels.weixin.qq.com/platform/live/liveBuild) 会实时渲染评论/弹幕:
.live-message-item
.finder-ui-live-plain-badge__name-text (身份标签: 粉丝/主播/通知)
.message-username-desc (昵称:)
.message-content (内容)
本模块用一个带调试端口的独立浏览器实例(默认 9223), 通过 CDP 每秒读一次
该列表, 增量提取新评论 → 交给朗读器。浏览器最小化/被遮挡都不影响。
依赖: 仅标准库 + 项目自带 wsclient.py
"""
import json
import re
import threading
import time
import urllib.request
from collections import deque
from logutil import dim_log, platform_log
from wsclient import WSClient, WSClosed
# 在页面里执行的取数脚本: 找到含评论列表的 iframe(或主文档), 抽出所有评论条目
JS_COLLECT = r"""
(() => {
const pick = () => {
const frames = [...document.querySelectorAll('iframe')];
for (const f of frames) {
try {
const cd = f.contentDocument;
if (cd && cd.querySelector && cd.querySelector('.comment__list')) return cd;
} catch (e) { }
}
if (document.querySelector('.comment__list')) return document;
return null;
};
const d = pick();
if (!d) return JSON.stringify({ ok: false, reason: 'no-console' });
const sc = d.querySelector('.comment__list');
const items = [...sc.querySelectorAll('.live-message-item')];
const out = items.map(el => {
const n = el.querySelector('.message-username-desc');
const c = el.querySelector('.message-content');
const b = el.querySelector('.finder-ui-live-plain-badge__name-text')
|| el.querySelector('.message-type');
return {
nick: n ? n.innerText.replace(/\s+/g, ' ').trim() : '',
content: c ? c.innerText.replace(/\s+/g, ' ').trim() : '',
badge: b ? b.innerText.replace(/\s+/g, ' ').trim() : ''
};
});
return JSON.stringify({ ok: true, count: items.length, items: out });
})()
"""
# 系统/非真人评论的身份标签, 直接忽略
SKIP_BADGES = {"通知", "主题", "系统", "公告"}
NICK_TAIL = re.compile(r"[::]\s*$")
CONSOLE_URL = "https://channels.weixin.qq.com/platform/live/liveBuild"
class WechatConsoleClient(threading.Thread):
"""视频号直播评论采集线程(CDP 版)。"""
def __init__(self, cfg: dict, on_event):
super().__init__(name="wechat-console", daemon=True)
w = cfg.get("wechat") or {}
self.port = int(w.get("cdp_port", 9223))
self.interval = max(0.3, w.get("interval_ms", 1200) / 1000.0)
self.debug = bool(w.get("debug", cfg.get("debug", False)))
self.cfg = cfg
self.on_event = on_event
self.stop = threading.Event()
self.ws: WSClient | None = None
self._seq = 0
self._recent: deque = deque(maxlen=24) # 已读指纹, 用于增量判断
self._baseline_done = False
self._last_warn = 0.0
self._miss = 0
self._last_nav = 0.0
self.status = "未连接"
# ────────────────────── CDP 连接 ──────────────────────
def _http_json(self, path: str, timeout: float = 5.0):
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}",
headers={"User-Agent": "wechat-console"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def _find_page(self) -> dict | None:
"""找「视频号助手」页面目标(含中控台/弹幕页)。"""
try:
pages = [t for t in self._http_json("/json/list") if t.get("type") == "page"]
except Exception:
return None
for p in pages:
if "channels.weixin.qq.com" in (p.get("url") or ""):
return p
return pages[0] if pages else None
def _connect(self) -> bool:
page = self._find_page()
if not page or not page.get("webSocketDebuggerUrl"):
return False
try:
ws = WSClient(page["webSocketDebuggerUrl"], name="cdp-wechat")
ws.connect(timeout=8)
except Exception as e:
if self.debug:
dim_log(f"[视频号] CDP 连接失败: {e}")
return False
self.ws = ws
self._seq = 0
self.status = f"已连接 {page.get('url','')[:48]}"
platform_log("视频号", f"中控台已挂载 · {page.get('url','')[:60]}")
return True
def _eval(self, js: str, timeout: float = 6.0) -> str | None:
if self.ws is None:
return None
self._seq += 1
mid = self._seq
try:
self.ws.send_text(json.dumps({
"id": mid, "method": "Runtime.evaluate",
"params": {"expression": js, "returnByValue": True, "awaitPromise": False},
}))
except Exception:
self._drop_conn()
return None
deadline = time.time() + timeout
while time.time() < deadline and not self.stop.is_set():
try:
_op, payload = self.ws.recv_message(timeout=1.0)
except WSClosed:
self._drop_conn()
return None
except Exception:
continue
try:
msg = json.loads(payload.decode("utf-8", "replace"))
except Exception:
continue
if msg.get("id") != mid:
continue
res = ((msg.get("result") or {}).get("result") or {})
if msg.get("error"):
if self.debug:
dim_log(f"[视频号] CDP 错误: {str(msg['error'])[:120]}")
return None
val = res.get("value")
return val if isinstance(val, str) else (json.dumps(val, ensure_ascii=False) if val is not None else None)
return None
def _drop_conn(self):
try:
if self.ws:
self.ws.close()
except Exception:
pass
self.ws = None
self.status = "连接断开"
# ────────────────────── 主循环 ──────────────────────
def run(self):
while not self.stop.is_set():
if self.ws is None and not self._connect():
now = time.time()
if now - self._last_warn > 30:
self._last_warn = now
platform_log("视频号", "等待中控台页面 (请用「启动调试浏览器.cmd」打开视频号助手并进入直播间)")
self.stop.wait(5.0)
continue
raw = self._eval(JS_COLLECT, timeout=6.0)
if raw is None and self.debug:
dim_log("[视频号] 本次轮询无响应 (CDP 超时)")
if raw:
try:
data = json.loads(raw)
except Exception:
data = None
if data and data.get("ok"):
self._miss = 0
self._handle_items(data.get("items") or [])
elif data and data.get("reason") == "no-console":
self._maybe_navigate()
else:
self._miss += 1
self.stop.wait(self.interval)
self._drop_conn()
def _maybe_navigate(self):
"""页面上找不到评论列表时, 自动把调试浏览器导航到直播中控台。"""
self._miss += 1
now = time.time()
if self._miss >= 3 and now - self._last_nav > 60:
self._last_nav = now
self._miss = 0
if self._eval(f"location.href='{CONSOLE_URL}'; 'nav'", timeout=5.0):
platform_log("视频号", "已自动跳转到直播中控台, 等待页面加载…")
self.stop.wait(9.0)
elif self._miss == 3 and self.debug:
dim_log("[视频号] 页面上没有评论列表 (等待自动跳转冷却)")
def _handle_items(self, items):
fresh = []
for it in items:
nick = (it.get("nick") or "").strip()
content = (it.get("content") or "").strip()
badge = (it.get("badge") or "").strip()
if badge in SKIP_BADGES:
continue
if not content:
continue
nick = NICK_TAIL.sub("", nick)
fp = f"{nick}|{content}"
if fp in self._recent:
continue
self._recent.append(fp)
if nick:
fresh.append((nick, content))
if not self._baseline_done:
# 首次连接只建立基线, 不朗读历史评论
self._baseline_done = True
if fresh and self.debug:
dim_log(f"[视频号] 基线 {len(fresh)} 条历史评论已跳过")
return
if self.debug:
dim_log(f"[视频号] 轮询: 列表 {len(items)} 条, 新增 {len(fresh)} 条")
for nick, content in fresh:
self.on_event({
"platform": "视频号",
"type": "Chat",
"data": {"name": nick, "content": content},
})