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,376 @@
|
||||
"""
|
||||
person_swap/service.py — 人物置换任务管理 (SQLite + 后台单worker)
|
||||
|
||||
设计:
|
||||
- 任务持久化: agent-local/runtime/person_swap/tasks.db (程序运行时自建)
|
||||
- 单 worker 线程顺序处理队列(账号并发受限, 避免撞限流)
|
||||
- 状态机: queued → processing(内含 preprocessing/submitting/generating) → succeeded|failed|cancelled
|
||||
- 失败自动重试(损耗系数建模: 重试多耗一轮生成, 由费用护栏兜底)
|
||||
- 费用护栏: 单价(配置, 实测后填)×时长 估算; 月度累计超预算 → 新任务 blocked
|
||||
- Dashboard 重启恢复: processing → queued 重新整跑(远端已提交任务可能浪费一次, MVP 接受的局限)
|
||||
|
||||
线程安全: worker 独占写状态; 查询接口直接读 sqlite(每次新连接)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_ROOT))
|
||||
|
||||
from lib.logger import get_logger
|
||||
|
||||
logger = get_logger("person_swap")
|
||||
|
||||
# ── 运行时目录(agent-local, 程序自建, 非人工修改) ──
|
||||
_RUNTIME = Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "person_swap"
|
||||
_UPLOAD_DIR = _RUNTIME / "uploads"
|
||||
_OUTPUT_DIR = _RUNTIME / "outputs"
|
||||
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_DB_PATH = _RUNTIME / "tasks.db"
|
||||
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_PROCESSING = "processing"
|
||||
STATUS_SUCCEEDED = "succeeded"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
STATUS_BLOCKED = "blocked" # 预算/账号不可用等业务拦截
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source_orig TEXT NOT NULL,
|
||||
image_orig TEXT NOT NULL,
|
||||
source_prep TEXT DEFAULT '',
|
||||
image_prep TEXT DEFAULT '',
|
||||
preview_path TEXT DEFAULT '',
|
||||
prompt TEXT DEFAULT '',
|
||||
character_name TEXT DEFAULT '',
|
||||
duration_sec INTEGER DEFAULT 0,
|
||||
model TEXT DEFAULT '',
|
||||
output_path TEXT DEFAULT '',
|
||||
error TEXT DEFAULT '',
|
||||
retries INTEGER DEFAULT 0,
|
||||
cost_est REAL DEFAULT 0,
|
||||
progress INTEGER DEFAULT 0,
|
||||
note TEXT DEFAULT '',
|
||||
cancel_requested INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
"""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
con = sqlite3.connect(str(_DB_PATH), timeout=15)
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
|
||||
def _cfg() -> dict:
|
||||
from lib.config import load_config
|
||||
return (load_config().get("person_swap", {}) or {})
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 任务服务
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class PersonSwapService:
|
||||
"""单例任务服务: create/list/get/cancel + 后台 worker 消费队列"""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> "PersonSwapService":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
con = _connect()
|
||||
con.executescript(_SCHEMA)
|
||||
con.commit()
|
||||
con.close()
|
||||
self._wake = threading.Event()
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run_worker, name="ps-worker", daemon=True)
|
||||
self._thread.start()
|
||||
# 启动恢复: processing → queued (Dashboard 重启后自动续跑)
|
||||
con = _connect()
|
||||
con.execute("UPDATE tasks SET status=?, note='服务重启, 任务重新排队' WHERE status=?",
|
||||
(STATUS_QUEUED, STATUS_PROCESSING))
|
||||
con.commit()
|
||||
con.close()
|
||||
logger.info("person_swap service 启动, worker=%s", self._thread.name)
|
||||
|
||||
# ── 查询 ──
|
||||
|
||||
def list_tasks(self, limit: int = 50, status: str = "") -> list:
|
||||
con = _connect()
|
||||
sql = "SELECT * FROM tasks"
|
||||
args = []
|
||||
if status:
|
||||
sql += " WHERE status=?"
|
||||
args.append(status)
|
||||
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||
args.append(int(limit))
|
||||
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
||||
con.close()
|
||||
return rows
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
con = _connect()
|
||||
row = con.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
con.close()
|
||||
return dict(row) if row else {}
|
||||
|
||||
def monthly_cost(self) -> dict:
|
||||
"""本月费用估算与预算"""
|
||||
cfg = _cfg()
|
||||
budget = float(cfg.get("monthly_budget", 300))
|
||||
con = _connect()
|
||||
row = con.execute(
|
||||
"SELECT COALESCE(SUM(cost_est),0) AS used FROM tasks "
|
||||
"WHERE status=? AND substr(created_at,1,7)=strftime('%Y-%m','now')",
|
||||
(STATUS_SUCCEEDED,)).fetchone()
|
||||
con.close()
|
||||
used = round(float(row["used"]), 2) if row else 0.0
|
||||
return {"used": used, "budget": budget, "remaining": round(budget - used, 2)}
|
||||
|
||||
# ── 创建/取消 ──
|
||||
|
||||
def create_task(self, source_path: str, image_path: str,
|
||||
prompt: str = "", character_name: str = "",
|
||||
duration_sec: int = 0) -> dict:
|
||||
"""登记任务(先做基础校验), 返回任务 dict; 超预算返回 blocked 任务"""
|
||||
source_path = str(source_path)
|
||||
image_path = str(image_path)
|
||||
if not os.path.exists(source_path):
|
||||
raise ValueError(f"源视频不存在: {source_path}")
|
||||
if not os.path.exists(image_path):
|
||||
raise ValueError(f"参考图不存在: {image_path}")
|
||||
|
||||
# 素材预检(时长/尺寸) — 快速失败, 不进队列
|
||||
from person_swap import preprocess as pp
|
||||
try:
|
||||
vinfo = pp.probe_video(source_path)
|
||||
ichk = pp.check_image(image_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"素材预检失败: {e}")
|
||||
if not ichk["ok"]:
|
||||
raise ValueError(ichk["error"])
|
||||
|
||||
# 预算护栏
|
||||
mc = self.monthly_cost()
|
||||
if mc["remaining"] <= 0:
|
||||
raise ValueError(f"月度预算已用尽(¥{mc['used']}/{mc['budget']}), 请调整 person_swap.monthly_budget")
|
||||
|
||||
# 超长提示(仍允许入队, worker 会截取)
|
||||
max_dur = int(_cfg().get("max_duration", 10))
|
||||
trimmed_note = ""
|
||||
if vinfo["duration_sec"] > max_dur:
|
||||
trimmed_note = f"源视频{vinfo['duration_sec']}s 超过{max_dur}s, 将截取前{max_dur}s"
|
||||
|
||||
task_id = uuid.uuid4().hex[:12]
|
||||
now = _now()
|
||||
model = ""
|
||||
try:
|
||||
from person_swap.api import get_model
|
||||
model = get_model()
|
||||
except Exception:
|
||||
pass
|
||||
task = {
|
||||
"id": task_id, "status": STATUS_QUEUED, "created_at": now, "updated_at": now,
|
||||
"source_orig": source_path, "image_orig": image_path,
|
||||
"prompt": prompt or "", "character_name": character_name or "",
|
||||
"duration_sec": int(duration_sec or 0), "model": model,
|
||||
"note": trimmed_note or "排队中", "progress": 0,
|
||||
}
|
||||
con = _connect()
|
||||
con.execute("INSERT INTO tasks (id,status,created_at,updated_at,source_orig,image_orig,prompt,character_name,duration_sec,model,note) "
|
||||
"VALUES (:id,:status,:created_at,:updated_at,:source_orig,:image_orig,:prompt,:character_name,:duration_sec,:model,:note)",
|
||||
task)
|
||||
con.commit()
|
||||
con.close()
|
||||
logger.info("新任务 %s queued (视频 %.1fs %dx%d)", task_id, vinfo["duration_sec"], vinfo["width"], vinfo["height"])
|
||||
self._wake.set()
|
||||
task["video_info"] = vinfo
|
||||
return task
|
||||
|
||||
def cancel_task(self, task_id: str) -> dict:
|
||||
task = self.get_task(task_id)
|
||||
if not task:
|
||||
raise ValueError("任务不存在")
|
||||
if task["status"] in (STATUS_SUCCEEDED, STATUS_FAILED, STATUS_CANCELLED):
|
||||
return task
|
||||
con = _connect()
|
||||
con.execute("UPDATE tasks SET cancel_requested=1, note='取消请求已登记(生成中任务远端可能继续跑完)' WHERE id=?",
|
||||
(task_id,))
|
||||
con.commit()
|
||||
con.close()
|
||||
self._wake.set()
|
||||
return self.get_task(task_id)
|
||||
|
||||
# ── worker ──
|
||||
|
||||
def _run_worker(self):
|
||||
while not self._stop.is_set():
|
||||
task = self._dequeue()
|
||||
if task is None:
|
||||
self._wake.wait(timeout=5)
|
||||
self._wake.clear()
|
||||
continue
|
||||
try:
|
||||
self._process(task)
|
||||
except Exception as e:
|
||||
logger.exception("任务 %s 处理异常", task["id"])
|
||||
self._update(task["id"], status=STATUS_FAILED, error=str(e)[:500], note="内部异常")
|
||||
# 让出, 检查下一个
|
||||
time.sleep(1)
|
||||
|
||||
def _dequeue(self) -> dict:
|
||||
con = _connect()
|
||||
row = con.execute("SELECT * FROM tasks WHERE status=? AND cancel_requested=0 "
|
||||
"ORDER BY created_at ASC LIMIT 1", (STATUS_QUEUED,)).fetchone()
|
||||
if row:
|
||||
con.execute("UPDATE tasks SET status=?, updated_at=? WHERE id=?",
|
||||
(STATUS_PROCESSING, _now(), row["id"]))
|
||||
con.commit()
|
||||
con.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def _update(self, task_id: str, **kw):
|
||||
cols = ", ".join(f"{k}=?" for k in kw)
|
||||
vals = list(kw.values())
|
||||
con = _connect()
|
||||
con.execute(f"UPDATE tasks SET {cols}, updated_at=? WHERE id=?",
|
||||
vals + [_now(), task_id])
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
def _process(self, task: dict):
|
||||
tid = task["id"]
|
||||
cfg = _cfg()
|
||||
|
||||
def setp(p: int, note: str):
|
||||
self._update(tid, progress=p, note=note)
|
||||
logger.info(" [%s] %d%% %s", tid, p, note)
|
||||
|
||||
# ── 阶段0: 账号可用性预检(欠费直接失败, 不烧重试) ──
|
||||
from person_swap.api import check_account_status, get_api_key
|
||||
st = check_account_status()
|
||||
if st["status"] != "ok":
|
||||
reason = "账号不可用: " + st["detail"]
|
||||
self._update(tid, status=STATUS_FAILED, error=reason, note="账号不可用", progress=0)
|
||||
logger.warning("任务 %s 失败: %s", tid, reason)
|
||||
return
|
||||
|
||||
# ── 阶段1: 预处理 ──
|
||||
setp(3, "预处理素材(转码/裁切/归一化)")
|
||||
from person_swap import preprocess as pp
|
||||
work_dir = _UPLOAD_DIR / tid
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prep_src = work_dir / "source_prep.mp4"
|
||||
try:
|
||||
sr = pp.prep_source_video(task["source_orig"], str(prep_src))
|
||||
except Exception as e:
|
||||
return self._handle_fail(task, f"视频预处理失败: {e}")
|
||||
prep_img = work_dir / "ref_prep.jpg"
|
||||
try:
|
||||
ir = pp.prep_reference_image(task["image_orig"], str(prep_img))
|
||||
except Exception as e:
|
||||
return self._handle_fail(task, f"参考图处理失败: {e}")
|
||||
preview = work_dir / "preview.jpg"
|
||||
try:
|
||||
pp.extract_preview_frame(sr["out_path"], str(preview))
|
||||
except Exception:
|
||||
preview = Path("")
|
||||
self._update(tid, source_prep=sr["out_path"], image_prep=ir["out_path"],
|
||||
preview_path=str(preview))
|
||||
|
||||
# ── 阶段2: 生成(重试循环, 损耗系数由费用护栏兜底) ──
|
||||
from person_swap import api
|
||||
max_retries = int(cfg.get("max_retries", 2))
|
||||
timeout_sec = int(cfg.get("timeout_sec", 900))
|
||||
output_path = str(_OUTPUT_DIR / f"{tid}.mp4")
|
||||
attempt = 0
|
||||
while True:
|
||||
if self._is_cancel_requested(tid):
|
||||
self._update(tid, status=STATUS_CANCELLED, note="已取消", progress=0)
|
||||
return
|
||||
attempt += 1
|
||||
setp(20, f"提交百炼生成(第{attempt}次)")
|
||||
try:
|
||||
api.run_swap(
|
||||
ref_image_path=ir["out_path"],
|
||||
source_video_path=sr["out_path"],
|
||||
output_path=output_path,
|
||||
prompt=task["prompt"],
|
||||
duration=task["duration_sec"],
|
||||
model=task["model"] or api.get_model(),
|
||||
api_key=get_api_key(),
|
||||
timeout_sec=timeout_sec,
|
||||
progress_cb=lambda p, n: self._update(tid, progress=max(p, self.get_task(tid)["progress"]), note=n)
|
||||
if not self._is_cancel_requested(tid) else None,
|
||||
)
|
||||
# 成功
|
||||
cost = self._estimate_cost(task, sr["duration_sec"])
|
||||
self._update(tid, status=STATUS_SUCCEEDED, output_path=output_path,
|
||||
cost_est=cost, progress=100, note="✅ 生成完成")
|
||||
logger.info("任务 %s 完成: %s (费用≈¥%.3f)", tid, output_path, cost)
|
||||
return
|
||||
except Exception as e:
|
||||
msg = str(e)[:500]
|
||||
logger.warning("任务 %s 第%d次失败: %s", tid, attempt, msg)
|
||||
if attempt > max_retries or self._is_cancel_requested(tid):
|
||||
return self._handle_fail(task, f"生成失败(重试{attempt-1}次后): {msg}")
|
||||
self._update(tid, note=f"失败, 自动重试({attempt}/{max_retries+1}): {msg[:80]}")
|
||||
time.sleep(3) # 重试前喘息
|
||||
|
||||
def _handle_fail(self, task: dict, reason: str):
|
||||
self._update(task["id"], status=STATUS_FAILED, error=reason[:500],
|
||||
note="❌ " + reason[:100])
|
||||
|
||||
def _is_cancel_requested(self, task_id: str) -> bool:
|
||||
con = _connect()
|
||||
row = con.execute("SELECT cancel_requested FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
con.close()
|
||||
return bool(row and row["cancel_requested"])
|
||||
|
||||
def _estimate_cost(self, task: dict, actual_duration: float) -> float:
|
||||
"""费用估算: 单价(元/秒) × 实际生成时长(含损耗系数)"""
|
||||
cfg = _cfg()
|
||||
price = float(cfg.get("price_per_sec", 0.0) or 0.0)
|
||||
if price <= 0:
|
||||
return 0.0
|
||||
gen_dur = task["duration_sec"] or actual_duration
|
||||
# 损耗系数: 每次重试多耗一轮; 用 1 + 0.4*retries 简化(行业 ~1.4)
|
||||
loss = 1 + 0.4 * int(cfg.get("max_retries", 2))
|
||||
return round(price * gen_dur * loss, 3)
|
||||
|
||||
|
||||
def get_service() -> PersonSwapService:
|
||||
return PersonSwapService.get()
|
||||
|
||||
|
||||
# ── CLI 自测 ──
|
||||
if __name__ == "__main__":
|
||||
svc = get_service()
|
||||
print("任务数:", len(svc.list_tasks(limit=100)))
|
||||
print("本月费用:", svc.monthly_cost())
|
||||
Reference in New Issue
Block a user