Files
HeXi/hexi/plugins/nonebot_plugin_video_analysis/web_hub.py
T
sansenhoshiandClaude Code a789a44f5d feat(video-analysis): 抖音免浏览器解析链路 + 快手支持 + 小红书加固 + 发送防重复
- 抖音(2026-10-13):删除 SSR + playwright 旧链路(douyin_ssr.py 移除,
  douyin_api.py 精简为下载工具),新增 douyin_web.py(x-tt-argus 网关头 +
  web detail 接口 + 最高码率梯/多 CDN 回退下载)与 douyin_sign.py
  (纯 Python a_bogus 签名,vendor 自 f2(Apache-2.0),依赖 gmssl SM3);
  实测 10/10 成功率、单作品 <1s(旧浏览器链路 5-15s 且随机撞验证码)
- 快手(2026-10-08):kuaishou_content.py 分享页 INIT_STATE 静态解析(无登录态),
  清晰度梯最佳档 + 图集 URL 组装,入口/策略/Web 面板同步接入
- 小红书:按链接来源定国际/国内站优先序、视频 backupUrls 逐候选回退、
  图片 urlDefault→infoList WB_DFT→url 三档回退
- 长视频防重复发送(2026-10-13):媒体发送带 per-call _timeout(默认 300s),
  超时=结果未知不再降级重试;auto/active 两条 handler 互斥
- Web 解析封面抽帧 ffmpeg 加 CREATE_NO_WINDOW(pythonw 下不再弹 cmd 黑窗)
- 依赖:+gmssl;测试:新增 test_douyin_web / test_kuaishou_parse / test_video_cleanup,
  扩充 test_video_policy / test_video_sender_link / test_video_group_file / test_video_media_naming

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:17 +08:00

222 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频解析 Web API 子应用(挂载到 /api/video_analysis)。
群策略(data/list.json v3,见 policy.py)的唯一 Web 读写入口,
鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
前端页面:hexi/web/src/plugins/video_analysis/index.tsx。
写入全部落在 `policy.PolicyStore` 上(加锁 + 原子替换 + 归一化),
所以这里不需要再做字段校验,只要校验群号形态。
另有一组「链接解析 + 预览」接口(/parse、/jobs、/jobs/{id}/refresh):
粘链接 → 起任务 → 出下载链接 + 页面内预览,实现在 services/web_jobs.py。
**每条路由都要自带 `dependencies=[auth]`** —— mount 层没有兜底,
漏一条就是匿名可访问(包括这条"让服务器去抓任意 URL"的接口)。
"""
from __future__ import annotations
from fastapi import Depends, FastAPI
from fastapi.responses import JSONResponse
from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import Adapter
from hexi.web_hub.web_auth import get_current_user, require_admin
from .cleanup import cleanup_config, run_stats, submit_clean
from .policy import PLANS, PLATFORMS, STORE, Policy
from .services.web_jobs import JOBS, extract_urls
API = require_admin
def _ok(data=None, msg: str = "ok") -> JSONResponse:
return JSONResponse({"status": 0, "msg": msg, "data": data})
def _fail(msg: str, status: int = 400) -> JSONResponse:
return JSONResponse({"status": status, "msg": msg})
async def _group_names() -> dict[str, str]:
"""群号 → 群名;拿不到 bot(未连接)时返回空表,前端只显示群号。"""
try:
bots = get_adapter(Adapter).bots
bot = next(iter(bots.values()), None)
if bot is None:
return {}
return {
str(g["group_id"]): g.get("group_name") or ""
for g in await bot.get_group_list()
}
except Exception: # noqa: BLE001 — 未连接/适配器未加载都按拿不到处理
return {}
def _payload(policy: Policy) -> dict:
"""策略的 JSON 视图;sends_link 是"实际会不会发链接"(供前端置灰)。"""
data = policy.to_dict()
data["sends_link"] = policy.sends_link
return data
def build_admin_app() -> FastAPI | None:
"""构建群策略管理 API 子应用(挂载到 /api/video_analysis)。"""
app = FastAPI(title="Video Analysis API")
auth = require_admin
@app.get("/overview", response_class=JSONResponse, dependencies=[auth])
async def overview():
"""一次拿全:群策略列表 + 默认节 + 黑名单 + 平台/方案选项。"""
names = await _group_names()
groups = [
{
"group_id": gid,
"group_name": names.get(gid, ""),
"online": gid in names,
"policy": _payload(policy),
}
for gid, policy in sorted(STORE.all_groups().items(), key=_sort_key)
]
return _ok(
{
"groups": groups,
"default": _payload(STORE.default_policy()),
"blacklist": STORE.blacklist(),
"platforms": list(PLATFORMS),
"plans": list(PLANS),
"online": bool(names),
}
)
@app.post("/group", response_class=JSONResponse, dependencies=[auth])
async def save_group(data: dict):
"""新增/覆盖单个群的策略(群不存在则加入白名单)。"""
gid = str(data.get("group_id", "")).strip()
if not gid.isdigit():
return _fail("群号必须是纯数字")
policy = await STORE.set_group(gid, Policy.from_dict(data.get("policy")))
return _ok(_payload(policy), f"群 {gid} 策略已保存")
@app.delete("/group/{group_id}", response_class=JSONResponse, dependencies=[auth])
async def remove_group(group_id: str):
"""移出白名单(该群不再解析)。"""
if not await STORE.remove_group(group_id):
return _fail(f"群 {group_id} 不在白名单中")
return _ok({"group_id": group_id}, f"群 {group_id} 已移出白名单")
@app.post("/default", response_class=JSONResponse, dependencies=[auth])
async def save_default(data: dict):
"""保存默认策略(私聊与未配置群使用)。"""
policy = await STORE.set_default(Policy.from_dict(data.get("policy", data)))
return _ok(_payload(policy), "默认策略已保存")
@app.post("/blacklist", response_class=JSONResponse, dependencies=[auth])
async def save_blacklist(data: dict):
"""整体替换全局黑名单(QQ 列表)。"""
raw = data.get("blacklist", [])
if not isinstance(raw, list):
return _fail("blacklist 必须是列表")
values = [str(x).strip() for x in raw if str(x).strip()]
invalid = [v for v in values if not v.isdigit()]
if invalid:
return _fail(f"黑名单只能是 QQ 号:{'、'.join(invalid)}")
stored = await STORE.set_blacklist(values)
return _ok({"blacklist": stored}, "黑名单已保存")
# ── 链接解析 + 预览(services/web_jobs.py) ──────────────
# 与群策略无关:解析结果按默认策略(default 节)的 plan / upload_public 存储。
@app.post("/parse", response_class=JSONResponse, dependencies=[auth])
async def parse(data: dict):
"""整段文本里挑链接 → 逐个起任务;同 URL 已有在跑的任务时直接复用。"""
urls = extract_urls(str(data.get("text") or ""))
if not urls:
return _fail("没找到链接")
jobs = await JOBS.submit(urls, force=bool(data.get("force")))
return _ok(
{"jobs": [j.to_dict() for j in jobs], "urls": urls},
f"已提交 {len(jobs)} 个任务",
)
@app.get("/jobs", response_class=JSONResponse, dependencies=[auth])
async def list_jobs():
"""全部任务,新的在前(前端按 1.5s 轮询这一个接口)。"""
return _ok({"jobs": [j.to_dict() for j in JOBS.list_jobs()]})
@app.post(
"/jobs/{job_id}/refresh", response_class=JSONResponse, dependencies=[auth]
)
async def refresh_job(job_id: str):
"""重传已落盘文件换一批新链接(预签名链接 1 小时过期)。"""
job = await JOBS.refresh(job_id)
if job is None:
return _fail("任务不存在,或还没有可刷新的文件")
return _ok({"job": job.to_dict()}, "链接已刷新")
@app.delete("/jobs/{job_id}", response_class=JSONResponse, dependencies=[auth])
async def remove_job(job_id: str):
"""删掉一条任务(还在跑的一并取消);只清任务表,temp 里的文件交给清理任务。"""
if not JOBS.remove(job_id):
return _fail("任务不存在")
return _ok({"job_id": job_id}, "任务已清理")
@app.post("/jobs/clear", response_class=JSONResponse, dependencies=[auth])
async def clear_jobs():
"""清掉所有已完成/失败的任务(排队、运行中的不动)。"""
removed = JOBS.clear_finished()
return _ok({"removed": removed}, f"已清理 {removed} 条任务")
# ── temp 缓存:占用统计 + 手动清理 ──────────────────────
# 与「清理temp / temp统计」命令、每日定时任务同一套函数(cleanup.py),
# 不重写清理逻辑;文件系统操作丢线程池,别卡住事件循环。
@app.get("/temp/stats", response_class=JSONResponse, dependencies=[auth])
async def temp_stats_api():
"""temp 目录占用(文件数/总大小/目录数)+ 当前自动清理配置。"""
st = await run_stats()
st["mode"] = cleanup_config.video_analysis_temp_cleanup_mode
st["max_age_days"] = cleanup_config.video_analysis_temp_max_age_days
st["hour"] = cleanup_config.video_analysis_temp_cleanup_hour
return _ok(st)
@app.post("/temp/cleanup", response_class=JSONResponse, dependencies=[auth])
async def temp_cleanup_api(data: dict, user: dict = Depends(get_current_user)):
"""手动清理 temp:**提交到任务槽后立即返回**,进度走 /hub/api/tasks/stream。
`days=0` 表示全部清空,不传则用配置的保留天数。清理本身是几十秒级的阻塞
任务,放请求里会把页面交互一起卡住;同槽已有任务时返回它并带 `reused=true`
(前端据此提示"已在运行",服务端这层才是防连点的真正保证)。
"""
raw = data.get("days")
if raw is None or str(raw).strip() == "":
days: int | None = None # 用配置的保留天数
shown = cleanup_config.video_analysis_temp_max_age_days
else:
try:
days = max(0, int(raw))
except (TypeError, ValueError):
return _fail("天数参数不合法")
shown = days
title = "清理 temp(全部)" if shown == 0 else f"清理 temp(保留 {shown} 天)"
task, reused = submit_clean(
days,
title=title,
trigger="web",
actor=str((user or {}).get("username") or ""),
)
return _ok(
{"task": task.to_dict(), "reused": reused},
"已有清理任务在跑" if reused else "清理已开始",
)
return app
def _sort_key(item: tuple[str, Policy]) -> tuple[int, int, str]:
"""群号按数值排序(非数字的排最后,不参与数值比较)。"""
gid = item[0]
return (1, 0, gid) if not gid.isdigit() else (0, int(gid), "")