- 抖音(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>
222 lines
9.6 KiB
Python
222 lines
9.6 KiB
Python
"""视频解析 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), "")
|