feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,288 @@
"""
plugins/guardd.py — 联邦状态监控插件 (v3.1)
WPRA v2.0: 优先 machines/*/heartbeat.json, 降级 status/live/
版本: 3.1.0 | 更新: 2026-05-31
"""
import json, os
from datetime import datetime, timezone
from pathlib import Path
from plugins.base import DashboardPlugin, CROSS_MACHINE, MACHINE_UID, HOSTNAME, AGENT_LOCAL
from plugins._registry import get_machine_list
# ── WPRA v2.0 路径 ────────────────────────────────
MACHINES_DIR = CROSS_MACHINE / "machines"
# ── 人工维护的 IP→hostname 映射(旧版兼容)───────────
_MACHINE_ALIASES: dict[str, str] = {
"192.168.31.95": "7kecheng",
"7kechengdeAir": "7kecheng",
"7kechengdeMacBook-Air.local": "7kecheng",
"Redmi-12C": "chengzigedeAir",
"192.168.31.96": "chengzigedeAir",
}
class GuarddPlugin(DashboardPlugin):
name = "guardd"
label = "联邦机器"
icon = "🖥"
version = "3.1.0"
description = "跨机器联邦状态监控:WPRA v2.0 心跳聚合"
order = 10
# ═══════════════════════════════════════════════════════
# WPRA v2.0: machines/*/heartbeat.json (主数据源)
# ═══════════════════════════════════════════════════════
def _read_wpra_heartbeats(self):
"""主数据源: machines/*/heartbeat.json
WPRA v2.0: 每台机器只写自己的 machines/{uid}/heartbeat.json
统一字段名映射到旧格式,保持 _build_machine_detail 兼容。
"""
data = {}
if not MACHINES_DIR.exists():
return data
for md in sorted(MACHINES_DIR.iterdir()):
if not md.is_dir():
continue
uid = md.name
hb_file = md / "heartbeat.json"
if not hb_file.exists():
continue
try:
hb = json.loads(hb_file.read_text())
# 统一字段名映射 (新→旧格式兼容)
machine_name = hb.get("machine_name", uid[:8])
updated_at = hb.get("updated_at", "")
mapped = {
"_hostname": machine_name,
"_uid": uid,
"hostname": machine_name,
"_received_at": updated_at,
"status": hb.get("status", "offline"),
"guardd_version": hb.get("guardd_version", ""),
"current_task": hb.get("current_task", None),
# 从 WPRA 扁平字段转为旧版嵌套格式
"cpu": {"load_1m": hb.get("cpu_load", 0)},
"disk": {
"available_gb": hb.get("disk_avail_gb", 0),
"total_gb": hb.get("disk_total_gb", 0),
"used_gb": hb.get("disk_used_gb", 0),
},
"memory": {"percent": hb.get("memory_pct", 0)},
"os": "",
"_live": bool(updated_at),
"_last_push_sec": 0,
"_source": "wpra_heartbeat",
}
# 计算 _last_push_sec
if updated_at:
try:
delta = (datetime.now(timezone.utc) -
datetime.fromisoformat(updated_at.replace("Z", "+00:00"))).total_seconds()
mapped["_last_push_sec"] = round(delta)
mapped["_live"] = delta < 120
except:
pass
data[uid] = mapped
except Exception:
continue
return data
# ═══════════════════════════════════════════════════════
# 旧版: status/live/{uid}.json (降级)
# ═══════════════════════════════════════════════════════
def _read_live_data(self):
"""实时数据优先: HTTP Push 写入的 status/live/, 补充 WPRA machines/*/
双轨制 (v4.2.0):
- 远程机器通过 HTTP Push 实时推送心跳 → status/live/{uid}.json(主数据源)
- 本地 guardd 写入 → machines/{uid}/heartbeat.json(降级补充)
优先读取实时推送数据,WPRA 文件仅作补充(适用于未推送的机器)。
"""
# 主数据源: HTTP Push 实时推送 → status/live/{uid}.json
live_dir = CROSS_MACHINE / "status" / "live"
data = {}
if live_dir.exists():
for f in live_dir.iterdir():
if f.suffix != ".json" or f.name.startswith("_"):
continue
try:
uid = f.name.replace(".json", "")
data[uid] = json.loads(f.read_text())
except:
pass
# 补充: WPRA machines/*/heartbeat.json(如 live 中没有该机器的数据)
wpra = self._read_wpra_heartbeats()
for uid, hb in wpra.items():
if uid not in data:
data[uid] = hb
return data
def _read_registry(self):
"""补充数据源: cross_machine/registry/{name}.json"""
reg_dir = CROSS_MACHINE / "registry"
machines = []
if reg_dir.exists():
for f in sorted(reg_dir.iterdir()):
if f.suffix == ".json":
try:
d = json.loads(f.read_text())
machines.append({
"hostname": d.get("hostname", f.stem),
"uid": d.get("uid", ""),
"role": d.get("role", ""),
"source": "registry",
})
except:
pass
return machines
def _read_plugin_data(self):
"""补充数据源: 本机 guardd 状态 (local) + 旧 cross_machine/data/ (兼容)"""
# 新: 本机 local 目录
local_guardd = AGENT_LOCAL / "runtime" / "guardd" / "data"
machines = {}
if local_guardd.exists():
for plugin_dir in local_guardd.iterdir():
if not plugin_dir.is_dir():
continue
for f in plugin_dir.iterdir():
if f.suffix == ".json":
try:
d = json.loads(f.read_text())
uid = d.get("machine_uid", "")
hn = d.get("hostname", "")
if uid and hn:
if uid not in machines:
machines[uid] = {"hostname": hn, "uid": uid, "source": "local_guardd"}
except: pass
# 旧: cross_machine/data/ (兼容旧数据)
data_dir = CROSS_MACHINE / "data"
if data_dir.exists():
for plugin_dir in data_dir.iterdir():
if not plugin_dir.is_dir():
continue
for f in plugin_dir.iterdir():
if f.suffix == ".json":
try:
d = json.loads(f.read_text())
uid = d.get("machine_uid", "")
hn = d.get("hostname", "")
if uid and hn and uid not in machines:
machines[uid] = {"hostname": hn, "uid": uid, "source": "legacy_cross_machine"}
except: pass
return list(machines.values())
# ═══════════════════════════════════════════════════════
# 概览 / 详情
# ═══════════════════════════════════════════════════════
def summary(self, machines: list[str]) -> dict:
live = self._read_live_data()
now = datetime.now(timezone.utc)
by_machine = {}
for uid, l in live.items():
hn = l.get("_hostname", uid[:8])
received = l.get("_received_at", "")
online = False
realtime = False
if received:
try:
delta = (now - datetime.fromisoformat(
received.replace("Z", "+00:00"))).total_seconds()
online = delta < 300
realtime = delta < 120
except:
pass
by_machine[hn] = {
"在线": online,
"实时推送": realtime,
"guardd版本": l.get("guardd_version", ""),
}
return {
"总机器": len(by_machine),
"在线": sum(1 for m in by_machine.values() if m["在线"]),
"实时": sum(1 for m in by_machine.values() if m["实时推送"]),
"各机器": by_machine,
}
def detail(self, machine: str = "") -> dict:
live = self._read_live_data()
now = datetime.now(timezone.utc)
if machine:
for uid, l in live.items():
if l.get("_hostname") == machine:
return self._build_machine_detail(machine, uid, l, now)
return {"_note": "未找到该机器数据"}
result = {}
seen = set()
for uid, l in sorted(live.items(), key=lambda x: x[1].get("_hostname", "")):
hn = l.get("_hostname", uid[:8])
if hn in seen:
continue
seen.add(hn)
result[hn] = self._build_machine_detail(hn, uid, l, now)
return result
def _build_machine_detail(self, hostname, uid, live, now):
received = live.get("_received_at", "")
entry = {
"hostname": hostname,
"_uid": uid[:8]+"..." if uid else "",
"status": "offline",
"last_seen": received,
"os": live.get("os", ""),
"cpu_load": 0,
"disk_used_gb": 0,
"disk_total_gb": 0,
"disk_avail_gb": 0,
"guardd_version": live.get("guardd_version", ""),
"current_task": live.get("current_task", ""),
"minutes_ago": 999,
"_live": False,
"_last_push_sec": 0,
}
if received:
try:
delta = (now - datetime.fromisoformat(
received.replace("Z", "+00:00"))).total_seconds()
entry["_live"] = delta < 120
entry["_last_push_sec"] = round(delta)
entry["minutes_ago"] = round(delta / 60)
entry["status"] = "online" if delta < 300 else ("recent" if delta < 3600 else "offline")
except:
pass
cpu = live.get("cpu", {})
entry["cpu_load"] = cpu.get("load_1m", 0)
disk = live.get("disk", {})
entry["disk_used_gb"] = disk.get("used_gb", 0)
entry["disk_total_gb"] = disk.get("total_gb", 0)
entry["disk_avail_gb"] = disk.get("available_gb", 0)
return entry
def actions(self) -> list[dict]:
machines = get_machine_list()
return [
{"name": f"唤醒 {hn}", "method": "POST", "endpoint": f"/api/wakeup/{hn}"}
for hn in machines
]
def get_productions(self, limit=50, offset=0, strategy=None, status=None):
result = self.detail()
return list(result.values())
def get_summary(self) -> dict:
return self.summary(get_machine_list())