feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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:
@@ -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())
|
||||
Reference in New Issue
Block a user