Files
MediaCrawler/mac-agent-os-main/05_tools/00_setup/guardd/modules/slot_manager.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

239 lines
9.0 KiB
Python

"""
slot_manager.py — 浏览器槽位管理器 (guardd 模块)
职责:
- 管理本机 Camoufox 浏览器实例(槽位)
- 按 browser_id 识别,不依赖槽位编号(用户拖拽窗口不影响)
- 同一账号不能同时在两个浏览器运行
- 启动时扫描孤儿浏览器进程
"""
import os
import signal
import subprocess
import time
from threading import Lock
from typing import Optional, List
class AccountBusyError(Exception):
"""账号已在其他槽位运行"""
pass
class BrowserSlotManager:
"""浏览器槽位管理器"""
def __init__(self, max_slots: int = 3):
self.max_slots = max_slots
self.slots = [None] * max_slots # list of dict or None
self._lock = Lock()
def acquire(self, account_id: str, identity_dir: str, nickname: str = "",
platform: str = "douyin") -> Optional[dict]:
"""获取一个槽位,返回 slot_info dict
Args:
account_id: 账号ID (douyin_136)
identity_dir: 身份目录名
nickname: 账号昵称 (显示用)
platform: 平台 (douyin / xiaohongshu)
Raises: AccountBusyError 如果账号已在其他槽位运行
"""
with self._lock:
for s in self.slots:
if s and s["account_id"] == account_id:
raise AccountBusyError(f"账号 {account_id} 已在 slot {s['slot_id']} 运行")
for i in range(self.max_slots):
if self.slots[i] is None:
info = {
"slot_id": i,
"account_id": account_id,
"nickname": nickname,
"platform": platform,
"browser_id": identity_dir,
"pid": None,
"blueprint": None,
"current_step": None,
"step_index": 0,
"total_steps": 0,
"started_at": time.time(),
"elapsed_sec": 0,
"cpu_percent": 0.0,
"memory_mb": 0.0,
"health": "healthy",
"last_heartbeat": time.time(),
}
self.slots[i] = info
return info
return None
def release(self, browser_id: str) -> bool:
"""按 browser_id 释放槽位(不依赖槽位编号)"""
with self._lock:
for i in range(self.max_slots):
if self.slots[i] and self.slots[i]["browser_id"] == browser_id:
pid = self.slots[i].get("pid")
if pid:
try:
os.kill(pid, signal.SIGTERM)
except (OSError, ProcessLookupError):
pass
self.slots[i] = None
return True
return False
def release_by_account(self, account_id: str) -> bool:
"""按 account_id 释放槽位"""
with self._lock:
for i in range(self.max_slots):
if self.slots[i] and self.slots[i]["account_id"] == account_id:
self.slots[i] = None
return True
return False
def update_step(self, browser_id: str, step_name: str, step_index: int = None, total_steps: int = None):
"""更新槽位中正在执行的步骤"""
with self._lock:
for s in self.slots:
if s and s["browser_id"] == browser_id:
s["current_step"] = step_name
s["elapsed_sec"] = int(time.time() - s["started_at"])
s["last_heartbeat"] = time.time()
if step_index is not None:
s["step_index"] = step_index
if total_steps is not None:
s["total_steps"] = total_steps
return True
return False
def set_pid(self, browser_id: str, pid: int):
"""设置浏览器进程 PID"""
with self._lock:
for s in self.slots:
if s and s["browser_id"] == browser_id:
s["pid"] = pid
return True
return False
def find_account(self, account_id: str) -> Optional[dict]:
"""查找账号在当前哪个槽位运行"""
with self._lock:
for s in self.slots:
if s and s["account_id"] == account_id:
return s
return None
def find_by_browser(self, browser_id: str) -> Optional[dict]:
with self._lock:
for s in self.slots:
if s and s["browser_id"] == browser_id:
return s
return None
def check_health(self):
"""每轮 cycle 检查所有浏览器进程健康状态"""
with self._lock:
for s in self.slots:
if not s or not s.get("pid"):
continue
try:
os.kill(s["pid"], 0) # 空信号测存活
except OSError:
s["health"] = "crashed"
s["account_id"] = None
continue
# 采集 CPU/内存
try:
r = subprocess.run(
["ps", "-p", str(s["pid"]), "-o", "%cpu=,%mem=,rss="],
capture_output=True, text=True, timeout=3
)
parts = r.stdout.strip().split()
if len(parts) >= 3:
s["cpu_percent"] = float(parts[0])
s["memory_mb"] = float(parts[2]) / 1024
s["health"] = "warning" if float(parts[0]) > 80 else "healthy"
except Exception:
pass
s["elapsed_sec"] = int(time.time() - s["started_at"])
s["last_heartbeat"] = time.time()
def cleanup_orphans(self):
"""启动时扫描孤儿浏览器进程"""
print(" [SlotManager] 扫描孤儿浏览器进程...")
try:
r = subprocess.run(
["ps", "aux"],
capture_output=True, text=True, timeout=5
)
orphan_count = 0
for line in r.stdout.split("\n"):
if "camoufox" in line.lower() or "firefox" in line.lower():
parts = line.split()
if len(parts) > 1:
pid = parts[1]
try:
os.kill(int(pid), signal.SIGTERM)
orphan_count += 1
except (OSError, ValueError):
pass
if orphan_count:
print(f" [SlotManager] 清理 {orphan_count} 个孤儿浏览器进程")
except Exception as e:
print(f" [SlotManager] 清理异常: {e}")
def check_processes(self):
"""逐 slot 检查 PID 存活状态(5 秒线程调用)"""
with self._lock:
for s in self.slots:
if not s or not s.get("pid"):
continue
pid = s["pid"]
alive = True
try:
os.kill(pid, 0)
except OSError:
alive = False
if alive:
s["health"] = "healthy"
s["last_heartbeat"] = time.time()
else:
s["health"] = "crashed"
s["pid"] = None
s["account_id"] = None
def track_loop(self, interval: float = 5.0):
"""后台线程:每 5 秒检查一次所有 slot 的进程存活"""
logger = __import__("logging").getLogger("guardd.slot_manager")
while True:
try:
self.check_processes()
except Exception as e:
logger.error(f"track_loop 异常: {e}")
__import__("time").sleep(interval)
def get_usage(self) -> dict:
with self._lock:
slots_info = []
for i, s in enumerate(self.slots):
if s:
slots_info.append({
"slot_id": i,
"account_id": s["account_id"],
"nickname": s.get("nickname", ""),
"platform": s.get("platform", ""),
"browser_id": s["browser_id"],
"pid": s["pid"],
"blueprint": s.get("blueprint"),
"current_step": s.get("current_step"),
"step_index": s.get("step_index", 0),
"total_steps": s.get("total_steps", 0),
"elapsed_sec": s.get("elapsed_sec", 0),
"health": s.get("health", "unknown"),
})
return {
"max": self.max_slots,
"used": sum(1 for s in self.slots if s is not None),
"slots": slots_info,
}