移植自 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 带网关原话 / 正常返回)。
284 lines
12 KiB
Python
284 lines
12 KiB
Python
#!/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())
|