移植自 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 带网关原话 / 正常返回)。
210 lines
11 KiB
Python
210 lines
11 KiB
Python
/**
|
||
* ave-test-cap.js — 🧪 能力试跑
|
||
*
|
||
* 单独测一个能力(不跑整条流程):
|
||
* 1. 选能力 → 自动带出「预检」(用哪个供应商/模型/参数/密钥是否就绪)
|
||
* 2. 按 params_schema 填参数
|
||
* 3. 🔍 预检(**零费用**,只校验配置)| ▶️ 真跑(**可能产生费用**,二次确认)
|
||
*
|
||
* 真跑当前支持:文本/图片 → 视频(可灵 kling-v3);其余能力给出"实现未接入 + 位置"
|
||
*/
|
||
const API = '/api/ave';
|
||
|
||
let _caps = [];
|
||
let _cats = [];
|
||
let _cur = null;
|
||
|
||
export async function loadView(container) {
|
||
container.innerHTML = `
|
||
<div style="padding:16px">
|
||
<h2 style="font-size:18px;margin:0 0 12px">🧪 能力试跑
|
||
<span style="font-size:12px;color:var(--text2)">单独测一个能力,不用跑整条流程</span></h2>
|
||
|
||
<div style="padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:12px">
|
||
💡 用法:先点「🔍 预检」确认配置(用哪个供应商/模型、参数是什么、<b>密钥是否就绪</b>)—— <b>预检零费用</b>;<br>
|
||
确认无误后再点「▶️ 真跑」实际调用(<b>可能产生费用</b>,会二次确认)。
|
||
</div>
|
||
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
|
||
<select id="tcCat" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></select>
|
||
<select id="tcCap" style="flex:1;min-width:240px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></select>
|
||
</div>
|
||
|
||
<div id="tcBody"><div style="color:var(--text2);font-size:12px">请选择能力</div></div>
|
||
</div>`;
|
||
|
||
await loadCaps();
|
||
document.getElementById('tcCat').addEventListener('change', () => fillCapSelect());
|
||
document.getElementById('tcCap').addEventListener('change', () => selectCap());
|
||
}
|
||
|
||
async function loadCaps() {
|
||
try {
|
||
const r = await fetch(`${API}/capabilities`);
|
||
const d = await r.json();
|
||
_caps = d.capabilities || [];
|
||
_cats = d.categories || [];
|
||
const c = document.getElementById('tcCat');
|
||
c.innerHTML = `<option value="">全部类别</option>` +
|
||
_cats.map(x => `<option value="${esc(x.id)}">${x.icon || ''} ${esc(x.name)}</option>`).join('');
|
||
fillCapSelect();
|
||
} catch (e) {
|
||
document.getElementById('tcBody').innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载能力失败:${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function fillCapSelect() {
|
||
const cat = document.getElementById('tcCat').value;
|
||
const list = cat ? _caps.filter(c => c.category === cat) : _caps;
|
||
const s = document.getElementById('tcCap');
|
||
const keep = s.value;
|
||
s.innerHTML = list.map(c => `<option value="${esc(c.id)}">${statusIcon(c.status)} ${esc(c.name)}(${esc(c.id)})</option>`).join('');
|
||
if (list.some(c => c.id === keep)) s.value = keep;
|
||
selectCap();
|
||
}
|
||
|
||
function statusIcon(st) { return { ready: '✅', partial: '🟡', planned: '⚪' }[st] || '⚪'; }
|
||
|
||
function selectCap() {
|
||
const cid = document.getElementById('tcCap').value;
|
||
_cur = _caps.find(c => c.id === cid) || null;
|
||
renderForm();
|
||
}
|
||
|
||
function renderForm() {
|
||
const el = document.getElementById('tcBody');
|
||
if (!_cur) { el.innerHTML = ''; return; }
|
||
const c = _cur;
|
||
const defaults = c.defaults || {};
|
||
const provs = c.providers || [];
|
||
const provId = defaults.provider || (provs[0] || {}).id || '';
|
||
const prov = provs.find(p => p.id === provId) || {};
|
||
|
||
// 参数表单(按 params_schema)
|
||
const schema = c.params_schema || {};
|
||
const fields = Object.entries(schema).map(([k, s]) => {
|
||
const v = defaults[k] ?? s.default ?? '';
|
||
const label = `<span style="font-size:11px;color:var(--text2)">${esc(s.label || k)}${s.required ? ' *' : ''}</span>`;
|
||
if (s.type === 'enum') {
|
||
return `<label style="display:block">${label}
|
||
<select data-p="${esc(k)}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||
${(s.options || []).map(o => `<option value="${esc(o)}" ${String(v) === String(o) ? 'selected' : ''}>${esc(o)}</option>`).join('')}
|
||
</select></label>`;
|
||
}
|
||
if (s.type === 'number') {
|
||
return `<label style="display:block">${label}
|
||
<input type="number" data-p="${esc(k)}" value="${esc(v)}" ${s.min !== undefined ? `min="${s.min}"` : ''} ${s.max !== undefined ? `max="${s.max}"` : ''}
|
||
style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>`;
|
||
}
|
||
return `<label style="display:block">${label}
|
||
<input data-p="${esc(k)}" value="${esc(v)}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>`;
|
||
}).join('');
|
||
|
||
// 常用输入(prompt / text 等,schema 里没有也给一个)
|
||
const extra = (!schema.prompt && !schema.text) ? `
|
||
<label style="display:block"><span style="font-size:11px;color:var(--text2)">提示词 / 文本(prompt)</span>
|
||
<textarea id="tcPrompt" rows="3" placeholder="例如:一个人坐在窗边看书,自然光,纪录片质感"
|
||
style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px;resize:vertical"></textarea></label>` : '';
|
||
|
||
el.innerHTML = `
|
||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;flex-wrap:wrap">
|
||
<div>
|
||
<div style="font-size:14px;font-weight:600">${esc(c.name)}
|
||
<code style="font-size:10px;color:var(--text2)">${esc(c.id)}</code></div>
|
||
<div style="font-size:11px;color:var(--text2);margin-top:3px">${esc(c.description || '')}</div>
|
||
</div>
|
||
<span style="font-size:10px;color:${c.status === 'ready' ? '#22c55e' : '#f59e0b'}">${statusIcon(c.status)} ${esc(c.status)}</span>
|
||
</div>
|
||
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px">
|
||
<label style="display:block"><span style="font-size:11px;color:var(--text2)">供应商</span>
|
||
<select id="tcProv" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||
${provs.map(p => `<option value="${esc(p.id)}" ${p.id === provId ? 'selected' : ''}>${esc(p.name)}${p.key_configured ? ' ✅' : ' ⚠️未配密钥'}</option>`).join('') || '<option value="">(无)</option>'}
|
||
</select></label>
|
||
<label style="display:block"><span style="font-size:11px;color:var(--text2)">模型(由供应商决定)</span>
|
||
<input id="tcModel" value="${esc(prov.model || '')}" readonly style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text2);border-radius:6px;font-size:12px"></label>
|
||
</div>
|
||
|
||
<div style="margin-top:10px;display:grid;gap:8px">
|
||
${extra}
|
||
${fields || '<div style="font-size:11px;color:var(--text2)">(该能力无可调参数)</div>'}
|
||
</div>
|
||
|
||
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
||
<button onclick="window._tcRun(true)" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">🔍 预检(零费用)</button>
|
||
<button onclick="window._tcRun(false)" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">▶️ 真跑(可能产生费用)</button>
|
||
<span style="font-size:10px;color:var(--text2);align-self:center">
|
||
密钥位置:<code>${esc(prov.key_ref || '—')}</code> · 实现:<code>${esc((c.impl || '').slice(0, 40))}</code>
|
||
</span>
|
||
</div>
|
||
|
||
<div id="tcResult" style="margin-top:12px"></div>
|
||
</div>`;
|
||
|
||
// 供应商切换 → 更新模型显示
|
||
document.getElementById('tcProv')?.addEventListener('change', (e) => {
|
||
const p = provs.find(x => x.id === e.target.value) || {};
|
||
const m = document.getElementById('tcModel');
|
||
if (m) m.value = p.model || '';
|
||
});
|
||
}
|
||
|
||
window._tcRun = async (dryRun) => {
|
||
if (!_cur) return;
|
||
const params = {};
|
||
document.querySelectorAll('[data-p]').forEach(el => {
|
||
let v = el.value;
|
||
if (el.type === 'number') v = Number(v);
|
||
params[el.dataset.p] = v;
|
||
});
|
||
const pt = document.getElementById('tcPrompt');
|
||
if (pt && pt.value.trim()) params.prompt = pt.value.trim();
|
||
const prov = document.getElementById('tcProv')?.value;
|
||
if (prov) params.provider = prov;
|
||
|
||
if (!dryRun) {
|
||
const warn = `▶️ 真跑「${_cur.name}」\n\n供应商:${prov}\n参数:${JSON.stringify(params).slice(0, 180)}\n\n⚠️ 这会实际调用外部 API,可能产生费用。\n确认继续?`;
|
||
if (!confirm(warn)) return;
|
||
}
|
||
|
||
const el = document.getElementById('tcResult');
|
||
el.innerHTML = `<div style="font-size:12px;color:var(--text2)">${dryRun ? '🔍 预检中…' : '▶️ 执行中(可能需要 1~2 分钟)…'}</div>`;
|
||
try {
|
||
const r = await fetch(`${API}/capabilities/${encodeURIComponent(_cur.id)}/test`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ params, dry_run: dryRun }),
|
||
});
|
||
const d = await r.json();
|
||
if (d.status !== 'ok') {
|
||
el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ ${esc(d.detail || '失败')}</div>`;
|
||
return;
|
||
}
|
||
const p = d.preflight || {};
|
||
if (dryRun || d.not_implemented) {
|
||
el.innerHTML = `
|
||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.9">
|
||
<div style="color:#22c55e;font-weight:600">${d.not_implemented ? 'ℹ️ 预检通过(真跑未接入)' : '✅ 预检完成'}</div>
|
||
<div>供应商:<b>${esc(p.provider_name)}</b> ${p.key_ready ? '(✅ 密钥已配)' : '(⚠️ 密钥未配 → 去「🔑 API 配置」)'}</div>
|
||
<div>模型:<code>${esc(p.model || '—')}</code> · 执行:${esc(p.execution)}</div>
|
||
<div>参数:<code>${esc(JSON.stringify(p.params))}</code></div>
|
||
<div style="color:var(--text2);margin-top:4px">${esc(d.message || '')}</div>
|
||
</div>`;
|
||
} else {
|
||
el.innerHTML = `
|
||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.9">
|
||
<div style="color:#22c55e;font-weight:600">✅ ${esc(d.message || '试跑完成')}</div>
|
||
<div>产物:<code>${esc(d.output || '')}</code></div>
|
||
${(d.output || '').endsWith('.mp4') ? `<video controls src="/api/ave/file?path=${encodeURIComponent(d.output)}" style="width:100%;max-width:320px;margin-top:8px;border-radius:6px"></video>` : ''}
|
||
</div>`;
|
||
}
|
||
} catch (e) {
|
||
el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ ${esc(e.message)}</div>`;
|
||
}
|
||
};
|
||
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||
}
|