Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/keyframe_gen.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

284 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""关键帧生成 —— 千问AI平台(DashScope)多图融合
2026-10-04 实测确认可用(¥0.2/张):
· 端点 https://maas.qianwenaiapi.com/api/v1/services/aigc/multimodal-generation/generation
· 模型 wan2.7-image-pro(最多 9 张输入图 / 顺序模式一次 12 张)
· 格式 input.messages[0].content[] = [{text}, {image}, {image}, ...]
· 图片 支持 base64 data URL(无需先上传 OSS);实测 769×1362 竖屏
用法(CLI 自测,会花钱):
python3 scripts/keyframe_gen.py --role <角色图> --scene <场景图> \
--prompt "把图1的人放进图2的房间里,中景,平视" --out /tmp/kf.png
python3 scripts/keyframe_gen.py --text-only --prompt "空旷病房" --out /tmp/kf.png
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parent
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
from lib.http import _NO_PROXY_OPENER # noqa: E402
from lib.logger import get_logger # noqa: E402
log = get_logger("keyframe_gen")
API = "https://maas.qianwenaiapi.com/api/v1/services/aigc/multimodal-generation/generation"
DEFAULT_MODEL = "wan2.7-image-pro" # 9 图输入 / 顺序 12 张;备选 qwen-image-3.0-pro
UA = {"User-Agent": "Mozilla/5.0 (AgentOS-Keyframe)"}
# ── 画幅预设(官方文档实测:size 格式为 `宽*高`,不是只有 1K/2K/4K 档位)──
# qwen-image 系列:宽高自由 512~2048
# wan2.5+:总像素 1280² ~ 1440²(推荐 9:16 = 960*1696)
# wan2.2 及更早:边长 512~1440 · FLUX:仅几个固定值
SIZE_PRESETS = {
"竖屏 9:16 · 720P(测试推荐)": "720*1280",
"竖屏 9:16 · 标准": "960*1696",
"竖屏 9:16 · 1080P": "1080*1920",
"方图 1:1": "1024*1024",
"横屏 16:9 · 720P": "1280*720",
"横屏 16:9 · 标准": "1696*960",
"省算力 · 小图": "768*1024",
}
DEFAULT_SIZE = "720*1280" # 竖屏 720P —— 测试期默认,省算力
# (正式出片可切 960*1696 或 1080*1920)
# ── 模型注册表(config/image_models.yaml)────────────────────
def _models_yaml() -> Path:
return Path(os.environ.get("AVE_IMAGE_MODELS")
or (SCRIPTS.parent / "config" / "image_models.yaml"))
def image_models() -> list[dict]:
"""读取模型注册表(含能力注释)。读不到时回退到内置最小集合。"""
try:
import yaml
d = yaml.safe_load(_models_yaml().read_text(encoding="utf-8")) or {}
ms = d.get("models") or []
if ms:
return ms
except Exception as e:
log.warning("读取 image_models.yaml 失败: %s", e)
return [{"id": DEFAULT_MODEL, "name": DEFAULT_MODEL, "vendor": "百炼(千问AI平台)",
"price": "¥0.2/张", "note": "内置回退项", "supports": ["multi", "single", "text2img"],
"limits": {}, "key_field": "aliyun"}]
def models_for_mode(mode: str) -> list[dict]:
"""某生成方式可用的模型(按 supports 过滤)"""
m = (mode or "").strip()
return [x for x in image_models() if m in (x.get("supports") or [])]
def model_info(mid: str) -> dict:
for x in image_models():
if x.get("id") == mid:
return x
return {}
# ── 按「生成方式」选模型 ──────────────────────────────────
# 多图融合 / 文生图 / 图生图 → wan2.7-image-pro(9 图输入,融合强)
# 改造已有图(结构保持重绘)→ qwen-image-edit-plus(指令编辑,保结构)
MODEL_BY_MODE = {
"multi": "wan2.7-image-pro",
"text2img": "wan2.7-image-pro",
"single": "wan2.7-image-pro",
"edit": "qwen-image-edit-plus",
}
# ⚠️ 官方示例里 qwen-image 系列的 content 顺序是「先 image 后 text」,
# 而 wan 系列是「先 text 后 image」。混用会导致指令被忽略。
IMAGE_FIRST_PREFIXES = ("qwen-image",)
def model_for_mode(mode: str) -> str:
return MODEL_BY_MODE.get((mode or "").strip(), DEFAULT_MODEL)
def _api_key() -> str:
"""读取百炼/千问平台 key(agent-local 私有配置)"""
import yaml
p = os.environ.get("AVE_LOCAL_CONFIG") or os.path.expanduser(
"~/workbuddy-agent-os/agent-local/tools/ave/config/local.yaml")
try:
return ((yaml.safe_load(open(p)) or {}).get("aliyun") or {}).get("api_key", "")
except Exception as e:
log.warning("读取 key 失败: %s", e)
return ""
def _assets_dir() -> Path:
"""资产库根目录(与 Dashboard 的 AssetStore 保持一致)"""
return Path(os.environ.get("AVE_ASSETS_DIR")
or os.path.expanduser("~/workbuddy-agent-os/agent-local/tools/ave/assets"))
def _url_to_local(s: str) -> Path | None:
"""把「后端 URL 形式」还原成本地文件路径
前端资产选择器给出的是可访问 URL:
/api/ave/assets/{kind}/{aid}/file/{name}
它与磁盘上的
{assets}/{kind}/{aid}/{name}
是同一个文件。这里做还原(含 URL 解码),并保证不越出资产目录。
"""
from urllib.parse import unquote
if not s.startswith("/api/ave/assets/"):
return None
parts = [x for x in s.split("/") if x] # api ave assets kind aid file name
if len(parts) < 7 or parts[5] != "file":
return None
kind, aid, name = (unquote(parts[3]), unquote(parts[4]), unquote(parts[6]))
base = _assets_dir().resolve()
cand = (base / kind / aid / name)
try:
cand = cand.resolve()
if not str(cand).startswith(str(base)): # 防路径穿越
return None
except Exception:
return None
return cand if cand.exists() else None
def _as_image_value(path_or_url: str) -> str:
"""参考图 → 可提交给模型的值
支持四种输入:
1. http(s) URL → 原样返回
2. data: base64 → 原样返回
3. /api/ave/assets/… → 还原成本地路径后转 base64(前端选择器给的格式)
4. 本地文件路径 → 转 base64
"""
s = str(path_or_url)
if s.startswith(("http://", "https://", "data:")):
return s
p = _url_to_local(s) or Path(s)
if not p.exists():
raise FileNotFoundError(
f"参考图不存在: {p}"
+ ("(未能从 /api/ave/assets/ 形式还原出本地文件)" if s.startswith("/api/") else ""))
b64 = base64.b64encode(p.read_bytes()).decode()
ext = p.suffix.lower().lstrip(".") or "png"
mime = {"jpg": "jpeg", "jpeg": "jpeg", "webp": "webp"}.get(ext, "png")
return f"data:image/{mime};base64,{b64}"
def build_keyframe(
prompt: str,
ref_images: list[str] | None = None,
*,
negative_prompt: str = "",
size: str = DEFAULT_SIZE,
n: int = 1,
model: str = DEFAULT_MODEL,
timeout: int = 180,
) -> list[str]:
"""生成关键帧,返回图片 URL 列表(1~n 张)
prompt 构图/光线/动作瞬间的描述(视觉描述,不含"运动"—— 运动属于视频阶段)
ref_images 参考图(本地路径或 URL),顺序即提示词里的"图1/图2…"
推荐:角色正面 + 角色 45° + 场景对应机位 [+ 道具]
"""
key = _api_key()
if not key:
raise RuntimeError("未找到 aliyun.api_key(agent-local/tools/ave/config/local.yaml)")
# ── 模型能力校验(依据 config/image_models.yaml)──────────
info = model_info(model)
if info.get("status"):
raise RuntimeError(
f"模型「{info.get('name', model)}」尚未接线(状态:{info['status']})。"
f"请先选百炼系模型,或告知我接入。")
if info and info.get("key_field", "aliyun") != "aliyun":
raise RuntimeError(
f"模型「{info.get('name', model)}」用的是 {info.get('key_field')} 凭证,"
f"当前 keyframe_gen 只实现了百炼通道。")
mx = (info.get("limits") or {}).get("max_input_images")
n_given = len(ref_images or [])
if mx and n_given > int(mx):
raise RuntimeError(
f"模型「{info.get('name', model)}」最多接受 {mx} 张输入图,"
f"当前给了 {n_given} 张 —— 请减少参考图,或换模型。")
n_max = (info.get("limits") or {}).get("n_max")
if n_max and int(n) > int(n_max):
raise RuntimeError(f"模型「{info.get('name', model)}」一次最多出 {n_max} 张(当前 {n})。")
full_text = prompt + (f"\n\n【避免】{negative_prompt}" if negative_prompt else "")
imgs = [{"image": _as_image_value(i)} for i in (ref_images or [])]
# 顺序按模型家族区分(见上方 IMAGE_FIRST_PREFIXES 的注释)
if str(model).startswith(IMAGE_FIRST_PREFIXES):
content: list[dict] = imgs + [{"text": full_text}]
else:
content = [{"text": full_text}] + imgs
payload = {
"model": model,
"input": {"messages": [{"role": "user", "content": content}]},
"parameters": {"size": size, "n": int(n), "watermark": False},
}
log.info("🖼 关键帧生成:model=%s refs=%d size=%s n=%d",
model, len(ref_images or []), size, n)
req = urllib.request.Request(API, data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
method="POST")
with _NO_PROXY_OPENER.open(req, timeout=timeout) as r:
d = json.loads(r.read().decode())
out: list[str] = []
for ch in ((d.get("output") or {}).get("choices") or []):
for c in ((ch.get("message") or {}).get("content") or []):
if c.get("image"):
out.append(c["image"])
log.info("✅ 完成,产出 %d 张(usage=%s)", len(out),
json.dumps(d.get("usage") or {}, ensure_ascii=False))
return out
def download(url: str, dest: str | Path, timeout: int = 90) -> str:
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
with _NO_PROXY_OPENER.open(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
dest.write_bytes(r.read())
log.info("已保存: %s (%d KB)", dest, dest.stat().st_size // 1024)
return str(dest)
def main() -> int:
ap = argparse.ArgumentParser(description="关键帧生成(多图融合)")
ap.add_argument("--prompt", required=True, help="画面描述(视觉,不含运动)")
ap.add_argument("--role", action="append", default=[], help="角色参考图(可多次)")
ap.add_argument("--scene", action="append", default=[], help="场景参考图(可多次)")
ap.add_argument("--extra", action="append", default=[], help="其他参考图(道具等)")
ap.add_argument("--negative", default="", help="负向提示词")
ap.add_argument("--size", default=DEFAULT_SIZE,
help="画幅 宽*高(如 960*1696 竖屏 9:16 / 1280*720 横屏)")
ap.add_argument("--n", type=int, default=1)
ap.add_argument("--model", default=DEFAULT_MODEL)
ap.add_argument("--out", default="/tmp/keyframe.png")
ap.add_argument("--text-only", action="store_true", help="不用参考图(纯文生图)")
args = ap.parse_args()
refs = [] if args.text_only else (args.role + args.scene + args.extra)
urls = build_keyframe(args.prompt, refs, negative_prompt=args.negative,
size=args.size, n=args.n, model=args.model)
if not urls:
print("❌ 未返回图片")
return 1
for i, u in enumerate(urls):
dest = args.out if len(urls) == 1 else args.out.replace(".png", f"_v{i+1}.png")
download(u, dest)
return 0
if __name__ == "__main__":
raise SystemExit(main())