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>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:17 +08:00
1 parent a31aab00e8
commit a789a44f5d
25 files changed
+3536 -895

No files matched your search

@@ -17,6 +17,7 @@ hexi/data/temp 是下载媒体中转区(见 utils.get_temp_root),发送成功后
import asyncio
import time
from collections.abc import Callable
from pathlib import Path
from typing import Literal
@@ -25,9 +26,11 @@ from nonebot.adapters.onebot.v11 import Bot, MessageEvent
from pydantic import BaseModel
from hexi.core.custom_utils import check_manage
from hexi.core.task_slots import TASKS, Task
require("nonebot_plugin_apscheduler")
from nonebot_plugin_apscheduler import scheduler
from hexi.core.scheduler import add_job
from nonebot_plugin_alconna import UniMessage
@@ -101,33 +104,62 @@ def _prune_empty_dirs(root: Path, max_age: float) -> int:
return removed
def clean_temp_files(sub: str = "", days: int | None = None) -> tuple[int, int]:
def clean_temp_files(
sub: str = "",
days: int | None = None,
on_progress: Callable[..., None] | None = None,
) -> tuple[int, int]:
"""清理 temp[/sub] 下超过期限的文件与随之空掉的目录。
Args:
on_progress: 进度回调 `(done, total, phase, message, warning)`(关键字可省)——
扫描阶段 total 未知,之后按"处理过的文件数"上报。跑在哪个线程调用都行
(任务槽那边会切回事件循环)。**返回 False 表示调用方请求取消**,
本函数会在处理完当前文件后尽早收尾(清理天然可中断,不硬杀线程)。
Returns:
(removed, total) — 删除的文件数、统计到的文件总数
(removed, total) — 删除的文件数、统计到的文件数(被取消时是"取消前"的进度)
"""
def report(**kwargs) -> bool:
if on_progress is None:
return True # 没有槽(纯函数调用)时不存在取消
return bool(on_progress(**kwargs))
root = get_temp_root(sub)
if not root.is_dir():
return 0, 0
max_age = _max_age_seconds(days)
report(phase="扫描中", unit="个文件", message="正在统计文件…")
files = _walk_files(root)
removed = 0
for path in files:
if not _file_is_stale(path, max_age):
continue
try:
path.unlink(missing_ok=True)
removed += 1
logger.info(f"temp 清理: 删除 {path}")
except OSError as e:
# 文件被占用(如发送中)等场景,留待下轮
logger.warning(f"temp 清理: 跳过 {path} ({e})")
report(
done=0, total=len(files), phase="清理中",
unit="个文件", message=f"共 {len(files)} 个文件",
)
dirs = _prune_empty_dirs(root, max_age)
if dirs:
logger.info(f"temp 清理: 同时收掉 {dirs} 个空目录")
removed = 0
canceled = False
for index, path in enumerate(files, 1):
if _file_is_stale(path, max_age):
try:
path.unlink(missing_ok=True)
removed += 1
logger.info(f"temp 清理: 删除 {path}")
except OSError as e:
# 文件被占用(如发送中)等场景,留待下轮 —— 也算"部分失败",报给任务槽
logger.warning(f"temp 清理: 跳过 {path} ({e})")
report(warning=f"跳过 {path.name}(被占用?):{e}")
if not report(done=index, total=len(files), message=f"已删除 {removed} 个"):
logger.info("temp 清理: 收到取消请求,提前结束")
canceled = True
break
if not canceled:
report(phase="回收空目录", done=len(files), total=len(files))
dirs = _prune_empty_dirs(root, max_age)
if dirs:
logger.info(f"temp 清理: 同时收掉 {dirs} 个空目录")
return removed, len(files)
@@ -165,7 +197,27 @@ async def _handle_clean(bot: Bot, event: MessageEvent):
except ValueError:
await UniMessage.text("天数参数不合法,示例:/清理temp 3").send()
return
removed, total = await _run_clean(days)
task, reused = submit_clean(
days,
title="清理 temp(命令)",
trigger="command",
actor=event.get_user_id(),
group_id=getattr(event, "group_id", "") or "",
)
if reused:
await UniMessage.text(
"已经有一个清理任务在跑了,等它跑完再来吧~(进度可在网页端看)"
).send()
return
await TASKS.wait(task) # 命令要"跑完再回复",与网页端的即提交即返回不同
if task.status == "canceled":
await UniMessage.text("清理被取消了(网页端点过取消)。").send()
return
if task.status != "done":
await UniMessage.text(f"清理失败了:{task.error}").send()
return
removed, total = task.result or (0, 0)
if total == 0:
await UniMessage.text("temp 目录目前是空的,没什么可清理的~").send()
else:
@@ -174,7 +226,7 @@ async def _handle_clean(bot: Bot, event: MessageEvent):
@stats_cmd.handle()
async def _handle_stats(event: MessageEvent):
st = await _run_stats()
st = await run_stats()
if st["files"] == 0:
if st.get("dirs"):
await UniMessage.text(
@@ -191,13 +243,37 @@ async def _handle_stats(event: MessageEvent):
).send()
async def _run_clean(days: int | None = None) -> tuple[int, int]:
"""执行清理并记录日志(供命令与定时任务共用)"""
removed, total = await asyncio.to_thread(clean_temp_files, "", days)
return removed, total
#: 任务槽名:命令 / Web / 每日定时三条路共用同一个槽,同槽只跑一个(防重复触发)
CLEANUP_SLOT = "video_analysis:temp_cleanup"
async def _run_stats() -> dict:
def submit_clean(
days: int | None,
*,
title: str,
trigger: str = "",
actor: str = "",
group_id: str = "",
) -> tuple[Task, bool]:
"""把清理提交到任务槽,立即返回 (任务, 是否复用了正在跑的任务)。
任务体跑在线程池里(见 hexi.core.task_slots),进度实时推给网页端;
同槽已有任务时不会重复清理 —— 服务端这一层才是"防连点"的真正保证。
trigger / actor / group_id 是审计信息(谁点的:web 用户名 / command 的 QQ 号)。
"""
return TASKS.submit(
slot=CLEANUP_SLOT,
title=title,
plugin_id="video_analysis",
trigger=trigger,
actor=actor,
group_id=group_id,
func=lambda report: clean_temp_files(days=days, on_progress=report),
)
async def run_stats() -> dict:
"""异步统计(命令 / Web 缓存面板共用)"""
return await asyncio.to_thread(temp_stats)
@@ -213,7 +289,7 @@ def _register_auto_job() -> None:
from apscheduler.jobstores.base import JobLookupError # noqa: PLC0415
if cleanup_config.video_analysis_temp_cleanup_mode == "auto":
scheduler.add_job(
add_job(
_scheduled_cleanup,
"cron",
hour=cleanup_config.video_analysis_temp_cleanup_hour,
@@ -239,13 +315,18 @@ def reload_cleanup_config() -> None:
async def _scheduled_cleanup() -> None:
"""每日定时清理 temp 目录(auto 模式)"""
"""每日定时清理 temp 目录(auto 模式);同样走任务槽(与手动清理互斥)"""
logger.info("temp 自动清理: 开始")
try:
removed, total = await asyncio.to_thread(clean_temp_files)
logger.info(f"temp 自动清理完成:删除 {removed} 个,当前共 {total} 个文件")
except Exception as e:
logger.exception(f"temp 自动清理失败: {e}")
task, reused = submit_clean(None, title="清理 temp(每日定时)", trigger="schedule")
if reused:
logger.info("temp 自动清理: 已有清理任务在跑,跳过本次")
return
await TASKS.wait(task)
if task.status != "done":
logger.error(f"temp 自动清理未完成({task.status}): {task.error}")
return
removed, total = task.result or (0, 0)
logger.info(f"temp 自动清理完成:删除 {removed} 个,当前共 {total} 个文件")
# 插件加载时按模式注册(auto)或提示(manual)