移植自 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 带网关原话 / 正常返回)。
239 lines
9.0 KiB
Python
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,
|
|
}
|