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 带网关原话 / 正常返回)。
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user