feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,704 @@
"""
口播素材系统 - Web 界面(Gradio)
用法:python app.py
功能:
- 搜索/筛选素材(关键词、平台、标签、情绪、下载状态)
- 查看素材详情和 AI 分析结果
- 一键复制金句
- 标记下载 / 本地播放
启动后访问:http://127.0.0.1:7860
"""
import os
import sys
import json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from utils import load_config, get_db, init_db, setup_logger, PROJECT_ROOT
try:
import gradio as gr
except ImportError:
print("[ERROR] Gradio 未安装,请执行:pip install gradio")
sys.exit(1)
# ============================================================
# 数据库查询
# ============================================================
def search_materials(
keyword: str = "",
platform: str = "全部",
emotion: str = "全部",
has_file: str = "全部",
worth_download: str = "全部",
sort_by: str = "fetched_at DESC",
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict], int]:
"""搜索素材,返回(结果列表, 总数)"""
config = load_config()
db = get_db(init_db())
where_clauses = []
params = []
if keyword:
where_clauses.append("(m.title LIKE ? OR m.description LIKE ? OR a.tags LIKE ? OR a.golden_quote LIKE ?)")
kw = f"%{keyword}%"
params.extend([kw, kw, kw, kw])
if platform and platform != "全部":
where_clauses.append("m.platform = ?")
params.append(platform)
if emotion and emotion != "全部":
where_clauses.append("a.emotion = ?")
params.append(emotion)
if has_file == "已下载":
where_clauses.append("m.download_status = 'done'")
elif has_file == "未下载":
where_clauses.append("m.download_status != 'done'")
if worth_download == "推荐下载":
where_clauses.append("a.worth_downloading = 'yes'")
elif worth_download == "不推荐":
where_clauses.append("a.worth_downloading = 'no'")
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# 总数
count_sql = f"""
SELECT COUNT(*) FROM materials m
LEFT JOIN analysis a ON m.id = a.material_id
WHERE {where_sql}
"""
total = db.execute(count_sql, params).fetchone()[0]
# 排序和分页
offset = (page - 1) * page_size
data_sql = f"""
SELECT m.*, a.tags, a.golden_quote, a.core_idea, a.structure,
a.emotion, a.worth_downloading, a.download_reason
FROM materials m
LEFT JOIN analysis a ON m.id = a.material_id
WHERE {where_sql}
ORDER BY {sort_by}
LIMIT ? OFFSET ?
"""
params.extend([page_size, offset])
rows = db.execute(data_sql, params).fetchall()
results = [dict(r) for r in rows]
db.close()
return results, total
def get_material_detail(material_id: int) -> dict:
"""获取素材详情"""
db = get_db(init_db())
row = db.execute("""
SELECT m.*, a.tags, a.golden_quote, a.core_idea, a.structure,
a.emotion, a.worth_downloading, a.download_reason, a.raw_model_output
FROM materials m
LEFT JOIN analysis a ON m.id = a.material_id
WHERE m.id = ?
""", (material_id,)).fetchone()
db.close()
return dict(row) if row else {}
def mark_download(material_id: int) -> str:
"""标记素材为待下载"""
db = get_db(init_db())
db.execute("UPDATE materials SET download_status = 'pending' WHERE id = ?", (material_id,))
db.commit()
db.close()
return "已加入下载队列"
def get_stats() -> dict:
"""获取统计信息"""
db = get_db(init_db())
total = db.execute("SELECT COUNT(*) FROM materials").fetchone()[0]
analyzed = db.execute("SELECT COUNT(*) FROM materials WHERE analyzed = 1").fetchone()[0]
downloaded = db.execute("SELECT COUNT(*) FROM materials WHERE download_status = 'done'").fetchone()[0]
pending = db.execute("SELECT COUNT(*) FROM materials WHERE download_status = 'pending'").fetchone()[0]
recommended = db.execute("SELECT COUNT(*) FROM analysis WHERE worth_downloading = 'yes'").fetchone()[0]
db.close()
return {
"总素材": total,
"已分析": analyzed,
"已下载": downloaded,
"待下载": pending,
"AI推荐": recommended,
}
# ============================================================
# Gradio 界面
# ============================================================
def build_app() -> gr.Blocks:
config = load_config()
# 初始化数据库
init_db()
stats = get_stats()
with gr.Blocks(
title=config.get("web", {}).get("title", "口播灵感工坊"),
) as app:
gr.Markdown("# 🎬 口播灵感工坊 — 素材采集 → 脚本生成")
with gr.Tabs():
# ====== 标签页1:素材库 ======
with gr.Tab("📦 素材库"):
stats_text = gr.Markdown(
f"📊 **统计** | 总素材: {stats['总素材']} | 已分析: {stats['已分析']} | "
f"已下载: {stats['已下载']} | 待下载: {stats['待下载']} | AI推荐: {stats['AI推荐']}"
)
with gr.Row():
keyword_input = gr.Textbox(label="关键词搜索", placeholder="标题/描述/标签/金句", scale=2)
platform_dd = gr.Dropdown(
label="平台", choices=["全部", "xiaohongshu", "douyin", "bilibili"],
value="全部", scale=1
)
emotion_dd = gr.Dropdown(
label="情绪", choices=["全部", "治愈", "励志", "震惊", "沉思", "有趣", "感动"],
value="全部", scale=1
)
with gr.Row():
has_file_dd = gr.Dropdown(
label="下载状态", choices=["全部", "已下载", "未下载"],
value="全部", scale=1
)
worth_dd = gr.Dropdown(
label="AI推荐", choices=["全部", "推荐下载", "不推荐"],
value="全部", scale=1
)
sort_dd = gr.Dropdown(
label="排序", choices=["fetched_at DESC", "like_count DESC", "collect_count DESC"],
value="fetched_at DESC", scale=1
)
search_btn = gr.Button("🔍 搜索", variant="primary", scale=1)
refresh_btn = gr.Button("🔄 刷新统计", scale=1)
result_table = gr.Dataframe(
headers=["ID", "平台", "标题", "作者", "金句", "情绪", "推荐下载", "状态"],
label="搜索结果",
interactive=False,
)
with gr.Row():
with gr.Column(scale=1):
detail_id = gr.Number(label="素材ID", precision=0)
with gr.Row():
detail_btn = gr.Button("查看详情", variant="secondary")
download_btn = gr.Button("📥 加入下载队列", variant="stop")
with gr.Column(scale=2):
detail_output = gr.Markdown("选择一条素材后点击「查看详情」")
# ====== 标签页2:脚本工厂 ======
with gr.Tab("🎬 脚本工厂"):
gr.Markdown("### 从素材或主题生成 AVE v2.0 导演脚本")
with gr.Row():
script_source = gr.Radio(
choices=["输入主题", "从素材库ID"],
label="脚本来源", value="输入主题"
)
with gr.Row():
topic_input = gr.Textbox(
label="主题", placeholder="输入口播主题,如:普通人如何抓住AI时代的机会",
visible=True, scale=3
)
material_id_input = gr.Number(
label="素材ID", precision=0, value=0,
visible=False, scale=1
)
with gr.Row():
style_dd = gr.Dropdown(
label="视频风格",
choices=["knowledge_lecture", "bedtime_story", "funny_talk", "tech_review"],
value="knowledge_lecture", scale=1
)
gen_btn = gr.Button("🚀 生成脚本", variant="primary", scale=1)
with gr.Row():
with gr.Column():
gr.Markdown("#### 📄 AVE 导演脚本 (YAML)")
yaml_output = gr.Textbox(label="", lines=20, max_lines=30)
with gr.Column():
gr.Markdown("#### 📝 说明文档 (MD)")
md_output = gr.Textbox(label="", lines=20, max_lines=30)
with gr.Row():
project_id_display = gr.Textbox(label="项目ID", visible=False)
save_btn = gr.Button("💾 保存到素材库", variant="primary")
save_status = gr.Markdown("")
gr.Markdown("---")
gr.Markdown("#### 📁 历史脚本")
with gr.Row():
refresh_list_btn = gr.Button("🔄 刷新列表", scale=1)
script_list_md = gr.Markdown("点击刷新查看已生成的脚本")
with gr.Row():
view_pid = gr.Textbox(label="查看脚本(粘贴项目ID)", placeholder="如 vid_20260504_8817", scale=2)
view_btn = gr.Button("📖 查看", scale=1)
with gr.Row():
with gr.Column():
gr.Markdown("##### YAML")
view_yaml = gr.Textbox(label="", lines=8, max_lines=15)
with gr.Column():
gr.Markdown("##### MD")
view_md = gr.Textbox(label="", lines=8, max_lines=15)
# 历史脚本回调
def list_scripts():
from pathlib import Path
output_dir = Path(__file__).parent / "scripts_output"
yamls = sorted(output_dir.glob("*.yaml"), reverse=True)
if not yamls:
return "(暂无已生成的脚本)"
lines = ["| 项目ID | 风格 | 段落 | 日期 |",
"|--------|------|------|------|"]
for y in yamls:
pid = y.stem
import yaml as pyyaml
try:
data = pyyaml.safe_load(y.read_text(encoding='utf-8'))
style = data.get('meta', {}).get('style', '?')
segs = len(data.get('segments', []))
except:
style = '?'; segs = '?'
date = pid.split('_')[1] if '_' in pid else '?'
date = f"{date[:4]}-{date[4:6]}-{date[6:8]}" if len(date)>=8 else '?'
lines.append(f"| `{pid}` | {style} | {segs}段 | {date} |")
lines.append("\n复制项目ID到下方输入框查看具体内容")
return "\n".join(lines)
def view_script(pid):
if not pid:
return "请输入项目ID", ""
from pathlib import Path
output_dir = Path(__file__).parent / "scripts_output"
yaml_path = output_dir / f"{pid.strip()}.yaml"
md_path = output_dir / f"{pid.strip()}.md"
yaml_c = yaml_path.read_text(encoding='utf-8') if yaml_path.exists() else "文件不存在"
md_c = md_path.read_text(encoding='utf-8') if md_path.exists() else "文件不存在"
return yaml_c, md_c
refresh_list_btn.click(list_scripts, outputs=[script_list_md])
view_btn.click(view_script, inputs=[view_pid], outputs=[view_yaml, view_md])
# 脚本工厂回调
def toggle_source(source):
show_topic = source == "输入主题"
return (
gr.update(visible=show_topic),
gr.update(visible=not show_topic),
)
def generate_script(topic, material_id, style):
import subprocess, sys
from pathlib import Path
script_path = Path(__file__).parent / "script_factory.py"
cmd = [sys.executable, str(script_path), "--topic", topic, "--style", style]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
return "生成失败:\n" + result.stderr[:500], "生成失败", ""
# 找到最新生成的YAML和MD
output_dir = Path(__file__).parent / "scripts_output"
yamls = sorted(output_dir.glob("*.yaml"))
mds = sorted(output_dir.glob("*.md"))
if not yamls:
return "未找到生成的脚本文件", "未找到", ""
latest_yaml = yamls[-1]
latest_md = mds[-1]
pid = latest_yaml.stem
yaml_content = latest_yaml.read_text(encoding="utf-8")
md_content = latest_md.read_text(encoding="utf-8")
return yaml_content, md_content, pid
except subprocess.TimeoutExpired:
return "生成超时(>5分钟)", "超时", ""
except Exception as e:
return f"生成异常: {str(e)}", "异常", ""
def save_script(pid):
if not pid:
return "请先生成脚本"
# 复制到 materials/
import shutil
from pathlib import Path
output_dir = Path(__file__).parent / "scripts_output"
materials_dir = Path.home() / "workbuddy-agent-os/agent-local/materials/videos"
materials_dir.mkdir(parents=True, exist_ok=True)
yaml_src = output_dir / f"{pid}.yaml"
md_src = output_dir / f"{pid}.md"
if yaml_src.exists():
shutil.copy2(str(yaml_src), str(materials_dir / f"{pid}.yaml"))
if md_src.exists():
shutil.copy2(str(md_src), str(materials_dir / f"{pid}.md"))
return f"✅ 已保存到 `{materials_dir}`"
# 事件绑定
script_source.change(
toggle_source,
inputs=[script_source],
outputs=[topic_input, material_id_input]
)
gen_btn.click(
generate_script,
inputs=[topic_input, material_id_input, style_dd],
outputs=[yaml_output, md_output, project_id_display]
)
save_btn.click(
save_script,
inputs=[project_id_display],
outputs=[save_status]
)
# ====== 标签页3:智能搜索 ======
with gr.Tab("🔍 智能搜索"):
gr.Markdown("### 搜索平台内容 → 预览筛选 → 下载分析 → 生成脚本")
gr.Markdown("""> **百度**: 直连搜索,无需扩展 ✅
> **抖音/知乎/小红书/B站/微博**: 需安装 OpenCLI Chrome 扩展(详见帮助)
>
> **使用建议**:
> - 选"百度"可直接搜热点/选题
> - 其他平台需先装 Chrome 扩展才能用
> - 也可直接粘贴链接到脚本工厂
""")
with gr.Row():
search_keyword = gr.Textbox(label="关键词", placeholder="输入搜索关键词", scale=3)
search_platform = gr.Dropdown(
label="平台", choices=["baidu", "douyin", "zhihu", "xiaohongshu", "bilibili", "weibo"],
value="baidu", scale=1
)
do_search_btn = gr.Button("🔍 搜索", variant="primary", scale=1)
search_status = gr.Markdown("")
# 搜索结果表格(带序号勾选)
search_results = gr.Dataframe(
headers=["选", "标题", "平台", "作者", "简介", "链接"],
label="搜索结果(勾选后点下方按钮处理)",
interactive=True,
col_count=(6, "fixed"),
)
with gr.Row():
max_items = gr.Slider(minimum=3, maximum=30, value=10, step=1, label="最大结果数", scale=1)
process_selected_btn = gr.Button("📥 下载并分析选中项", variant="primary", scale=2)
process_status = gr.Markdown("")
with gr.Row():
with gr.Column():
gr.Markdown("#### 📄 生成的 YAML")
search_yaml_out = gr.Textbox(label="", lines=12, max_lines=20)
with gr.Column():
gr.Markdown("#### 📝 说明文档")
search_md_out = gr.Textbox(label="", lines=12, max_lines=20)
# 搜索回调
def do_web_search(keyword, platform, max_n):
if not keyword:
return [], "请输入关键词"
import sys, importlib
import collect as collect_mod
importlib.reload(collect_mod)
# 百度直接走 HTTP,其他平台走 OpenCLI
results = collect_mod.search_web(keyword, platform, int(max_n))
if not results:
msg = "未找到结果"
if platform != "baidu":
msg += "。请确认: ① Chrome 扩展已安装 ② opencli daemon 运行中 ③ Chrome 已登录该平台"
return [[""] * 6], msg
table = []
for i, r in enumerate(results):
title = (r.get("title", "") or "")[:30]
brief = (r.get("brief", "") or "")[:50]
url = (r.get("url", "") or "")[:60]
author = (r.get("author", "") or "")[:15]
table.append(["☐", title, r.get("platform", ""), author, brief, url])
return table, f"✅ 找到 {len(results)} 条结果,勾选感兴趣的,点击下方按钮处理"
def process_selected(table_data, keyword, platform):
"""处理选中项:下载 + 转写 + 生成脚本"""
if not table_data:
return "", "请先搜索"
selected = []
for row in table_data:
if row and row[0] in ("☑", "✓", "✅", True, "True", "true"):
selected.append(row)
if not selected:
return "", "请勾选至少一条结果"
total = len(selected)
report = []
for i, item in enumerate(selected):
title = item[1] if len(item) > 1 else ""
url = item[5] if len(item) > 5 else ""
report.append(f" {i+1}. {title}")
result = f"✅ 选中 {total} 条:\n" + "\n".join(report)
result += "\n\n⚠️ 批量下载转写功能需在命令行执行:"
result += f"\n python collect.py --platform {platform} --keyword \"{keyword}\""
result += "\n\n当前界面支持:复制选中链接 → 到脚本工厂输入主题生成脚本"
return "", result
do_search_btn.click(
do_web_search,
inputs=[search_keyword, search_platform, max_items],
outputs=[search_results, search_status]
)
process_selected_btn.click(
process_selected,
inputs=[search_results, search_keyword, search_platform],
outputs=[search_yaml_out, process_status]
)
# ====== 标签页4:跨平台情报 ======
with gr.Tab("🌐 跨平台情报"):
gr.Markdown("### 全平台并行搜索 → 统一对比 → 勾选处理")
gr.Markdown("""> 输入主题后系统自动搜索 **百度 + 抖音 + 知乎 + 小红书 + B站 + 微博** 六个平台
> 结果按匹配度排序,一眼对比哪个平台的素材最适合你
""")
with gr.Row():
xp_keyword = gr.Textbox(label="主题", placeholder="输入要搜索的主题/标题/思路", scale=3)
xp_max = gr.Slider(minimum=3, maximum=10, value=5, step=1, label="每平台条数")
xp_search_btn = gr.Button("🚀 全平台搜索", variant="primary", scale=1)
xp_status = gr.Markdown("")
# 统一结果(Markdown 展示)
xp_results_md = gr.Markdown("搜索结果将显示在这里")
xp_cache = gr.State([])
with gr.Row():
with gr.Column(scale=2):
gr.Markdown("#### 选择要处理的内容")
gr.Markdown("从上方结果中复制序号(如 `1,3,5`)到下方输入框")
xp_selected = gr.Textbox(
label="选择序号",
placeholder="输入序号,如 1,3,5",
lines=2
)
xp_process_btn = gr.Button("📥 查看选中项", variant="primary")
with gr.Column(scale=1):
xp_output = gr.Markdown("")
# 搜索回调
def do_xp_search(keyword, max_n):
if not keyword:
return "请输入主题", [], ""
import sys, importlib
import collect as cm
importlib.reload(cm)
results = cm.search_all(keyword, int(max_n))
if not results:
return "未找到结果,试试其他关键词", [], ""
# 按平台分组统计
platform_count = {}
for r in results:
p = r.get("platform", "?")
platform_count[p] = platform_count.get(p, 0) + 1
summary = " | ".join([f"{cm.PLATFORM_LABELS.get(p,p)}: {c}条" for p, c in platform_count.items()])
# Markdown 表格(紧凑格式)
lines = ["| # | 平台 | 标题(点击链接查看原文) | 作者 |",
"|---|------|--------------------------|------|"]
for i, r in enumerate(results, 1):
title = (r.get("title", "") or "")[:40]
author = (r.get("author", "") or "")[:10]
label = r.get("label", "")
url = (r.get("url", "") or "")
# 加链接
linked_title = f"[{title}]({url})" if url else title
lines.append(f"| {i} | {label} | {linked_title} | {author} |")
md = "\n".join(lines)
md += f"\n\n✅ 共 **{len(results)}** 条。{summary}"
md += "\n\n> 复制上方序号(如 `1,3,5`)到右侧输入框查看详情。点击标题链接直接打开原文。"
return md, results
def do_xp_process(selection_text, cached_results):
if not cached_results:
return "请先搜索"
if not selection_text.strip():
return "请输入要查看的序号"
try:
indices = [int(x.strip()) for x in selection_text.split(",") if x.strip().isdigit()]
selected = []
for idx in indices:
if 1 <= idx <= len(cached_results):
selected.append(cached_results[idx - 1])
if not selected:
return "未匹配到有效序号"
report_parts = []
for i, r in enumerate(selected):
report_parts.append(f"**{i+1}. [{r.get('label','')}] {r.get('title','')}**")
report_parts.append(f" - 平台: {r.get('platform','')} | 作者: {r.get('author','')} | 类型: {r.get('type','')}")
report_parts.append(f" - 链接: {r.get('url','')}")
report_parts.append(f" - 简介: {r.get('brief','')}")
report_parts.append("")
return "\n".join(report_parts)
except:
return "序号格式错误,请用逗号分隔(如:1,3,5)"
xp_search_btn.click(
do_xp_search,
inputs=[xp_keyword, xp_max],
outputs=[xp_results_md, xp_cache]
)
xp_process_btn.click(
do_xp_process,
inputs=[xp_selected, xp_cache],
outputs=[xp_output]
)
# 素材库事件绑定(共用)
def show_detail(material_id):
if not material_id:
return "请输入素材ID"
detail = get_material_detail(int(material_id))
if not detail:
return "未找到该素材"
local_info = ""
if detail.get("local_files"):
files = json.loads(detail["local_files"])
local_info = f"\n\n**本地文件**:\n"
for k, v in files.items():
local_info += f"- {k}: `{v}`\n"
tags = detail.get("tags") or "未分析"
quote = detail.get("golden_quote") or "—"
idea = detail.get("core_idea") or "—"
structure = detail.get("structure") or "—"
emotion = detail.get("emotion") or "—"
worth = detail.get("worth_downloading") or "—"
reason = detail.get("download_reason") or "—"
status = detail.get("download_status") or "none"
return f"""### {detail.get('title', '无标题')}
**平台**:{detail.get('platform', '')} | **作者**:{detail.get('author', '')}
**链接**:{detail.get('url', '')}
**状态**:{status}
---
**🏷️ 标签**:{tags}
**💡 金句**:> {quote}
**🎯 核心立意**:{idea}
**📐 脚本结构**:{structure}
**🎭 情绪**:{emotion}
**⭐ 推荐下载**:{worth} — {reason}
{local_info}"""
def do_search(keyword, platform, emotion, has_file, worth, sort_by):
results, total = search_materials(keyword, platform, emotion, has_file, worth, sort_by)
if not results:
return [["—"] * 8]
table_data = []
for r in results:
table_data.append([
r["id"],
r["platform"],
(r["title"] or "")[:30],
(r["author"] or "")[:15],
(r.get("golden_quote") or "")[:25],
r.get("emotion") or "—",
r.get("worth_downloading") or "—",
r.get("download_status") or "none",
])
return table_data
def do_download(material_id):
if not material_id:
return "请输入素材ID"
return mark_download(int(material_id))
def refresh_stats():
s = get_stats()
return (
f"📊 **统计** | 总素材: {s['总素材']} | 已分析: {s['已分析']} | "
f"已下载: {s['已下载']} | 待下载: {s['待下载']} | AI推荐: {s['AI推荐']}"
)
# 素材库事件绑定
search_btn.click(do_search, inputs=[keyword_input, platform_dd, emotion_dd, has_file_dd, worth_dd, sort_dd], outputs=[result_table])
detail_btn.click(show_detail, inputs=[detail_id], outputs=[detail_output])
download_btn.click(do_download, inputs=[detail_id], outputs=[detail_output])
refresh_btn.click(refresh_stats, outputs=[stats_text])
return app
def main():
config = load_config()
host = config.get("web", {}).get("host", "127.0.0.1")
port = config.get("web", {}).get("port", 7860)
# 清除代理(防止 Gradio 6.0 启动时被代理拦截 localhost)
for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY"]:
os.environ.pop(key, None)
app = build_app()
print(f"[INFO] 启动 Web 界面: http://{host}:{port}")
app.launch(server_name=host, server_port=port, theme=gr.themes.Soft())
if __name__ == "__main__":
main()