移植自 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 带网关原话 / 正常返回)。
705 lines
31 KiB
Python
705 lines
31 KiB
Python
"""
|
||
口播素材系统 - 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()
|