Compare commits

...
3 Commits
Author SHA1 Message Date
sansenhoshiandClaude Code 4b5e8b8b2e feat(web): 顶栏账号菜单接入 bot 启停 / 插件页分区 / 首页协议端配对信息
- 重启/关闭 bot 从设置页提到顶栏账号下拉(破坏性操作弹确认框),等待回连逻辑抽成
  lib/botAction.ts 供两处共用:先等旧进程让出端口,再以 dashboard SSE 真收到一帧为准,
  只看 HTTP 200 会在"端口起来但插件未挂接"时误判就绪
- 插件管理页按 带 Web 页面 / 带配置项 / 都没有 分区,卡片加类型与 Web/配置 徽章
- 首页「BOT 协议端信息」分两半:左半登录账号,右半 NoneBot 接入信息
  (IP / 运行端口 / WS 路径 / 访问令牌,均可一键复制,另给整条反向 WS 地址),
  监听地址非 0.0.0.0 时提示仅本机可连;「BOT 后端信息」移到自己一行
- dashboard.py 新增 endpoint 段:读 driver.config 的 host/port、驱动实际注册的
  反向 WS 路由(自动跳过根路径与尾斜杠别名)、适配器令牌与本机网卡 IP

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:52 +08:00
sansenhoshiandClaude Code 814c1994b6 feat(mc-server-status): 服务器列表 Web 管理页 / 数据层抽离 / mcstatus 13 适配
- services/servers.py:校验 + 读写 + 增删改收敛成一份数据层(顶层不 import nonebot,
  可脱离运行时单测),群命令与 Web API 都只经它改数据,校验不再各写一套
- web_hub.py + /hub/mc_server_status 页面:按群列出服务器,抽屉新增/编辑(改名)/
  测试连接,行内删除(删空一台连带删群);每条路由自带 require_admin
- handlers/server.py 改为薄封装,只在需要命令特有提示时自己判断
- services/mc.py 的图标属性名兼容 mcstatus ≥13 的 icon(旧版叫 favicon,
  读错会把整条查询打成"查询失败")
- tests/test_mc_servers.py:数据层归一/改名/删群/坏文件兜底 + 图标解码两版属性名

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:46 +08:00
sansenhoshiandClaude Code 4badcfcf32 feat(video-analysis): 群策略 v3 / 群文件投递通道 / Web 管理页
- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
  公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
  写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
  本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
  (只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组

顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:32 +08:00
44 changed files with 6534 additions and 910 deletions
@@ -38,4 +38,17 @@ __plugin_meta__ = PluginMetadata(
""",
)
from . import web_hub as _web_hub # noqa: E402
from .handlers import add_server, del_server, list_all, test_server, xinxi # noqa: E402,F401
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/mc_server_status,
# 前端页面 hexi/web/src/plugins/mc_server_status/(服务器数据的增删改)
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
register_web_plugin(
"mc_server_status",
"MC服务器",
"server",
lambda: _web_hub.build_admin_app(),
module_name=__name__,
)
@@ -1,4 +1,3 @@
from json import dump, load
from os import makedirs
from pathlib import Path
from typing import List, Optional
@@ -7,6 +6,8 @@ from nonebot import get_bot, get_bots, get_driver, get_plugin_config
from nonebot.adapters import Bot
from pydantic import BaseModel
from .services import servers as store
# 数据目录:项目 hexi/data/ 下(与其它插件数据统一)
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
@@ -40,18 +41,16 @@ async def on_startup():
save_file()
else:
load_file()
# 群命令与 Web 管理台都改 var.group_list,保存时整表落盘(见 services/servers.py)
def load_file():
with open(DATA_DIR / pc.mc_status_data_filename, "r", encoding="utf-8") as r:
tmp_data = load(r)
for i in tmp_data:
var.group_list[int(i)] = tmp_data[i]
"""数据读写都在 services/servers.py(群命令与 Web 管理台共用同一份校验)。"""
var.group_list.update(store.load_servers(pc.mc_status_data_filename))
def save_file():
with open(DATA_DIR / pc.mc_status_data_filename, "w", encoding="utf-8") as w:
dump(var.group_list, w, indent=4, ensure_ascii=False)
store.save_servers(pc.mc_status_data_filename, var.group_list)
# qq机器人连接时执行
@@ -14,7 +14,8 @@ from nonebot.log import logger
from nonebot.params import RegexGroup
from nonebot.plugin import PluginMetadata
from ..config import pc, save_file, var
from ..config import pc, var
from ..services import servers as store
from ..services.mc import check_mc_status
__plugin_meta__ = PluginMetadata(
@@ -76,18 +77,13 @@ async def _(mp=RegexGroup()):
return
group = int(mp[1])
new_server_name = mp[2]
server_host = mp[3]
server_type = mp[4].lower()
if server_type not in ["js", "bds"]:
await add_server.finish("类型请填js或bds")
if group not in var.group_list:
var.group_list[group] = {new_server_name: [server_host, server_type]}
else:
for server_name in var.group_list[group]:
if new_server_name == server_name:
await add_server.finish("有同名服务器啦!")
var.group_list[group][new_server_name] = [server_host, server_type]
save_file()
if new_server_name in var.group_list.get(group, {}):
await add_server.finish("有同名服务器啦!")
# 校验 + 落盘都在 services/servers.py(与 Web 管理台同一套)
try:
store.set_server(group, new_server_name, mp[3], mp[4])
except store.ServerError as e:
await add_server.finish("类型请填js或bds" if "类型请填" in str(e) else str(e))
await add_server.finish("添加成功")
@@ -96,18 +92,11 @@ async def _(mp=RegexGroup()):
if not mp[0]:
await del_server.finish(f"删除服务器 [群号] [名称]")
return
group = int(mp[1])
name = mp[2]
if group not in var.group_list:
await del_server.finish("这个群没有添加服务器")
if name in var.group_list[group]:
var.group_list[group].pop(name)
if not var.group_list[group]:
var.group_list.pop(group)
save_file()
await del_server.finish("删除成功")
else:
await del_server.finish("没找到该名称的服务器")
try:
store.delete_server(int(mp[1]), mp[2])
except store.ServerError as e:
await del_server.finish(str(e))
await del_server.finish("删除成功")
@list_all.handle()
@@ -129,9 +118,9 @@ async def _(mp=RegexGroup()):
if not mp[0]:
await test_server.finish("测试服务器 [服务器地址] [类型]\n类型写js或bds,js是Java服务器,bds是基岩服务器")
return
server_host = mp[1]
server_type = mp[2].lower()
if server_type not in ["js", "bds"]:
await add_server.finish("类型请填js或bds")
msg = await check_mc_status("测试", server_host, server_type)
await list_all.finish(msg)
try:
server_type = store.normalize_type(mp[2])
except store.ServerError as e:
await test_server.finish(str(e))
msg = await check_mc_status("测试", mp[1], server_type)
await test_server.finish(msg)
@@ -5,15 +5,31 @@ from __future__ import annotations
from base64 import b64decode
from io import BytesIO
from re import findall
from typing import Union
from mcstatus import BedrockServer, JavaServer
from nonebot.adapters.onebot.v11 import Message, MessageSegment as MS
def _server_icon(status) -> bytes | None:
"""取服务器图标字节;没有图标或格式不认识就返回 None。
属性名跨版本换过:mcstatus ≥13 叫 `icon`,11.x/12.x 叫 `favicon`
(13.0.0 的 changelog:`JavaStatusResponse.favicon` → `icon`,值没变)。
值可能是完整的 data URI,也可能是不带前缀的裸 base64,两种都认。
"""
raw = getattr(status, "icon", None) or getattr(status, "favicon", None)
if not raw:
return None
data = raw.split("base64,", 1)[-1] if "base64," in raw else raw
try:
return b64decode(data)
except Exception: # noqa: BLE001 — 图标坏了不影响其它字段
return None
async def check_mc_status(
name: str, host: str, server_type: str
) -> Union[str, Message]:
) -> str | Message:
try:
if server_type == "js":
js = await JavaServer.async_lookup(host, timeout=2)
@@ -38,11 +54,8 @@ async def check_mc_status(
else:
player_list = "没人在线"
latency = round(status.latency)
if status.favicon:
aa, bb = status.favicon.split("base64,")
icon = MS.image(BytesIO(b64decode(bb))) + "\n"
else:
icon = ""
icon_data = _server_icon(status)
icon = MS.image(BytesIO(icon_data)) + "\n" if icon_data else ""
msg = (
icon
+ f"名称:{name}\n版本:{version}\n在线:{online}\n延迟:{latency}ms\n在线列表:\n{player_list}"
@@ -0,0 +1,252 @@
"""MC 服务器状态 —— 服务器列表数据层(校验 + 读写 + 增删改)。
内存态就是 `config.var.group_list`,形态是 `{int 群号: {服务器名: [地址, 类型]}}`
(类型 `js`=Java / `bds`=基岩)。群命令(handlers/server.py)与 Web 管理台
(web_hub.py)都只经这里改数据,校验只有这一份,两边不会各自跑偏。
本模块顶层**不 import nonebot**,运行态 config 到函数里再延迟 import
(见 `_runtime()`),这样数据层能脱离 NoneBot 运行时被单测加载。
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
try: # 单测里可能拿不到 nonebot.logger,退化成标准库
from nonebot.log import logger
except Exception: # noqa: BLE001 — 拿不到日志不影响数据层可用
logger = logging.getLogger(__name__)
#: 服务器类型 → 中文说明(排序即前端下拉顺序)
SERVER_TYPES: dict[str, str] = {"js": "Java 版", "bds": "基岩版"}
#: 地址里写了端口才连得快,否则 Java 版要多探几轮(上游加的注释,保留)
ADDRESS_HINT = "地址带端口查得更快,如 mc.example.com:25565、mc.example.com:19132"
MAX_NAME_LEN = 24
MAX_GROUP_LEN = 12
MAX_ADDRESS_LEN = 64
class ServerError(ValueError):
"""校验失败 / 目标不存在,`str(e)` 就是给用户看的话术。"""
def _runtime() -> Any:
"""取运行态 `config` 模块(数据目录、配置、内存态都在那边)。
到调用时才 import:本模块要能在"插件包还没初始化"的场景下被加载,
只有真正读写数据时才需要 config(单测按包结构加载本模块,见 tests/test_mc_servers.py)。
"""
from .. import config
return config
# ───────────────────────── 校验 / 归一化 ─────────────────────────
def normalize_type(value: Any, default: str = "js") -> str:
"""类型归一:`js`/`java` → js,`bds`/`be`/`bedrock` → bds;空值取 default。"""
if value is None or str(value).strip() == "":
return default
text = str(value).strip().lower()
if text in ("js", "java"):
return "js"
if text in ("bds", "be", "bedrock"):
return "bds"
raise ServerError("类型请填 js 或 bds(js = Java 版,bds = 基岩版)")
def normalize_address(value: Any) -> str:
"""地址归一:去空白、去协议前缀与尾部斜杠;`bds` 默认端口 19132 不写进数据里。"""
text = str(value or "").strip()
if not text:
raise ServerError("服务器地址不能为空")
for prefix in ("minecraft://", "mc://", "http://", "https://"):
if text.lower().startswith(prefix):
text = text[len(prefix) :]
break
text = text.strip().strip("/").strip()
if not text:
raise ServerError("服务器地址不能为空")
if any(ch.isspace() for ch in text) or "/" in text:
raise ServerError("服务器地址里不能有空格或斜杠")
if len(text) > MAX_ADDRESS_LEN:
raise ServerError(f"服务器地址太长了(最多 {MAX_ADDRESS_LEN} 个字符)")
if text.startswith(":") or text.endswith(":"):
raise ServerError("服务器地址的端口不能为空")
return text
def normalize_name(value: Any) -> str:
"""服务器名归一:去首尾空白、单双引号;名字里有空格是允许的。"""
text = str(value or "").strip()
if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'“”":
text = text[1:-1].strip()
if not text:
raise ServerError("服务器名称不能为空")
if len(text) > MAX_NAME_LEN:
raise ServerError(f"服务器名称太长了(最多 {MAX_NAME_LEN} 个字符)")
return text
def parse_group_id(value: Any) -> int:
"""群号:必须是纯数字(DataStore/命令层都按 int 存)。"""
text = str(value if value is not None else "").strip()
if not text:
raise ServerError("群号不能为空")
if len(text) > MAX_GROUP_LEN:
raise ServerError("群号看上去不对(最多 12 位数字)")
if not text.isdigit():
raise ServerError("群号必须是纯数字")
if not int(text):
raise ServerError("群号看上去不对")
return int(text)
# ───────────────────────── 数据读写 ─────────────────────────
def data_path(filename: str) -> Path:
return _runtime().DATA_DIR / filename
def load_servers(filename: str) -> dict[int, dict[str, list[str]]]:
"""读数据文件;文件不存在/内容坏了按"空数据 + 警告"处理,绝不让插件起不来。"""
path = data_path(filename)
if not path.exists():
return {}
try:
with open(path, encoding="utf-8") as f:
raw = json.load(f)
except Exception as e: # noqa: BLE001
logger.warning(f"MC 服务器数据读取失败(按空数据处理):{path} {e!r}")
return {}
data: dict[int, dict[str, list[str]]] = {}
if not isinstance(raw, dict):
logger.warning(f"MC 服务器数据格式不是对象,已忽略:{path}")
return data
for group_key, servers in raw.items():
try:
group_id = int(group_key)
except (TypeError, ValueError):
logger.warning(f"MC 服务器数据里有个非数字群号,已忽略:{group_key!r}")
continue
if not isinstance(servers, dict):
continue
clean: dict[str, list[str]] = {}
for name, value in servers.items():
if isinstance(value, (list, tuple)) and len(value) >= 2:
clean[str(name)] = [str(value[0]), str(value[1])]
data[group_id] = clean
return data
def save_servers(filename: str, data: dict[int, dict[str, list[str]]]) -> None:
"""整表落盘(先写 .tmp 再替换,避免写一半断电留下半截 JSON)。"""
path = data_path(filename)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
tmp.replace(path)
def _state() -> tuple[Any, dict[int, dict[str, list[str]]]]:
config = _runtime()
return config, config.var.group_list
def snapshot() -> dict[int, dict[str, list[str]]]:
"""整表深拷贝(给 Web 只读接口用,避免调用方误改内存态)。"""
_, groups = _state()
return {gid: {name: list(value) for name, value in servers.items()} for gid, servers in groups.items()}
def mutate(func, *args, **kwargs):
"""校验通过后才落盘;冲突类错误不要走到这里(改内存前就该抛)。"""
config, groups = _state()
result = func(groups, *args, **kwargs)
save_servers(config.pc.mc_status_data_filename, groups)
return result
# ───────────────────────── 增删改 ─────────────────────────
def _upsert(
groups: dict[int, dict[str, list[str]]],
group_id: int,
old_name: str | None,
name: str,
address: str,
server_type: str,
) -> bool:
servers = groups.setdefault(group_id, {})
if old_name and old_name != name:
# 改名:先查旧名在不在、新名会不会撞,确认没问题再动数据
if old_name not in servers:
raise ServerError(f"找不到要改名的服务器「{old_name}」")
if name in servers:
raise ServerError(f"群里已经有一个叫「{name}」的服务器了")
servers.pop(old_name)
servers[name] = [address, server_type]
return True
created = name not in servers
servers[name] = [address, server_type]
return created
def set_server(
group_id: int,
name: str,
address: str,
server_type: str,
old_name: str | None = None,
) -> bool:
"""新增/更新一台服务器,返回是否新建。`old_name` 传了且与 name 不同即为改名。"""
group_id = parse_group_id(group_id)
name = normalize_name(name)
address = normalize_address(address)
server_type = normalize_type(server_type)
old = normalize_name(old_name) if old_name else None
return mutate(_upsert, group_id, old, name, address, server_type)
def _delete(
groups: dict[int, dict[str, list[str]]], group_id: int, name: str | None
) -> None:
if group_id not in groups:
raise ServerError(f"群 {group_id} 还没有配置服务器")
if name is None: # 整个群一起删
groups.pop(group_id)
return
if name not in groups[group_id]:
raise ServerError(f"群里没有叫「{name}」的服务器")
groups[group_id].pop(name)
if not groups[group_id]: # 删空了就把群一起收掉,别留空壳
groups.pop(group_id)
def delete_server(group_id: int, name: str) -> None:
"""删掉某群里的一台服务器(删空了连带删群)。"""
group_id = parse_group_id(group_id)
mutate(_delete, group_id, normalize_name(name))
def delete_group(group_id: int) -> None:
"""删掉整个群的服务器配置。"""
group_id = parse_group_id(group_id)
mutate(_delete, group_id, None)
def lookup(group_id: int, name: str) -> list[str] | None:
"""按群号 + 服务器名取 `[地址, 类型]`(给 Web 的「测试连接」用)。"""
_, groups = _state()
return groups.get(int(group_id), {}).get(name)
@@ -0,0 +1,159 @@
"""MC 服务器信息查询 —— Web API 子应用(挂载到 /api/mc_server_status)。
服务器数据(`hexi/data/mc_status_data.json`:`{群号: {名字: [地址, 类型]}}`)
的唯一 Web 读写入口:列出 / 新增 / 编辑(含改名)/ 删除 / 测试连接。
写操作全部落在 services/servers.py(校验只有那一份),和群里的
`添加服务器`/`删除服务器` 命令改的是同一份内存态 + 同一个文件。
鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
**每条路由都要自带 `dependencies=[auth]`** —— mount 层没有兜底,漏一条就是匿名可访问。
前端页面:hexi/web/src/plugins/mc_server_status/index.tsx。
"""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from hexi.web_hub.web_auth import require_admin
from .services import mc as mc_service
from .services import servers as store
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:
from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import Adapter
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() -> dict:
"""整表视图:群列表(含群名与在线标记)+ 类型选项。"""
groups = store.snapshot()
return {
"groups": [
{
"group_id": str(gid),
"servers": [
{"name": name, "address": value[0], "type": value[1]}
for name, value in sorted(servers.items())
],
}
for gid, servers in sorted(groups.items())
],
"types": store.SERVER_TYPES,
"address_hint": store.ADDRESS_HINT,
}
async def _payload_with_names() -> dict:
"""跑一次 payload 再把群名补上(两条路由共用的收尾)。"""
data = _payload()
names = await _group_names()
for row in data["groups"]:
row["group_name"] = names.get(row["group_id"], "")
row["online"] = row["group_id"] in names
data["bot_online"] = bool(names)
return data
def build_admin_app() -> FastAPI | None:
"""构建 MC 服务器数据管理 API 子应用(挂载到 /api/mc_server_status)。"""
app = FastAPI(title="MC Server Status API")
auth = require_admin
@app.get("/overview", response_class=JSONResponse, dependencies=[auth])
async def overview():
"""一次拿全:所有群 + 服务器列表 + 类型选项。"""
return _ok(await _payload_with_names())
@app.post("/server", response_class=JSONResponse, dependencies=[auth])
async def save_server(data: dict):
"""新增/更新一台服务器;带 old_name 且与 name 不同即为改名。"""
try:
group_id = store.parse_group_id(data.get("group_id"))
name = store.normalize_name(data.get("name"))
created = store.set_server(
group_id,
name,
str(data.get("address") or ""),
str(data.get("type") or ""),
old_name=str(data.get("old_name") or "").strip() or None,
)
except store.ServerError as e:
return _fail(str(e))
return _ok(
await _payload_with_names(),
f"服务器「{name}」已{'添加' if created else '保存'}",
)
@app.delete("/server/{group_id}/{name}", response_class=JSONResponse, dependencies=[auth])
async def remove_server(group_id: str, name: str):
"""删掉某群里的一台服务器(删空了连带删群)。"""
try:
store.delete_server(group_id, name)
except store.ServerError as e:
return _fail(str(e))
return _ok(await _payload_with_names(), f"服务器「{name}」已删除")
@app.delete("/group/{group_id}", response_class=JSONResponse, dependencies=[auth])
async def remove_group(group_id: str):
"""删掉整个群的服务器配置。"""
try:
store.delete_group(group_id)
except store.ServerError as e:
return _fail(str(e))
return _ok(await _payload_with_names(), f"群 {group_id} 的服务器配置已删除")
@app.post("/test", response_class=JSONResponse, dependencies=[auth])
async def test_server(data: dict):
"""测试连接:给了群号+名字就测存量那台,否则测临时填的地址(不落库)。
对不上的地址走 mcstatus 自己的 2 秒超时,返回的是查询失败文案而不是报错。
"""
group_id = str(data.get("group_id") or "").strip()
name = str(data.get("name") or "").strip()
address = str(data.get("address") or "").strip()
server_type = str(data.get("type") or "").strip()
if group_id.isdigit() and name:
found = store.lookup(int(group_id), name)
if found:
address, server_type = found[0], found[1]
if not address:
return _fail("先填服务器地址")
try:
address = store.normalize_address(address)
server_type = store.normalize_type(server_type)
except store.ServerError as e:
return _fail(str(e))
message = await mc_service.check_mc_status(name or "测试", address, server_type)
# check_mc_status 正常返回 str;带图标的 Java 服返回 Message(Web 侧只要纯文本)
text = str(message) if not isinstance(message, str) else message
return _ok({"ok": "查询失败" not in text, "message": text}, "测试完成")
return app
@@ -18,5 +18,22 @@ __plugin_meta__ = PluginMetadata(
# 显式导入子模块:注册配置 schema + 消息 matcher(配合 load_plugins 只加载到包层)
from . import config as _config # noqa: E402
from . import handlers as _handlers # noqa: E402
from . import list_proc as _list_proc # noqa: E402
from . import web_hub as _web_hub # noqa: E402
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/video_analysis,
# 前端页面 hexi/web/src/plugins/video_analysis/
register_web_plugin(
"video_analysis",
"视频解析",
"video",
lambda: _web_hub.build_admin_app(),
module_name=__name__,
)
_config.register_config()
# 导入期把群策略读进内存(含 list.json v1/v2 → v3 迁移),
# 之后 verify_user 走内存,不再碰磁盘。
_list_proc.warmup()
@@ -10,7 +10,9 @@ hexi/data/temp 是下载媒体中转区(见 utils.get_temp_root),发送成功后
- /清理temp [天数] 清理 temp 下超过 N 天(默认取配置)未修改的文件
- /temp统计 查看 temp 目录占用情况
目录结构始终保留,占用中的文件自动跳过。
媒体按「作者目录/作品目录」分层落盘,文件删完后这些空目录会一并收掉
(只删同为过期、且确实为空的目录 —— 正在落盘的目录 mtime 很新,不会误删)。
占用中的文件自动跳过。
"""
import asyncio
@@ -71,11 +73,39 @@ def _walk_files(root: Path) -> list[Path]:
return files
def _prune_empty_dirs(root: Path, max_age: float) -> int:
"""自底向上收掉空目录(作者的层与作品的层都算),返回删除的目录数。
只删「本身就是空」且 mtime 已过期的目录:正在落盘的目录刚建出来、
mtime 很新,不会被误删;刚删完文件的目录 mtime 会被刷新,留到下一轮。
root 自身不在 rglob 结果里,不会被删。
"""
removed = 0
# 目录另有 60s 下限:清理temp 0 时不能把"刚建出来、还没写第一个文件"的
# 目录(下载落盘点先 mkdir 再 open)删掉
dir_age = max(max_age, 60)
dirs = [p for p in root.rglob("*") if p.is_dir()]
# 深的先处理:子目录删掉后父目录才可能变空,同一轮里能被顺带收掉
for path in sorted(dirs, key=lambda p: len(p.parts), reverse=True):
try:
if any(path.iterdir()):
continue
if not _file_is_stale(path, dir_age):
continue
path.rmdir()
removed += 1
logger.info(f"temp 清理: 删除空目录 {path}")
except OSError:
# 被占用 / 刚被别的进程删掉 → 留待下轮
continue
return removed
def clean_temp_files(sub: str = "", days: int | None = None) -> tuple[int, int]:
"""清理 temp[/sub] 下超过期限的文件。
"""清理 temp[/sub] 下超过期限的文件与随之空掉的目录。
Returns:
(removed, total) — 删除数、统计到的文件总数
(removed, total) — 删除的文件数、统计到的文件总数
"""
root = get_temp_root(sub)
if not root.is_dir():
@@ -94,17 +124,22 @@ def clean_temp_files(sub: str = "", days: int | None = None) -> tuple[int, int]:
except OSError as e:
# 文件被占用(如发送中)等场景,留待下轮
logger.warning(f"temp 清理: 跳过 {path} ({e})")
dirs = _prune_empty_dirs(root, max_age)
if dirs:
logger.info(f"temp 清理: 同时收掉 {dirs} 个空目录")
return removed, len(files)
def temp_stats(sub: str = "") -> dict:
"""统计 temp[/sub] 目录:文件数、总大小(字节)"""
"""统计 temp[/sub] 目录:文件数、总大小(字节)、目录数(含作者/作品层)"""
root = get_temp_root(sub)
if not root.is_dir():
return {"files": 0, "bytes": 0}
return {"files": 0, "bytes": 0, "dirs": 0}
files = _walk_files(root)
total_bytes = sum(p.stat().st_size for p in files if p.exists())
return {"files": len(files), "bytes": total_bytes}
dirs = sum(1 for p in root.rglob("*") if p.is_dir())
return {"files": len(files), "bytes": total_bytes, "dirs": dirs}
# ── 手动清理命令(manual 模式,auto 模式下也可用) ──────────────
@@ -141,11 +176,18 @@ async def _handle_clean(bot: Bot, event: MessageEvent):
async def _handle_stats(event: MessageEvent):
st = await _run_stats()
if st["files"] == 0:
await UniMessage.text("temp 目录目前是空的。").send()
if st.get("dirs"):
await UniMessage.text(
f"temp 目录下没有文件了,还剩 {st['dirs']} 个空目录"
f"(下次清理/清理temp 会一并收掉)。"
).send()
else:
await UniMessage.text("temp 目录目前是空的。").send()
else:
size_mb = st["bytes"] / 1024 / 1024
await UniMessage.text(
f"temp 目录:共 {st['files']} 个文件,占用 {size_mb:.1f} MB。"
f"temp 目录:共 {st['files']} 个文件,占用 {size_mb:.1f} MB,"
f"{st.get('dirs', 0)} 个目录。"
).send()
@@ -1,21 +1,48 @@
"""统一配置注册:把插件配置按文档标准接入 hexi.web_hub.web_config(Web 可读可改)。
- temp 清理配置(env pydantic) → register_model_config
- 群文件投递配置(打包/解压密码, env pydantic) → register_model_config
- S3 存储配置(原硬编码在 services/storage/s3.py) → register_config_items(store=s3 模块)
- 群分组配置(data/list.json) → register_config_items(type=json, nosave)
群策略(data/list.json v3)不在这里注册:它有独立的 Web 页面与 API
(见 web_hub.py),不再走配置抽屉里的裸 JSON 编辑。
"""
from __future__ import annotations
from nonebot import get_plugin_config
from pydantic import BaseModel
from hexi.web_hub.config_standard import register_config_items, register_model_config
from . import cleanup, list_proc # noqa: F401
from . import cleanup # noqa: F401
from .services.storage import s3 as _s3mod
# 插件模块名 = plugin_id(与 NoneBot 模块名一致)
_PLUGIN_ID = __package__
class GroupFileConfig(BaseModel):
# 上传群文件时打包成一个 zip(关掉则逐个上传原文件)
video_analysis_group_file_zip: bool = True
# 压缩包解压密码(留空 = 不加密; 设置后要求 pyzipper 可用, 否则放弃群文件上传)
video_analysis_group_file_password: str = ""
group_file_config = get_plugin_config(GroupFileConfig)
def group_file_settings() -> tuple[bool, str]:
"""群文件投递设置:(是否打包成 zip, 解压密码)。
运行期读取实例属性,所以 Web 保存后立即生效(apply 是 setattr)。
"""
return (
bool(group_file_config.video_analysis_group_file_zip),
str(group_file_config.video_analysis_group_file_password or ""),
)
def _reset_s3_caches(_values=None, store=None):
"""保存 S3 配置后清空懒加载客户端缓存,让下次上传用新配置重建。"""
if store is None:
@@ -59,7 +86,29 @@ def register_config() -> None:
apply_extra=lambda _values, _conf: cleanup.reload_cleanup_config(),
)
# 2) S3 存储配置(来源无关)
# 2) 群文件投递:打包 / 解压密码
register_model_config(
_PLUGIN_ID,
group_file_config,
fields=[
"video_analysis_group_file_zip",
"video_analysis_group_file_password",
],
labels={
"video_analysis_group_file_zip": "群文件打包成 zip",
"video_analysis_group_file_password": "压缩包解压密码",
},
descriptions={
"video_analysis_group_file_zip": "开启后群文件收到的是一个压缩包;关闭则逐个上传原文件",
"video_analysis_group_file_password": "留空 = 不加密;设置后用 AES-256 加密(需要 7-Zip/WinRAR 等工具解压),缺 pyzipper 时会放弃群文件上传而不是传明文",
},
types={
"video_analysis_group_file_zip": "bool",
"video_analysis_group_file_password": "password",
},
)
# 3) S3 存储配置(来源无关)
register_config_items(
_PLUGIN_ID,
[
@@ -86,19 +135,3 @@ def register_config() -> None:
store=_s3mod,
apply_extra=_reset_s3_caches,
)
# 3) 群分组配置(data/list.json, 权威源在插件自身)
register_config_items(
_PLUGIN_ID,
[
{
"key": "group_config",
"label": "群分组配置",
"type": "json",
"description": "data/list.json 内容。groups 为 {群号: {auto, plan(A/B), auto_link[]}},blacklist 为禁用用户 QQ 列表。白名单即 groups 的键。",
"getter": list_proc.get_group_config_sync,
"setter": list_proc.set_group_config_sync,
"nosave": True,
}
],
)
@@ -12,6 +12,7 @@ from ..services.fetchers.douyin_api import fetch_douyin_content
from ..services.fetchers.douyin_ssr import MOBILE_UA, fetch_douyin_note_ssr
from ..models import DouyinFetchError
from ..utils import get_data_dir, parse_netscape_cookies
from ..policy import Policy
from .sender import PendingMedia, _as_paths
SHORT_LINK_PATTERN = re.compile(r"(v\.douyin\.com/[A-Za-z0-9_\-]+)")
@@ -111,11 +112,13 @@ async def process_douyin_res(
file_paths: Union[Path, list[Path]],
is_private: bool,
image_post: bool,
plan: str | None = None,
policy: Policy | None = None,
platform: str | None = None,
) -> tuple[Optional[PendingMedia], Optional[str]]:
"""下载已完成 → 打包为待发送媒体(不上传、不发送、不清理)
多级发送(temp 本地 → S3 链接 → 回退本地)由 send_pending_media 统一处理。
多级发送(temp 本地 → S3 链接 → 回退本地)与群文件上传由
send_pending_media 统一按 policy 处理;platform 用于群文件限定平台。
"""
if not file_paths:
return None, None
@@ -124,7 +127,8 @@ async def process_douyin_res(
files=_as_paths(file_paths),
image_post=image_post,
is_private=is_private,
plan=plan,
policy=policy,
platform=platform,
title=title,
),
None,
@@ -16,7 +16,8 @@ from ..services.fetchers.rednote_content import fetch_rednote_content
from .douyin import parse_douyin, process_douyin_res
from .sender import PendingMedia, send_pending_media
from .universal import handle_universal
from ..list_proc import AUTO_LINK_KEYWORDS, get_group_auto_link, verify_user
from ..list_proc import get_policy, is_group_whitelisted, is_user_blacklisted
from ..policy import Policy, match_platform
URL_PATTERN = re.compile(r"(https?://\S+)")
XCX_PATTERN = r"QQ小程序(?:&amp;#93;|&#93;|\])"
@@ -44,106 +45,119 @@ active_video_handler = on_message(priority=10, block=False, rule=to_me())
async def _check_access(
event: Event, *, auto_only: bool = False, msg: str | None = None
) -> tuple[bool, str | None]:
) -> tuple[bool, Policy | None]:
"""统一权限检查。
auto_only=True → 自动解析:需白名单 + 开启自动解析,或 auto_link 关键词命中。
auto_only=False → 主动触发:需非黑名单,群聊还需白名单。
auto_only=True → 自动解析:需白名单群 + (开启自动解析或消息命中自动策略)。
auto_only=False → 主动触发:需白名单群 + 非黑名单用户。
私聊不做自动解析,读 default 节策略后直接解析。
禁用策略(ban_link)是消息级的(按链接判定),见 match_message。
Returns:
(allowed, plan) — plan 用于 S3 路由,不允许时为 None
(allowed, policy) — policy 决定存储/投递,不允许时为 None
"""
white, black, auto, plan = await verify_user(event)
target = get_target(event)
if target.private:
if auto_only:
logger.info("权限分析:自动解析不处理私聊")
return False, None
if black:
if is_user_blacklisted(event):
logger.info(f"权限分析:黑名单用户私聊,不回复: {event.get_user_id()}")
return False, None
logger.info("权限分析:私聊,直接解析")
return True, None
return True, get_policy(event)
group_id = str(event.group_id)
if not white:
if not is_group_whitelisted(event):
logger.info(f"权限分析:群 {group_id} 不在白名单,不做处理")
return False, None
if auto_only and not auto:
if msg is not None and await _match_auto_link(event, msg):
logger.info(f"权限分析:群 {group_id} 未开启自动解析,但自动链接关键词命中")
else:
logger.info(f"权限分析:群 {group_id} 未开启自动解析")
return False, None
if not auto_only and black:
if is_user_blacklisted(event):
logger.info(f"权限分析:黑名单用户,不回复: {event.get_user_id()}")
return False, None
policy = get_policy(event)
if auto_only and not policy.auto and not _match_auto_link(msg or "", policy):
logger.info(f"权限分析:群 {group_id} 未开启自动解析且未命中自动策略")
return False, None
logger.info(
f"权限分析:群 {group_id} 权限通过 — "
f"自动解析: {auto}, 方案: {plan or '默认(PLANC)'}"
f"权限分析:群 {group_id} 权限通过 — 自动解析: {policy.auto}, "
f"存储: {policy.plan}, 公网: {policy.upload_public}, "
f"群文件: {policy.upload_group_file}"
)
return True, plan
return True, policy
async def _match_auto_link(event: Event, msg: str) -> bool:
"""消息中的 URL 是否命中群配置的 auto_link 关键词。"""
keywords = await get_group_auto_link(event)
if not keywords:
def _match_auto_link(msg: str, policy: Policy) -> bool:
"""消息中的 URL 是否命中群策略的自动策略(auto_link)。"""
if not policy.auto_link:
return False
urls = URL_PATTERN.findall(msg)
if not urls:
return False
for kw in keywords:
domains = AUTO_LINK_KEYWORDS.get(kw, (kw,))
if any(any(domain in url for domain in domains) for url in urls):
logger.info(f"自动链接:关键词 {kw} 命中消息 {urls}")
for url in URL_PATTERN.findall(msg):
platform = policy.auto_matched(url)
if platform:
logger.info(f"自动策略:{platform} 命中消息 {url}")
return True
return False
def _skip_banned(url: str, policy: Policy) -> bool:
"""链接是否命中禁用策略(命中即静默丢弃,只记日志)。"""
platform = policy.banned(url)
if platform:
logger.info(f"禁用策略:{platform} 已禁用,忽略链接 {url}")
return True
return False
@auto_video_handler.handle()
async def handle_auto_video(event: Event):
msg = str(event.get_message()).strip()
allowed, plan = await _check_access(event, auto_only=True, msg=msg)
if allowed:
await match_message(event, plan=plan)
allowed, policy = await _check_access(event, auto_only=True, msg=msg)
if allowed and policy is not None:
await match_message(event, policy)
@active_video_handler.handle()
async def handle_active_video(event: Event):
allowed, plan = await _check_access(event, auto_only=False)
if allowed:
await match_message(event, plan=plan)
allowed, policy = await _check_access(event, auto_only=False)
if allowed and policy is not None:
await match_message(event, policy)
async def match_message(event: Event, plan: str | None = None):
"""消息匹配与分派:文本链接 / QQ小程序卡片统一走 dispatch_url。"""
async def match_message(event: Event, policy: Policy):
"""消息匹配与分派:文本链接 / QQ小程序卡片统一走 dispatch_url。
命中禁用策略的链接在这里丢弃;消息里还有其它可用链接则继续解析。
"""
msg = str(event.get_message()).strip()
logger.info(f"消息解析:获取到的消息:{msg}")
is_private = get_target(event).private
message = None
public_url = None
url = ""
if re.search(XCX_PATTERN, msg) or "CQ:json" in msg or "CQ:share" in msg:
logger.info("消息解析:检测到 CQ 卡片")
url = await _extract_xcx_url(msg)
url = await _extract_xcx_url(msg) or ""
logger.info(f"消息解析:卡片链接:{url}")
if not url or not any(domain in url for domain in VALID_HOSTS):
return
message, public_url = await dispatch_url(url, is_private, plan=plan)
if _skip_banned(url, policy):
return
message, public_url = await dispatch_url(url, is_private, policy)
if not message:
return
else:
urls = URL_PATTERN.findall(msg)
urls = [u for u in URL_PATTERN.findall(msg) if not _skip_banned(u, policy)]
for url in urls:
logger.info(f"消息解析:作品链接:{url}")
message, public_url = await dispatch_url(url, is_private, plan=plan)
message, public_url = await dispatch_url(url, is_private, policy)
if message:
break
if not message:
@@ -151,12 +165,12 @@ async def match_message(event: Event, plan: str | None = None):
if isinstance(message, PendingMedia):
ok, pub = await send_pending_media(message, event)
if pub:
if pub and policy.sends_link:
await UniMessage.text(f"{pub}").send()
if not ok:
await UniMessage.text(f"媒体发送失败:{url}").send()
else:
if public_url:
if public_url and policy.sends_link:
await UniMessage.text(f"{public_url}").send()
await message.send()
@@ -164,7 +178,7 @@ async def match_message(event: Event, plan: str | None = None):
async def dispatch_url(
url: str,
is_private: bool,
plan: str | None = None,
policy: Policy,
) -> tuple[UniMessage | None, str | None]:
"""按平台分派解析(文本链接与小程序卡片共用)。"""
url = url.rstrip(",。!?、;:)】》\"')")
@@ -175,6 +189,9 @@ async def dispatch_url(
logger.info(f"b23 短链重定向: {url} -> {resolved}")
url = resolved
# 平台标签:短链重定向之后再判定(群文件限定平台用)
platform = match_platform(url)
if "douyin.com" in url or "v.douyin.com" in url or "iesdouyin.com" in url:
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
try:
@@ -186,7 +203,8 @@ async def dispatch_url(
logger.warning(f"媒体解析:未能获取到作品:{url}")
return None, None
return await process_douyin_res(
title, parsed_path, is_private, image_post, plan=plan
title, parsed_path, is_private, image_post,
policy=policy, platform=platform,
)
except Exception:
await UniMessage.text(f"无法解析到媒体:{url}").send()
@@ -213,7 +231,7 @@ async def dispatch_url(
return None, None
return await process_douyin_res(
title, parsed_path, is_private,
isinstance(parsed_path, list), plan=plan,
isinstance(parsed_path, list), policy=policy, platform=platform,
)
except Exception:
await UniMessage.text(f"无法解析到内容:{url}").send()
@@ -232,7 +250,7 @@ async def dispatch_url(
return None, None
return await process_douyin_res(
title, parsed_path, is_private,
isinstance(parsed_path, list), plan=plan,
isinstance(parsed_path, list), policy=policy, platform=platform,
)
except Exception:
await UniMessage.text(f"无法解析到内容:{url}").send()
@@ -242,7 +260,7 @@ async def dispatch_url(
if any(domain in url for domain in VALID_HOSTS):
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
try:
return await handle_universal(url, is_private, plan=plan)
return await handle_universal(url, is_private, policy, platform)
except Exception as e:
logger.exception(e)
await UniMessage.text("下载过程中出现错误。").send()
@@ -10,6 +10,10 @@
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
→ 统一走合并转发,一个图/视频一个节点。
群文件(并行通道): `policy.upload_group_file` 开着时,消息链跑完后额外把文件
传到群文件(默认打包成一个 zip,可配解压密码,见 config.group_file_settings),
失败只记日志、不影响发送结果(打包/上传实现在 services/storage/group_file.py)。
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
"""
@@ -20,7 +24,11 @@ from nonebot import get_bot, get_driver, logger
from nonebot.adapters import Event
from nonebot_plugin_alconna import UniMessage
from ..config import group_file_settings
from ..policy import Policy
from ..services.storage.group_file import upload_group_files
from ..services.storage.s3 import upload_with_plan
from ..utils import media_rel_dir_of
@dataclass
@@ -30,7 +38,9 @@ class PendingMedia:
files: list[Path]
image_post: bool = False
is_private: bool = False
plan: str | None = None
policy: Policy | None = None
#: 平台规范标签(见 policy.match_platform),群文件限定平台用
platform: str | None = None
title: str = ""
@@ -43,8 +53,12 @@ def _as_paths(file_paths: Path | list[Path]) -> list[Path]:
# ─────────────────────── 合并转发(多媒体专用) ───────────────────────
#: 按视频段发送的扩展名(其余按图片发;直链下载可能落 webm/mov 等)
_VIDEO_SUFFIXES = {".mp4", ".webm", ".mov", ".flv", ".mkv", ".ts"}
def _is_video(fp: Path) -> bool:
return fp.suffix.lower() == ".mp4"
return fp.suffix.lower() in _VIDEO_SUFFIXES
def _needs_forward(files: list[Path]) -> bool:
@@ -117,13 +131,7 @@ async def _build_s3_forward_items(
items: list[tuple[Path, str | None]] = []
public_url = None
for fp in media.files:
local_url, pub = upload_with_plan(
fp,
plan=media.plan,
is_private=media.is_private,
title=media.title,
image_post=media.image_post,
)
local_url, pub = upload_with_plan(fp, policy=media.policy)
if not local_url:
raise RuntimeError(f"上传本地 S3 失败: {fp}")
if pub:
@@ -136,10 +144,10 @@ async def _build_s3_forward_items(
def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage:
"""本地文件版消息(mp4 → 视频,其余 → 图片)"""
"""本地文件版消息(视频扩展名 → 视频段,其余 → 图片段)"""
msg = UniMessage()
for fp in files:
if fp.suffix.lower() == ".mp4":
if _is_video(fp):
msg.video(path=fp)
else:
msg.image(path=fp)
@@ -153,28 +161,22 @@ def _build_s3_msg(
msg = UniMessage()
public_url = None
for fp in media.files:
local_url, pub = upload_with_plan(
fp,
plan=media.plan,
is_private=media.is_private,
title=media.title,
image_post=media.image_post,
)
local_url, pub = upload_with_plan(fp, policy=media.policy)
if not local_url:
raise RuntimeError(f"上传本地 S3 失败: {fp}")
if pub:
public_url = pub
if fp.suffix.lower() == ".mp4":
if _is_video(fp):
msg.video(url=local_url)
else:
msg.image(url=local_url)
return msg, public_url
async def send_pending_media(
async def _send_media_core(
media: PendingMedia, event: Event | None = None
) -> tuple[bool, str | None]:
"""多级发送,返回 (是否成功, public_url)
"""多级发送主体,返回 (是否成功, public_url)
public_url 仅在走 S3 链接发送成功时返回(调用方决定是否发文字)。
temp 文件发送成功后保留(用户手动清理 data/temp)。
@@ -236,3 +238,47 @@ async def send_pending_media(
except Exception as e:
logger.exception(f"回退发送失败: {e}")
return False, None
async def _upload_group_files(media: PendingMedia, event: Event | None) -> None:
"""群文件并行通道:消息链跑完后按策略额外传一份(失败只记日志)。
是否打包成 zip / 是否加密由全局配置决定(config.group_file_settings);
平台清单(policy.group_file_platforms)非空时只传清单里的平台,其余平台
照常走消息、不传群文件。
"""
policy = media.policy
if policy is None or event is None:
return
if not policy.allows_group_file(media.platform):
if policy.upload_group_file:
logger.info(
f"群文件限定平台 {policy.group_file_platforms},"
f"本次为 {media.platform or '未知平台'},跳过群文件上传"
)
return
group_id = getattr(event, "group_id", None)
if group_id is None:
return
zip_files, password = group_file_settings()
await upload_group_files(
media.files,
int(group_id),
title=media.title,
zip_files=zip_files,
password=password,
policy=policy,
rel_dir=media_rel_dir_of(media.files[0]),
)
async def send_pending_media(
media: PendingMedia, event: Event | None = None
) -> tuple[bool, str | None]:
"""多级发送 + 群文件并行通道,返回 (是否成功, public_url)。"""
if not media.files:
return False, None
ok, public_url = await _send_media_core(media, event)
await _upload_group_files(media, event)
return ok, public_url
@@ -1,11 +1,11 @@
"""通用平台视频解析编排层 — B站 / YouTube / Twitter 等"""
from pathlib import Path
from typing import Optional
from nonebot import logger
from nonebot_plugin_alconna import UniMessage
from ..policy import Policy
from ..services.fetchers.video_downloader import download_video
from .sender import PendingMedia, _as_paths
@@ -13,15 +13,16 @@ from .sender import PendingMedia, _as_paths
async def handle_universal(
url: str,
is_private: bool,
plan: str | None = None,
policy: Policy | None = None,
platform: str | None = None,
) -> tuple[Optional[PendingMedia], Optional[str]]:
"""
下载通用平台视频 → 打包待发送媒体(上传/发送由 sender 多级处理)
下载通用平台视频 → 打包待发送媒体(上传/发送由 sender 按 policy 多级处理)
Returns:
(PendingMedia, public_url) — None 表示下载失败
"""
video_file = await download_video(url)
video_file, title = await download_video(url)
if not video_file:
await UniMessage.text("视频下载失败。").send()
return None, None
@@ -33,8 +34,9 @@ async def handle_universal(
files=_as_paths(video_file),
image_post=False,
is_private=is_private,
plan=plan,
title="title",
policy=policy,
platform=platform,
title=title,
),
None,
)
@@ -1,359 +1,434 @@
import json
import asyncio
import os
"""群策略管理 —— 命令入口与访问层。
策略模型、平台定义与存储(list.json v3)见 `policy.py`。
本模块只做两件事:给 handlers 提供同步访问器(走内存),以及把命令解析成
`STORE.update_group(...)` 调用。
命令统一入口 `视频策略`:
视频策略 查看当前群策略面板
视频策略 <群号> ... 管理员操作指定群
视频策略 自动 on|off
视频策略 自动策略 +小红书 -抖音 命中即解析(即使关了自动解析)
视频策略 禁用策略 +X 命中即不解析(自动/手动都不解析)
视频策略 存储 A|B|C
视频策略 公网 on|off
视频策略 链接 on|off 发送下载链接(需先开公网)
视频策略 群文件 on|off
视频策略 黑名单 +QQ -QQ 全局用户黑名单
视频策略 白名单 +群号 -群号
所有设置项都要求超管(`check_admin`);设置时群会自动加入白名单。
"""
from __future__ import annotations
from typing import Any
from nonebot.plugin.on import on_command
from nonebot import logger
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import UniMessage, get_target
from nonebot.plugin.on import on_command
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.uniseg import get_target
from hexi.core.custom_utils import check_admin
# from hexi.core.message_utils import send_poke
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
FILE_PATH = os.path.join(BASE_DIR, "data", "list.json")
from .policy import (
PLANS,
PLATFORMS,
STORE,
Policy,
apply_platform_diff,
normalize_platforms,
)
USER_DATA: dict[str, str] = {}
_LOADED = False
LOCK = asyncio.Lock()
# ─────────────────────────── 访问层 ───────────────────────────
add_black_list = on_command("添加黑名单",rule=check_admin)
add_white_list = on_command("添加白名单",rule=check_admin)
add_auto_list = on_command("添加自动名单",rule=check_admin)
def warmup() -> None:
"""导入期调用一次:把 list.json 读进内存(含 v1/v2 → v3 迁移)。"""
STORE.load()
def get_policy(event: Event) -> Policy:
"""事件对应的策略:私聊读 default 节,群聊读该群策略。"""
if get_target(event).private:
return STORE.default_policy()
return STORE.get(str(event.group_id))
def is_group_whitelisted(event: Event) -> bool:
"""私聊不适用(返回 False,调用方应先判断私聊)。"""
return STORE.is_whitelisted(str(getattr(event, "group_id", "")))
def is_user_blacklisted(event: Event) -> bool:
return STORE.is_blacklisted(event.get_user_id())
# ─────────────────────────── 命令解析 ───────────────────────────
_TRUE_WORDS = {"on", "开", "true", "1", "yes", "启用"}
_FALSE_WORDS = {"off", "关", "false", "0", "no", "禁用"}
TOGGLE_USAGE = "用法:视频策略 自动|公网|链接|群文件 on|off"
def _args(event: Event, matcher: Matcher) -> list[str]:
"""去掉命令前缀后的参数列表。"""
text = event.get_message().extract_plain_text().strip()
cmd = matcher.state["_prefix"]["command"][0]
return text.replace(cmd, "", 1).strip().split()
def _parse_onoff(raw: str) -> bool | None:
word = raw.strip().lower()
if word in _TRUE_WORDS:
return True
if word in _FALSE_WORDS:
return False
return None
def _split_target(args: list[str], event: Event) -> tuple[str | None, list[str]]:
"""首个参数是纯数字群号时视为操作目标群,返回 (群号, 剩余参数)。"""
if args and args[0].isdigit() and len(args[0]) >= 5:
return args[0], args[1:]
return None, args
def _fmt_platforms(values: list[str]) -> str:
return "、".join(values) if values else "—"
def _panel(group_id: str | None, policy: Policy, whitelisted: bool) -> str:
head = f"群 {group_id} 策略" if group_id else "私聊/默认策略"
if group_id and not whitelisted:
head += "(未加入白名单,本群不会自动解析)"
link_state = "开" if policy.sends_link else "关"
if policy.send_link and not policy.upload_public:
link_state += "(未上传公网,实际不发)"
file_state = "开" if policy.upload_group_file else "关"
if policy.upload_group_file and policy.group_file_platforms:
file_state += f"(仅 {'、'.join(policy.group_file_platforms)})"
return "\n".join(
[
head,
f"自动解析:{'开' if policy.auto else '关'}",
f"自动策略:{_fmt_platforms(policy.auto_link)}",
f"禁用策略:{_fmt_platforms(policy.ban_link)}",
f"存储策略:{policy.plan}",
f"上传公网:{'开' if policy.upload_public else '关'}",
f"发送链接:{link_state}",
f"上传群文件:{file_state}",
f"平台标签:{'、'.join(PLATFORMS)}",
]
)
# ─────────────────────────── 命令注册 ───────────────────────────
policy_cmd = on_command("视频策略", rule=check_admin)
add_white_list = on_command("添加白名单", rule=check_admin)
remove_white_list = on_command("移除白名单", rule=check_admin)
add_black_list = on_command("添加黑名单", rule=check_admin)
remove_black_list = on_command("移除黑名单", rule=check_admin)
# 旧命令保留兼容
add_auto_list = on_command("添加自动名单", rule=check_admin)
set_plan_cmd = on_command("设置方案", rule=check_admin)
set_auto_link_cmd = on_command("设置自动链接", rule=check_admin)
# 自动链接关键词 → 域名匹配表(auto_link 配置项使用)
AUTO_LINK_KEYWORDS = {
"xhs": ("xiaohongshu.com", "xhslink.com", "xhslink.cn"),
"bilibili": ("bilibili.com", "b23.tv", "bili2233.cn"),
"b23": ("bilibili.com", "b23.tv", "bili2233.cn"),
"douyin": ("douyin.com", "v.douyin.com", "iesdouyin.com",
"m.douyin.com", "jingxuan.douyin.com"),
"yt": ("youtube.com", "youtu.be"),
"youtube": ("youtube.com", "youtu.be"),
"x": ("x.com", "twitter.com"),
"twitter": ("x.com", "twitter.com"),
}
async def _target_group(
args: list[str], event: Event, matcher: Matcher
) -> tuple[str | None, list[str]]:
"""解析目标群号与剩余参数;未指定时用当前群(私聊则报错返回 None)。"""
group_id, rest = _split_target(args, event)
if group_id is not None:
return group_id, rest
if get_target(event).private:
cmd = matcher.state["_prefix"]["command"][0]
await UniMessage.text(f"私聊下请带上群号,如:{cmd} 123456789 自动 on").send()
return None, rest
return str(event.group_id), rest
@add_black_list.handle()
async def handle_add_black(event: Event, matcher: Matcher):
input_id = event.get_message().extract_plain_text().strip()
cmd = matcher.state["_prefix"]["command"][0]
input_id = input_id.replace(cmd, "").strip()
ok = await _add_to_blacklist(input_id)
if ok:
await UniMessage.text(f"{input_id} 已加入黑名单").send()
@policy_cmd.handle()
async def handle_policy(event: Event, matcher: Matcher):
args = _args(event, matcher)
group_id, rest = await _target_group(args, event, matcher)
if group_id is None:
return
# 无子命令 → 面板
if not rest:
policy = STORE.get(group_id)
await UniMessage.text(
_panel(group_id, policy, STORE.is_whitelisted(group_id))
).send()
return
what, options = rest[0], rest[1:]
changes: dict[str, Any] = {}
note = ""
if what in ("自动", "自动解析"):
if len(options) != 1 or (value := _parse_onoff(options[0])) is None:
await UniMessage.text(TOGGLE_USAGE).send()
return
changes["auto"] = value
note = f"自动解析已{'开启' if value else '关闭'}"
elif what in ("自动策略", "自动链接"):
platforms = apply_platform_diff(STORE.get(group_id).auto_link, options)
if platforms is None:
await UniMessage.text(
f"用法:视频策略 自动策略 +平台/-平台(平台:{'、'.join(PLATFORMS)})"
).send()
return
changes["auto_link"] = platforms
note = f"自动策略已设为:{_fmt_platforms(platforms)}"
elif what in ("禁用策略", "禁用链接"):
platforms = apply_platform_diff(STORE.get(group_id).ban_link, options)
if platforms is None:
await UniMessage.text(
f"用法:视频策略 禁用策略 +平台/-平台(平台:{'、'.join(PLATFORMS)})"
).send()
return
changes["ban_link"] = platforms
note = f"禁用策略已设为:{_fmt_platforms(platforms)}"
elif what in ("存储", "方案", "存储策略"):
plan = options[0].strip().upper() if options else ""
if plan not in PLANS:
await UniMessage.text("用法:视频策略 存储 A|B|C").send()
return
changes["plan"] = plan
note = f"存储策略已设为 {plan}"
elif what in ("公网", "上传公网"):
if len(options) != 1 or (value := _parse_onoff(options[0])) is None:
await UniMessage.text(f"用法:视频策略 {what} on|off").send()
return
changes["upload_public"] = value
note = f"上传公网已{'开启' if value else '关闭'}"
if not value and STORE.get(group_id).send_link:
note += "(发送链接已开但无公网链接,实际不会发)"
elif what in ("链接", "发送链接", "下载链接"):
if len(options) != 1 or (value := _parse_onoff(options[0])) is None:
await UniMessage.text(f"用法:视频策略 {what} on|off").send()
return
changes["send_link"] = value
note = f"发送下载链接已{'开启' if value else '关闭'}"
if value and not STORE.get(group_id).upload_public:
note += ";当前未开启上传公网,需先:视频策略 公网 on"
elif what in ("群文件平台", "群文件限定平台"):
platforms = apply_platform_diff(
STORE.get(group_id).group_file_platforms, options
)
if platforms is None:
await UniMessage.text(
"用法:视频策略 群文件平台 +平台 -平台"
f"(平台:{'、'.join(PLATFORMS)};留空 = 全部平台)"
).send()
return
changes["group_file_platforms"] = platforms
note = (
f"群文件限定平台已设为:{_fmt_platforms(platforms)}"
if platforms
else "群文件限定平台已清空(所有平台都传群文件)"
)
elif what in ("群文件", "上传群文件"):
if len(options) != 1 or (value := _parse_onoff(options[0])) is None:
await UniMessage.text(f"用法:视频策略 {what} on|off").send()
return
changes["upload_group_file"] = value
note = f"上传群文件已{'开启' if value else '关闭'}"
elif what == "黑名单":
note = await _handle_blacklist(options)
if note is None:
return
elif what == "白名单":
note = await _handle_whitelist(options)
if note is None:
return
else:
await UniMessage.text(f"{input_id} 已在黑名单中").send()
await UniMessage.text(
"用法:视频策略 [群号] 自动|自动策略|禁用策略|存储|公网|链接|"
"群文件|群文件平台|黑名单|白名单 ..."
).send()
return
if changes:
policy = await STORE.update_group(group_id, create=True, **changes)
if policy is None:
await UniMessage.text(f"群 {group_id} 不在白名单中,请先添加白名单").send()
return
note += "\n\n" + _panel(group_id, policy, STORE.is_whitelisted(group_id))
await UniMessage.text(note).send()
async def _handle_blacklist(options: list[str]) -> str | None:
"""全局黑名单增删,返回提示语;参数非法返回 None(已回复用法)。"""
if not options:
current = "、".join(STORE.blacklist()) or "—"
return f"全局黑名单:{current}\n用法:视频策略 黑名单 +QQ -QQ"
for token in options:
qq = token[1:].strip()
if token[:1] not in "+-" or not qq.isdigit():
return "用法:视频策略 黑名单 +QQ -QQ"
added, removed = [], []
for token in options:
qq = token[1:].strip()
if token[0] == "+" and await STORE.add_blacklist(qq):
added.append(qq)
elif token[0] == "-" and await STORE.remove_blacklist(qq):
removed.append(qq)
parts = []
if added:
parts.append(f"已加入黑名单:{'、'.join(added)}")
if removed:
parts.append(f"已移出黑名单:{'、'.join(removed)}")
return "\n".join(parts) if parts else "黑名单无变化"
async def _handle_whitelist(options: list[str]) -> str | None:
"""白名单增删,返回提示语;参数非法返回 None(已回复用法)。"""
if not options:
current = "、".join(sorted(STORE.all_groups())) or "—"
return f"白名单:{current}\n用法:视频策略 白名单 +群号 -群号"
for token in options:
gid = token[1:].strip()
if token[:1] not in "+-" or not gid.isdigit():
return "用法:视频策略 白名单 +群号 -群号"
added, removed = [], []
for token in options:
gid = token[1:].strip()
if token[0] == "+" and await STORE.update_group(gid, create=True) is not None:
added.append(gid)
elif token[0] == "-" and await STORE.remove_group(gid):
removed.append(gid)
parts = []
if added:
parts.append(f"已加入白名单:{'、'.join(added)}")
if removed:
parts.append(f"已移除白名单:{'、'.join(removed)}")
return "\n".join(parts) if parts else "白名单无变化"
# ───────────────────── 旧命令(兼容保留) ─────────────────────
@add_white_list.handle()
async def handle_add_white(event: Event, matcher: Matcher):
input_id = event.get_message().extract_plain_text().strip()
cmd = matcher.state["_prefix"]["command"][0]
input_id = input_id.replace(cmd, "").strip()
ok = await add_white_user(input_id)
if ok:
await UniMessage.text(f"群 {input_id} 已加入白名单").send()
else:
await UniMessage.text(f"群 {input_id} 已在白名单中").send()
args = _args(event, matcher)
group_id = args[0].strip() if args else ""
if not group_id.isdigit():
await UniMessage.text("用法:添加白名单 <群号>").send()
return
if STORE.is_whitelisted(group_id):
await UniMessage.text(f"群 {group_id} 已在白名单中").send()
return
await STORE.update_group(group_id, create=True)
await UniMessage.text(f"群 {group_id} 已加入白名单").send()
@remove_white_list.handle()
async def handle_remove_white(event: Event, matcher: Matcher):
args = _args(event, matcher)
group_id = args[0].strip() if args else ""
if not group_id.isdigit():
await UniMessage.text("用法:移除白名单 <群号>").send()
return
ok = await STORE.remove_group(group_id)
await UniMessage.text(
f"群 {group_id} 已移出白名单" if ok else f"群 {group_id} 不在白名单中"
).send()
@add_black_list.handle()
async def handle_add_black(event: Event, matcher: Matcher):
args = _args(event, matcher)
user_id = args[0].strip() if args else ""
if not user_id.isdigit():
await UniMessage.text("用法:添加黑名单 <QQ>").send()
return
ok = await STORE.add_blacklist(user_id)
await UniMessage.text(
f"{user_id} 已加入黑名单" if ok else f"{user_id} 已在黑名单中"
).send()
@remove_black_list.handle()
async def handle_remove_black(event: Event, matcher: Matcher):
args = _args(event, matcher)
user_id = args[0].strip() if args else ""
if not user_id.isdigit():
await UniMessage.text("用法:移除黑名单 <QQ>").send()
return
ok = await STORE.remove_blacklist(user_id)
await UniMessage.text(
f"{user_id} 已移出黑名单" if ok else f"{user_id} 不在黑名单中"
).send()
@add_auto_list.handle()
async def handle_add_auto(event: Event, matcher: Matcher):
input_id = event.get_message().extract_plain_text().strip()
cmd = matcher.state["_prefix"]["command"][0]
input_id = input_id.replace(cmd, "").strip()
ok = await add_auto_user(input_id)
if ok:
await UniMessage.text(f"群 {input_id} 已开启自动解析").send()
else:
await UniMessage.text(f"群 {input_id} 已开启自动解析,无需重复设置").send()
args = _args(event, matcher)
group_id = args[0].strip() if args else ""
if not group_id.isdigit():
await UniMessage.text("用法:添加自动名单 <群号>").send()
return
policy = STORE.get(group_id)
if policy.auto and STORE.is_whitelisted(group_id):
await UniMessage.text(f"群 {group_id} 已开启自动解析,无需重复设置").send()
return
await STORE.update_group(group_id, create=True, auto=True)
await UniMessage.text(f"群 {group_id} 已开启自动解析").send()
@set_plan_cmd.handle()
async def handle_set_plan(event: Event, matcher: Matcher):
input_text = event.get_message().extract_plain_text().strip()
cmd = matcher.state["_prefix"]["command"][0]
args = input_text.replace(cmd, "").strip().split()
if len(args) != 2:
await UniMessage.text("用法:设置方案 <群号> A/B").send()
args = _args(event, matcher)
if len(args) != 2 or args[1].upper() not in PLANS:
await UniMessage.text("用法:设置方案 <群号> A/B/C").send()
return
group_id, plan = args[0], args[1].upper()
ok = await set_group_plan(group_id, plan)
if ok:
await UniMessage.text(f"群 {group_id} 存储方案已设为 {plan}").send()
elif plan not in ("A", "B"):
await UniMessage.text("方案必须是 A 或 B").send()
else:
policy = await STORE.update_group(group_id, plan=plan)
if policy is None:
await UniMessage.text(f"群 {group_id} 不在白名单中,请先添加白名单").send()
return
await UniMessage.text(f"群 {group_id} 存储方案已设为 {plan}").send()
@set_auto_link_cmd.handle()
async def handle_set_auto_link(event: Event, matcher: Matcher):
input_text = event.get_message().extract_plain_text().strip()
cmd = matcher.state["_prefix"]["command"][0]
args = input_text.replace(cmd, "").strip().split()
if len(args) < 2:
args = _args(event, matcher)
if not args:
await UniMessage.text(
f"用法:设置自动链接 <群号> <关键词...>(关键词:{'/'.join(AUTO_LINK_KEYWORDS)})"
f"用法:设置自动链接 <群号> <平台...>(平台:{'、'.join(PLATFORMS)})"
).send()
return
group_id = args[0]
keywords = args[1:]
ok = await set_group_auto_link(group_id, keywords)
if ok:
if keywords:
await UniMessage.text(
f"群 {group_id} 自动链接关键词已设为: {'、'.join(keywords)}\n"
"匹配到对应平台链接时,即使未开启自动解析也会自动下载"
).send()
else:
await UniMessage.text(f"群 {group_id} 的自动链接关键词已清空").send()
else:
await UniMessage.text(f"群 {group_id} 不在白名单中,请先添加白名单").send()
async def _safe_write_json(data, file_path: str):
tmp_path = file_path + ".tmp"
# 在线程池中执行耗时的文件写入
await asyncio.to_thread(_write_json_sync, data, tmp_path)
# os.replace 是轻量级系统调用,通常很快,可直接在主线程执行
# (也可放 to_thread,但一般没必要)
os.replace(tmp_path, file_path)
def _write_json_sync(data, tmp_path: str):
"""同步写入函数,供 to_thread 调用"""
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _migrate_to_v2(data: dict) -> tuple[dict, bool]:
"""将旧格式(平铺数组)转换为新格式(group-centric map)"""
if "groups" in data:
return data, False # 已是 v2
white = data.get("WHITE_LIST", [])
auto_list = data.get("AUTO_ANALYSIS", [])
pa = data.get("PLANA", [])
pb = data.get("PLANB", [])
groups: dict[str, dict] = {}
for gid in white:
entry: dict = {}
if gid in auto_list:
entry["auto"] = True
if gid in pa:
entry["plan"] = "A"
elif gid in pb:
entry["plan"] = "B"
groups[gid] = entry
return {
"groups": groups,
"blacklist": data.get("BLACK_LIST", []),
}, True
async def load_list(file_path: str = FILE_PATH):
global USER_DATA, _LOADED
if _LOADED:
platforms = normalize_platforms(args[1:]) if len(args) > 1 else []
if len(args) > 1 and not platforms:
await UniMessage.text(f"平台标签无效,可选:{'、'.join(PLATFORMS)}").send()
return
async with LOCK:
if _LOADED:
return
exists = await asyncio.to_thread(os.path.exists, file_path)
if not exists:
USER_DATA = {"groups": {}, "blacklist": []}
await _safe_write_json(USER_DATA, file_path)
else:
content = await asyncio.to_thread(_read_file_sync, file_path)
raw = json.loads(content)
USER_DATA, migrated = _migrate_to_v2(raw)
if migrated:
await _safe_write_json(USER_DATA, file_path)
logger.info("list.json 已从旧格式迁移为新 group-centric 格式")
_LOADED = True
def _read_file_sync(file_path: str) -> str:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
async def verify_user(event: Event) -> tuple[bool, bool, bool, str | None]:
"""
验证用户/群权限
Returns:
is_white — 群在白名单
is_black — 用户在黑名单
is_auto — 群开启自动解析
plan — 存储方案 "A" / "B" / None
"""
await load_list()
user_id = event.get_user_id()
groups = USER_DATA.get("groups", {})
blacklist = USER_DATA.get("blacklist", [])
is_black = user_id in blacklist
plan = None
is_auto = False
if get_target(event).private:
logger.debug(f"[DEBUG] private chat user_id: {repr(user_id)}")
return True, is_black, False, None
group_id = str(event.group_id)
group_config = groups.get(group_id, {})
is_white = group_id in groups
is_auto = group_config.get("auto", False)
plan = group_config.get("plan")
logger.debug(f"[DEBUG] user_id: {repr(user_id)} group_id: {group_id}")
logger.debug(f"[DEBUG] is_white: {is_white} is_black: {is_black} "
f"is_auto: {is_auto} plan: {plan}")
return is_white, is_black, is_auto, plan
async def _add_to_blacklist(new_id: str) -> bool:
await load_list()
async with LOCK:
lst: list = USER_DATA.get("blacklist", [])
if new_id in lst:
return False
lst.append(new_id)
USER_DATA["blacklist"] = lst
await _safe_write_json(USER_DATA, FILE_PATH)
return True
async def add_white_user(new_id: str) -> bool:
"""添加群到白名单(groups map)"""
await load_list()
async with LOCK:
groups: dict = USER_DATA.get("groups", {})
if new_id in groups:
return False
groups[new_id] = {}
USER_DATA["groups"] = groups
await _safe_write_json(USER_DATA, FILE_PATH)
return True
async def add_auto_user(new_id: str) -> bool:
"""设置群自动解析(群不在白名单则自动加入)"""
await load_list()
async with LOCK:
groups: dict = USER_DATA.get("groups", {})
if new_id not in groups:
groups[new_id] = {}
if groups[new_id].get("auto"):
return False
groups[new_id]["auto"] = True
USER_DATA["groups"] = groups
await _safe_write_json(USER_DATA, FILE_PATH)
return True
async def set_group_plan(group_id: str, plan: str) -> bool:
"""设置群的存储方案(A 或 B),群必须在白名单中"""
if plan not in ("A", "B"):
return False
await load_list()
async with LOCK:
groups: dict = USER_DATA.get("groups", {})
if group_id not in groups:
return False
groups[group_id]["plan"] = plan
USER_DATA["groups"] = groups
await _safe_write_json(USER_DATA, FILE_PATH)
return True
async def get_group_auto_link(event: Event) -> list[str]:
"""获取群配置的自动链接关键词(auto_link),私聊返回空列表"""
await load_list()
if get_target(event).private:
return []
group_id = str(event.group_id)
return USER_DATA.get("groups", {}).get(group_id, {}).get("auto_link", [])
async def set_group_auto_link(group_id: str, keywords: list[str]) -> bool:
"""设置群的自动链接关键词(auto_link),群必须在白名单中
keywords 为空列表时清空该配置。
"""
await load_list()
async with LOCK:
groups: dict = USER_DATA.get("groups", {})
if group_id not in groups:
return False
if keywords:
groups[group_id]["auto_link"] = keywords
else:
groups[group_id].pop("auto_link", None)
USER_DATA["groups"] = groups
await _safe_write_json(USER_DATA, FILE_PATH)
return True
# ── Web「分组配置」同步读写(list.json) ─────────────────────────────
def get_group_config_sync() -> dict:
"""Web 读取用:确保已加载并返回 list.json 的完整结构(dict)。"""
global USER_DATA, _LOADED
if not _LOADED:
if os.path.exists(FILE_PATH):
try:
raw = json.loads(_read_file_sync(FILE_PATH))
USER_DATA, _ = _migrate_to_v2(raw)
except Exception: # noqa: BLE001
USER_DATA = {"groups": {}, "blacklist": []}
else:
USER_DATA = {"groups": {}, "blacklist": []}
_LOADED = True
return USER_DATA
def set_group_config_sync(new_data: dict) -> bool:
"""Web 保存用:校验结构 → 更新内存 → 同步写回 list.json。
只保留合法字段(auto/plan/auto_link/blacklist),避免脏数据。
"""
global USER_DATA
if not isinstance(new_data, dict):
return False
groups = new_data.get("groups", {})
blacklist = new_data.get("blacklist", [])
if not isinstance(groups, dict) or not isinstance(blacklist, list):
return False
norm_groups: dict[str, dict] = {}
for gid, entry in groups.items():
if not isinstance(entry, dict):
continue
e: dict = {}
if entry.get("auto"):
e["auto"] = True
plan = str(entry.get("plan", "")).upper()
if plan in ("A", "B"):
e["plan"] = plan
if isinstance(entry.get("auto_link"), list):
e["auto_link"] = [str(x) for x in entry["auto_link"]]
norm_groups[str(gid)] = e
norm = {"groups": norm_groups, "blacklist": [str(x) for x in blacklist]}
USER_DATA = norm
_write_json_sync(norm, FILE_PATH + ".tmp")
os.replace(FILE_PATH + ".tmp", FILE_PATH)
return True
if await STORE.update_group(group_id, auto_link=platforms) is None:
await UniMessage.text(f"群 {group_id} 不在白名单中,请先添加白名单").send()
return
if platforms:
await UniMessage.text(
f"群 {group_id} 自动策略已设为:{_fmt_platforms(platforms)}\n"
"匹配到对应平台链接时,即使未开启自动解析也会自动下载"
).send()
else:
await UniMessage.text(f"群 {group_id} 的自动策略已清空").send()
@@ -0,0 +1,486 @@
"""群策略模型与存储(list.json v3)。
策略以「群」为单位,字段全部可选,缺省值见 `Policy`:
auto 自动解析 默认 False
auto_link 自动策略(命中平台即解析) 默认 []
ban_link 禁用策略(命中平台不解析) 默认 []
plan 存储策略 A/B/C 默认 C
upload_public 上传公网 默认 False
send_link 发送下载链接 默认 False(依赖 upload_public)
upload_group_file 上传群文件(并行通道) 默认 False
group_file_platforms 群文件限定平台 默认 [](空 = 全部平台)
文件结构(v3)::
{
"groups": {"<群号>": {<Policy 字段>}},
"default": {<Policy 字段>}, # 私聊 + 群条目缺字段时的兜底
"blacklist": ["<QQ>", ...] # 全局用户黑名单
}
读路径全内存:`verify_user` 在每条消息的热路径上,首次 `load()` 之后不再读盘。
写路径走 `asyncio.Lock` + `.tmp` 原子替换;Web 子应用与 bot 同进程同事件循环,
asyncio 锁即可覆盖两边并发写(Web 不在独立线程里跑写操作)。
换 ORM 时只需再实现一个同样接口的 Store,调用方零改动。
"""
from __future__ import annotations
import asyncio
import json
import os
import re
from collections.abc import Iterable
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
from nonebot import logger
# ─────────────────────────── 平台 ───────────────────────────
XHS = "小红书"
BILIBILI = "哔哩哔哩"
DOUYIN = "抖音"
YOUTUBE = "Youtube"
X = "X"
#: 规范标签(存储值 = 展示值,命令/Web 输入同一个词)
PLATFORMS: tuple[str, ...] = (XHS, BILIBILI, DOUYIN, YOUTUBE, X)
#: 平台 → 域名片段(判定 auto_link / ban_link 命中用)
PLATFORM_DOMAINS: dict[str, tuple[str, ...]] = {
XHS: ("xiaohongshu.com", "xhslink.com", "xhslink.cn"),
BILIBILI: ("bilibili.com", "b23.tv", "bili2233.cn"),
DOUYIN: (
"douyin.com",
"iesdouyin.com",
"m.douyin.com",
"jingxuan.douyin.com",
),
YOUTUBE: ("youtube.com", "youtu.be"),
X: ("x.com", "twitter.com"),
}
#: 输入别名 → 规范标签(统一小写比较)
_PLATFORM_ALIASES: dict[str, str] = {
"xhs": XHS,
"小红书": XHS,
"bilibili": BILIBILI,
"b23": BILIBILI,
"b站": BILIBILI,
"哔哩哔哩": BILIBILI,
"douyin": DOUYIN,
"抖音": DOUYIN,
"yt": YOUTUBE,
"youtube": YOUTUBE,
"油管": YOUTUBE,
"x": X,
"twitter": X,
"推特": X,
}
#: 分隔符:兼容手写 JSON 里的 "小红书,xhs 抖音" 这类写法
_SPLIT_RE = re.compile(r"[,,、/\s]+")
def normalize_platform(raw: Any) -> str | None:
"""用户输入 → 规范标签,未知返回 None。"""
if not isinstance(raw, str):
return None
return _PLATFORM_ALIASES.get(raw.strip().lower())
def normalize_platforms(values: Any) -> list[str]:
"""归一 + 去重 + 丢弃非法值,结果按 PLATFORMS 顺序排列。
接受字符串(按分隔符拆)或列表。
"""
if values is None:
items: Iterable[Any] = ()
elif isinstance(values, str):
items = _SPLIT_RE.split(values)
elif isinstance(values, (list, tuple, set)):
items = list(values)
else:
return []
picked = {p for p in (normalize_platform(v) for v in items) if p}
return [p for p in PLATFORMS if p in picked]
def apply_platform_diff(current: Iterable[str], tokens: Iterable[str]) -> list[str] | None:
"""按 `+平台` / `-平台` 增删;出现裸平台名时整体覆盖。
全部 token 都带 +/- 时做增量,否则视为覆盖(命令 `视频策略 自动策略 …` 用)。
含未知平台(或覆盖时没有合法平台)返回 None,由调用方提示用法。
"""
tokens = [t for t in tokens if str(t).strip()]
if not tokens:
return None
if all(str(t)[:1] in "+-" for t in tokens):
result = normalize_platforms(current)
for token in tokens:
picked = normalize_platforms([str(token)[1:]])
if not picked:
return None
if str(token)[0] == "+" and picked[0] not in result:
result.append(picked[0])
elif str(token)[0] == "-" and picked[0] in result:
result.remove(picked[0])
return normalize_platforms(result)
picked = normalize_platforms(tokens)
return picked or None
def match_platform(url: str) -> str | None:
"""URL → 平台规范标签;不属于任何已支持平台时返回 None。"""
for name, domains in PLATFORM_DOMAINS.items():
if any(domain in url for domain in domains):
return name
return None
# ─────────────────────────── 策略 ───────────────────────────
PLANS: tuple[str, ...] = ("A", "B", "C")
DEFAULT_PLAN = "C"
POLICY_FIELDS: tuple[str, ...] = (
"auto",
"auto_link",
"ban_link",
"plan",
"upload_public",
"send_link",
"upload_group_file",
"group_file_platforms",
)
@dataclass
class Policy:
"""单个群的策略(字段缺省即默认值,见类文档)。"""
auto: bool = False
auto_link: list[str] = field(default_factory=list)
ban_link: list[str] = field(default_factory=list)
plan: str = DEFAULT_PLAN
upload_public: bool = False
send_link: bool = False
upload_group_file: bool = False
#: 群文件限定平台;空列表 = 所有平台都传群文件
group_file_platforms: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
"""构造即归一:直接 Policy(...) 传入平台别名/非法 plan 也不至于静默失效。"""
self.auto_link = normalize_platforms(self.auto_link)
self.ban_link = normalize_platforms(self.ban_link)
self.group_file_platforms = normalize_platforms(self.group_file_platforms)
if self.plan not in PLANS:
self.plan = DEFAULT_PLAN
@property
def sends_link(self) -> bool:
"""是否真的发下载链接:没有公网链接可发时恒为 False。"""
return self.send_link and self.upload_public
def allows_group_file(self, platform: str | None) -> bool:
"""这个平台的作品要不要传群文件。
开关关着 → 不传;清单为空 → 全部平台都传;否则只认清单里的平台
(平台识别不出来时按"不在清单里"处理)。
"""
if not self.upload_group_file:
return False
if not self.group_file_platforms:
return True
return platform is not None and platform in self.group_file_platforms
def banned(self, url: str) -> str | None:
"""URL 命中的禁用平台(未命中返回 None)。"""
platform = match_platform(url)
return platform if platform and platform in self.ban_link else None
def auto_matched(self, url: str) -> str | None:
"""URL 命中的自动策略平台(未命中返回 None)。"""
platform = match_platform(url)
return platform if platform and platform in self.auto_link else None
def to_dict(self) -> dict[str, Any]:
return {
"auto": self.auto,
"auto_link": list(self.auto_link),
"ban_link": list(self.ban_link),
"plan": self.plan,
"upload_public": self.upload_public,
"send_link": self.send_link,
"upload_group_file": self.upload_group_file,
"group_file_platforms": list(self.group_file_platforms),
}
@classmethod
def from_dict(cls, raw: Any) -> Policy:
"""脏数据归一:非法 plan 回退 C,平台别名转规范标签,多余键丢弃。"""
if not isinstance(raw, dict):
return cls()
plan = str(raw.get("plan", DEFAULT_PLAN)).strip().upper()
return cls(
auto=bool(raw.get("auto", False)),
auto_link=normalize_platforms(raw.get("auto_link")),
ban_link=normalize_platforms(raw.get("ban_link")),
plan=plan if plan in PLANS else DEFAULT_PLAN,
upload_public=bool(raw.get("upload_public", False)),
send_link=bool(raw.get("send_link", False)),
upload_group_file=bool(raw.get("upload_group_file", False)),
group_file_platforms=normalize_platforms(raw.get("group_file_platforms")),
)
# ───────────────────────── 迁移 ─────────────────────────
def _v1_to_v2(raw: dict) -> dict:
"""旧格式(平铺数组)→ group-centric。"""
white = raw.get("WHITE_LIST", [])
auto_list = raw.get("AUTO_ANALYSIS", [])
pa = raw.get("PLANA", [])
pb = raw.get("PLANB", [])
groups: dict[str, dict] = {}
for gid in white:
entry: dict = {}
if gid in auto_list:
entry["auto"] = True
if gid in pa:
entry["plan"] = "A"
elif gid in pb:
entry["plan"] = "B"
groups[str(gid)] = entry
return {"groups": groups, "blacklist": raw.get("BLACK_LIST", [])}
def migrate(raw: Any) -> tuple[dict, bool]:
"""v1 / v2 → v3,返回 (数据, 是否发生迁移)。
v2 → v3 的关键一步:v2 的 `plan=B` 隐含"上传公网",解耦后给它显式补上
`upload_public`,保证已有群行为不变。
"""
if not isinstance(raw, dict):
return _empty_data(), False
changed = False
if "groups" not in raw:
raw = _v1_to_v2(raw)
changed = True
groups: dict[str, dict] = {}
for gid, entry in (raw.get("groups") or {}).items():
if not isinstance(entry, dict):
changed = True
continue
policy = Policy.from_dict(entry)
if policy.plan == "B" and "upload_public" not in entry:
policy.upload_public = True
if entry != policy.to_dict():
changed = True
groups[str(gid)] = policy.to_dict()
if "default" not in raw:
changed = True
data = {
"groups": groups,
"default": Policy.from_dict(raw.get("default")).to_dict(),
"blacklist": [str(x) for x in (raw.get("blacklist") or [])],
}
return data, changed
def _empty_data() -> dict:
return {
"groups": {},
"default": Policy().to_dict(),
"blacklist": [],
}
# ─────────────────────────── 存储 ───────────────────────────
class PolicyStore:
"""list.json 读写:读全内存,写加锁 + 原子替换。"""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._groups: dict[str, Policy] = {}
self._default = Policy()
self._blacklist: list[str] = []
self._loaded = False
self._lock = asyncio.Lock()
# ── 读 ──────────────────────────────────────────────
def load(self) -> None:
"""首次调用读盘 + 迁移(幂等,之后不再读盘)。"""
if self._loaded:
return
raw: Any = {}
if self.path.exists():
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except Exception:
logger.exception(f"读取 {self.path.name} 失败,改用空配置")
data, migrated = migrate(raw)
self._apply(data)
self._loaded = True
if migrated:
self._write_sync(self._snapshot(data))
logger.info(f"{self.path.name} 已迁移为 v3 格式(群 {len(self._groups)} 个)")
def _apply(self, data: dict) -> None:
self._groups = {
str(gid): Policy.from_dict(entry)
for gid, entry in (data.get("groups") or {}).items()
}
self._default = Policy.from_dict(data.get("default"))
self._blacklist = [str(x) for x in (data.get("blacklist") or [])]
def is_whitelisted(self, group_id: Any) -> bool:
"""群是否在白名单里(白名单即 groups 的键,仍是准入门槛)。"""
self.load()
return str(group_id) in self._groups
def get(self, group_id: Any) -> Policy:
"""群策略:未配置的群回落到 default 节。"""
self.load()
return self._groups.get(str(group_id), self._default)
def all_groups(self) -> dict[str, Policy]:
self.load()
return dict(self._groups)
def default_policy(self) -> Policy:
self.load()
return self._default
def blacklist(self) -> list[str]:
self.load()
return list(self._blacklist)
def is_blacklisted(self, user_id: Any) -> bool:
self.load()
return str(user_id) in self._blacklist
# ── 写 ──────────────────────────────────────────────
def _snapshot(self, data: dict | None = None) -> dict:
"""在事件循环线程内构造完整快照,写线程不再触碰共享状态。"""
if data is not None:
return data
return {
"groups": {gid: p.to_dict() for gid, p in self._groups.items()},
"default": self._default.to_dict(),
"blacklist": list(self._blacklist),
}
def _write_sync(self, data: dict) -> None:
tmp = self.path.with_name(self.path.name + ".tmp")
tmp.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
os.replace(tmp, self.path)
async def _save(self) -> None:
snapshot = self._snapshot()
async with self._lock:
await asyncio.to_thread(self._write_sync, snapshot)
async def update_group(
self, group_id: Any, *, create: bool = False, **changes: Any
) -> Policy | None:
"""更新群策略的部分字段;群不存在且 create=False 时返回 None。
changes 传最终值(列表类字段由调用方算好新列表)。
"""
self.load()
gid = str(group_id)
current = self._groups.get(gid)
if current is None:
if not create:
return None
current = Policy()
valid = {k: v for k, v in changes.items() if k in POLICY_FIELDS}
policy = replace(current, **valid) if valid else current
policy = Policy.from_dict(policy.to_dict()) # 归一化(平台别名/非法 plan)
self._groups[gid] = policy
await self._save()
return policy
async def set_group(self, group_id: Any, policy: Policy) -> Policy:
"""整体覆盖群策略(Web 编辑用)。"""
self.load()
gid = str(group_id)
policy = Policy.from_dict(policy.to_dict())
self._groups[gid] = policy
await self._save()
return policy
async def remove_group(self, group_id: Any) -> bool:
self.load()
gid = str(group_id)
if gid not in self._groups:
return False
del self._groups[gid]
await self._save()
return True
async def set_default(self, policy: Policy) -> Policy:
self.load()
self._default = Policy.from_dict(policy.to_dict())
await self._save()
return self._default
async def add_blacklist(self, user_id: Any) -> bool:
self.load()
uid = str(user_id)
if uid in self._blacklist:
return False
self._blacklist.append(uid)
await self._save()
return True
async def set_blacklist(self, values: Iterable[Any]) -> list[str]:
"""整体替换黑名单(去重保序,一次落盘)。"""
self.load()
cleaned: list[str] = []
for value in values:
uid = str(value).strip()
if uid and uid not in cleaned:
cleaned.append(uid)
self._blacklist = cleaned
await self._save()
return list(self._blacklist)
async def remove_blacklist(self, user_id: Any) -> bool:
self.load()
uid = str(user_id)
if uid not in self._blacklist:
return False
self._blacklist.remove(uid)
await self._save()
return True
#: 单例(插件自己的 data/ 目录,与历史 list.json 同路径,原地迁移)
STORE = PolicyStore(Path(__file__).resolve().parent / "data" / "list.json")
@@ -14,15 +14,19 @@
"""
import re
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from nonebot import logger
from ...models import ContentFetchError
from ...utils import get_data_dir, parse_netscape_cookies, slugify
from ...utils import (
build_author_dir,
build_work_stem,
get_data_dir,
get_temp_root,
parse_netscape_cookies,
)
DATA_DIR = get_data_dir()
@@ -81,10 +85,10 @@ async def _parse(url: str):
# 4. 文章动态 → 转 opus
if await dynamic.is_article():
return await _parse_opus(dynamic.turn_to_opus(), "文章")
return await _parse_opus(dynamic.turn_to_opus(), "文章", url)
info = await dynamic.get_info()
return await _parse_dynamic_info(info)
return await _parse_dynamic_info(info, url)
async def _parse_article(read_id: int) -> tuple[str, Union[Path, list[Path]]]:
@@ -94,10 +98,12 @@ async def _parse_article(read_id: int) -> tuple[str, Union[Path, list[Path]]]:
# 文章接口对匿名请求风控更严(-509),必须带凭证
article = Article(read_id, _build_credential())
opus = await article.turn_to_opus()
return await _parse_opus(opus, "文章")
return await _parse_opus(opus, "文章", f"cv{read_id}")
async def _parse_opus(opus, kind: str) -> tuple[str, Union[Path, list[Path]]]:
async def _parse_opus(
opus, kind: str, source: str = ""
) -> tuple[str, Union[Path, list[Path]]]:
"""图文动态/专栏解析(opus 接口返回 dict,直接访问)"""
info = await opus.get_info()
item = info.get("item") or {}
@@ -107,10 +113,12 @@ async def _parse_opus(opus, kind: str) -> tuple[str, Union[Path, list[Path]]]:
images: list[str] = []
texts: list[str] = []
author = ""
author_id = ""
for module in item.get("modules") or []:
if module.get("module_type") == "MODULE_TYPE_AUTHOR":
author_info = module.get("module_author") or {}
author = author_info.get("name", "")
author_id = str(author_info.get("mid") or "")
elif module.get("module_type") == "MODULE_TYPE_CONTENT":
content = module.get("module_content") or {}
for para in content.get("paragraphs") or []:
@@ -127,16 +135,20 @@ async def _parse_opus(opus, kind: str) -> tuple[str, Union[Path, list[Path]]]:
if not images:
return text or f"B站{kind}", []
file_name = _build_file_name(author, text or f"B站{kind}", kind)
file_paths = await _download_images(images, file_name)
rel_stem = _build_rel_stem(author, author_id, text or f"B站{kind}", source)
file_paths = await _download_images(images, rel_stem)
return text, file_paths
async def _parse_dynamic_info(info: dict) -> tuple[str, Union[Path, list[Path]]]:
async def _parse_dynamic_info(
info: dict, source: str = ""
) -> tuple[str, Union[Path, list[Path]]]:
"""动态解析(图文 / 视频 / 纯文字)"""
item = info.get("item") or {}
modules = item.get("modules") or {}
author = ((modules.get("module_author") or {}).get("name")) or "B站用户"
module_author = modules.get("module_author") or {}
author = module_author.get("name") or "B站用户"
author_id = str(module_author.get("mid") or "")
module_dynamic = modules.get("module_dynamic") or {}
major = module_dynamic.get("major") or {}
major_type = major.get("type", "")
@@ -151,7 +163,13 @@ async def _parse_dynamic_info(info: dict) -> tuple[str, Union[Path, list[Path]]]
title = archive.get("title") or desc or "B站视频动态"
logger.info(f"B站视频动态: bvid={bvid} 标题={title[:40]}")
video_path = await download_video(f"https://www.bilibili.com/video/{bvid}")
# 动态数据里已有 up 的昵称/mid,传下去才能和同一位 up 的图文
# 落在同一个作者目录(否则要赌 yt-dlp 返回的 id 对得上)
video_path, _ = await download_video(
f"https://www.bilibili.com/video/{bvid}",
author=author,
author_id=author_id,
)
if video_path:
return title, video_path
raise ContentFetchError(f"视频动态下载失败: {bvid}")
@@ -173,8 +191,8 @@ async def _parse_dynamic_info(info: dict) -> tuple[str, Union[Path, list[Path]]]
images = [u for u in images if u]
if images:
file_name = _build_file_name(author, title, "动态")
file_paths = await _download_images(images, file_name)
rel_stem = _build_rel_stem(author, author_id, title, source)
file_paths = await _download_images(images, rel_stem)
logger.info(f"B站图文动态: 作者={author}, 标题={title[:40]}, 图片={len(images)} 张")
return title, file_paths
@@ -197,14 +215,17 @@ def _extract_text(nodes: list) -> str:
return "".join(parts)
def _build_file_name(author: str, title: str, kind: str) -> str:
"""构建文件名 stem: {作者}_{标题}_{类型}_{时间}"""
slug_author = slugify(author)
slug_title = slugify(title or "", max_length=15)
if not slug_title:
slug_title = datetime.now().strftime("%H%M%S")
time_suffix = datetime.now().strftime("%H%M%S")
return f"{slug_author}_{slug_title}_{kind}_{time_suffix}"
def _build_rel_stem(
author: str, author_id: str, title: str, source: str = ""
) -> str:
"""相对平台根的路径词干:`{作者}_{mid}/{作品名}`
昵称/mid 都拿不到时用 source(动态链接)当来源码(见 utils.build_author_dir)。
"""
return (
f"{build_author_dir(author, author_id, source=source)}"
f"/{build_work_stem(title)}"
)
def _build_credential():
@@ -227,20 +248,21 @@ def _build_credential():
)
async def _download_images(image_urls: list[str], file_name: str) -> list[Path]:
"""并发下载图片(复用抖音图文的下载流程)"""
import httpx
async def _download_images(image_urls: list[str], rel_stem: str) -> list[Path]:
"""并发下载图片(复用抖音图文的下载流程)
落 hexi/data/temp/bilibili(原先落系统 temp,cleanup 扫不到、永不清理)。
"""
from .douyin_api import _process_note_with_parsed
tmp_root = Path(tempfile.gettempdir()) / "bilibili"
tmp_root.mkdir(parents=True, exist_ok=True)
tmp_root = get_temp_root("bilibili")
headers = {
"Referer": BILI_REFERER,
"User-Agent": BILI_UA,
}
return await _process_note_with_parsed(
[[u] for u in image_urls], None, tmp_root, file_name, headers
[[u] for u in image_urls], None, tmp_root, rel_stem, headers
)
@@ -10,11 +10,9 @@ from nonebot import logger
from playwright.async_api import async_playwright
from ...models import DouyinFetchError
from ...utils import ensure_unique_path, get_temp_root
from ...utils import get_temp_root, unique_media_path
from .douyin_parser import (
ParsedDouyinContent,
extract_trailing_digits,
is_animated_note,
parse_animated_note_videos,
parse_douyin_response,
parse_note_images,
@@ -194,9 +192,9 @@ async def fetch_douyin_content(
if parsed.media_type == "视频":
content = await _process_video(
api_response, tmp_root, parsed.file_name, aweme_id, headers
api_response, tmp_root, parsed.rel_stem, aweme_id, headers
)
return parsed.file_name, content
return parsed.raw_title, content
elif parsed.media_type == "图片":
# 先解析 images 列表,区分纯动图和图文/图+视频
@@ -209,15 +207,15 @@ async def fetch_douyin_content(
images_urls,
video_url,
tmp_root,
parsed.file_name,
parsed.rel_stem,
headers,
)
else:
# 纯动图(所有项都是视频)
content = await _process_animated_note(
api_response, tmp_root, parsed.file_name, headers
api_response, tmp_root, parsed.rel_stem, headers
)
return parsed.file_name, content
return parsed.raw_title, content
return None, None
@@ -228,11 +226,14 @@ async def fetch_douyin_content(
async def _process_video(
api_response: dict,
tmp_root: Path,
file_name: str,
rel_stem: str,
aweme_id: str,
headers: Dict[str, str],
) -> Path:
"""处理视频内容,返回本地文件路径"""
"""处理视频内容,返回本地文件路径
rel_stem 是相对平台根的路径词干 `{作者目录}/{作品名}`(见 ParsedDouyinContent.rel_stem)。
"""
groups = parse_video_urls(api_response)
best_group = None
@@ -251,7 +252,7 @@ async def _process_video(
best = max(full, key=lambda x: x["br"])
logger.info(f"选择码率: {best['br']} - {best['url'][:60]}...")
output_path = ensure_unique_path(tmp_root / f"{file_name}.mp4")
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
async with httpx.AsyncClient(headers=headers) as client:
async with client.stream("GET", best["url"]) as resp:
resp.raise_for_status()
@@ -271,9 +272,10 @@ async def _process_video(
logger.info(f"选择视频码率: {video['br']}")
logger.info(f"选择音频码率: {audio['br']}")
video_path = tmp_root / f"{file_name}_v.mp4"
audio_path = tmp_root / f"{file_name}_a.mp4"
output_path = ensure_unique_path(tmp_root / f"{file_name}.mp4")
# 先定下产物名(顺带建好作者目录),分轨中间文件与产物同目录
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
video_path = output_path.parent / f"{output_path.stem}_v.mp4"
audio_path = output_path.parent / f"{output_path.stem}_a.mp4"
async with httpx.AsyncClient(headers=headers) as client:
logger.info("开始下载视频...")
@@ -291,7 +293,9 @@ async def _process_video(
f.write(chunk)
logger.info("合并视频和音频...")
merge_video_audio(video_path, audio_path, output_path)
# ffmpeg 是同步子进程,直接 await 不了:不丢线程池会卡住整个事件循环
# (合并期间 Web 轮询、群消息全都停摆)
await asyncio.to_thread(merge_video_audio, video_path, audio_path, output_path)
video_path.unlink()
audio_path.unlink()
@@ -306,11 +310,14 @@ async def _process_note_with_parsed(
images_urls: List[List[str]],
video_url: Optional[str],
tmp_root: Path,
file_name: str,
rel_stem: str,
headers: Dict[str, str],
) -> List[Path]:
"""根据已解析的图片/视频 URL 列表,并行下载"""
note_dir = ensure_unique_path(tmp_root / file_name)
"""根据已解析的图片/视频 URL 列表,并行下载
一个作品一个目录:`{平台根}/{作者目录}/{作品名}[_{短码}]/001.jpg…`
"""
note_dir = unique_media_path(tmp_root / rel_stem)
note_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"图文保存目录: {note_dir}")
@@ -353,7 +360,7 @@ async def _process_note(
api_response: dict,
api_response_favorite: dict,
tmp_root: Path,
file_name: str,
rel_stem: str,
aweme_id: str,
headers: Dict[str, str],
) -> List[Path]:
@@ -367,7 +374,7 @@ async def _process_note(
raise DouyinFetchError("未找到图文链接")
return await _process_note_with_parsed(
images_urls, video_url, tmp_root, file_name, headers
images_urls, video_url, tmp_root, rel_stem, headers
)
@@ -377,14 +384,14 @@ async def _process_note(
async def _process_animated_note(
api_response: dict,
tmp_root: Path,
file_name: str,
rel_stem: str,
headers: Dict[str, str],
) -> List[Path]:
"""处理动图内容(media_type=42),并行下载所有无声 mp4 视频"""
video_urls = parse_animated_note_videos(api_response)
logger.info(f"解析到的动图视频链接: {video_urls}")
note_dir = ensure_unique_path(tmp_root / file_name)
note_dir = unique_media_path(tmp_root / rel_stem)
note_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"动图保存目录: {note_dir}")
@@ -4,13 +4,12 @@ import json
import re
import urllib.parse
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, List, Optional
from nonebot import logger
from ...models import DouyinFetchError
from ...utils import slugify
from ...utils import build_author_dir, build_work_stem
# ============================= 数据结构 =============================
@@ -22,7 +21,13 @@ class ParsedDouyinContent:
raw_title: str
raw_nickname: str
media_type: str # "视频" | "图片"
file_name: str # 构建好的文件名 stem
file_name: str # 作品名 stem(落盘文件名/多图子目录名)
author_dir: str = "" # 作者目录 `{昵称}_{uid}`(落盘与 S3 key 的首层)
@property
def rel_stem(self) -> str:
"""相对平台根目录的路径词干:`{作者目录}/{作品名}`"""
return f"{self.author_dir}/{self.file_name}" if self.author_dir else self.file_name
# ============================= URL 工具 =============================
@@ -109,6 +114,29 @@ def extract_author_nickname(api_response: dict) -> str:
return nickname
def extract_author_id(api_response: dict) -> str:
"""从 API 响应提取作者稳定 id,优先级: uid > unique_id(抖音号) > sec_uid
SSR 路径的 `aweme_list[0].author.uid` 与 API 路径的
`aweme_detail.author.uid` 走同一套取值;都拿不到返回空串,
作者目录退化成只用昵称(见 utils.build_author_dir)。
"""
aweme_detail = api_response.get("aweme_detail") or {}
author = aweme_detail.get("author") or {}
if not author:
aweme_list = api_response.get("aweme_list") or []
if aweme_list and isinstance(aweme_list, list):
author = (aweme_list[0] or {}).get("author") or {}
for key in ("uid", "unique_id", "sec_uid"):
value = str(author.get(key) or "").strip()
if value and value != "0":
logger.info(f"RAW作者id({key}):{value}")
return value
logger.info("未获取到作者 id,作者目录只用昵称")
return ""
def detect_media_type(referer_url: str | None) -> str | None:
"""根据页面 URL 检测媒体类型(视频/图片)"""
if referer_url is None:
@@ -326,36 +354,6 @@ def parse_ssr_page(html: str) -> Optional[dict]:
return {"aweme_list": [item]}
# ============================= 文件名构建 =============================
def build_file_name(
raw_title: str,
raw_nickname: str,
media_type: str,
) -> str:
"""
构建文件名 stem,格式: {作者}_{标题}_{类型}_{时间戳}
昵称不限长,标题最多 15 字符(slugify 后),末尾 HHMMSS 防覆盖。
"""
slug_nickname = slugify(raw_nickname)
if raw_title:
slug_title = slugify(raw_title, max_length=15)
else:
slug_title = ""
if not slug_title:
slug_title = datetime.now().strftime("%H%M%S")
logger.info(f"标题为空,使用短时间戳: {slug_title}")
slug_type = slugify(media_type)
time_suffix = datetime.now().strftime("%H%M%S")
return f"{slug_nickname}_{slug_title}_{slug_type}_{time_suffix}"
# ============================= 动图检测 =============================
@@ -410,11 +408,21 @@ def parse_douyin_response(
raw_title = extract_title_from_api(api_response)
raw_nickname = extract_author_nickname(api_response)
file_name = build_file_name(raw_title, raw_nickname, media_type)
raw_author_id = extract_author_id(api_response)
# 昵称/uid 都拿不到时的来源码:优先作品链接,其次响应里的作品 id
aweme_id = str(
(api_response.get("aweme_detail") or {}).get("aweme_id")
or ((api_response.get("aweme_list") or [{}])[0] or {}).get("aweme_id")
or ""
)
author_dir = build_author_dir(
raw_nickname, raw_author_id, source=referer_url or aweme_id
)
file_name = build_work_stem(raw_title)
logger.info(
f"内容标题: {raw_title}, 作者: {raw_nickname}, "
f"类型: {media_type}, 文件名: {file_name}"
f"内容标题: {raw_title}, 作者: {raw_nickname}({raw_author_id}), "
f"类型: {media_type}, 落盘路径: {author_dir}/{file_name}"
)
return ParsedDouyinContent(
@@ -422,4 +430,5 @@ def parse_douyin_response(
raw_nickname=raw_nickname,
media_type=media_type,
file_name=file_name,
author_dir=author_dir,
)
@@ -85,13 +85,13 @@ async def fetch_douyin_note_ssr(
images_urls, video_url = parse_note_images(api_response, None, vid)
if images_urls:
file_paths = await _process_note_with_parsed(
images_urls, video_url, tmp_root, parsed.file_name, headers
images_urls, video_url, tmp_root, parsed.rel_stem, headers
)
else:
file_paths = await _process_animated_note(
api_response, tmp_root, parsed.file_name, headers
api_response, tmp_root, parsed.rel_stem, headers
)
return parsed.file_name, file_paths
return parsed.raw_title, file_paths
async def _fetch_ssr_api_response(
@@ -19,16 +19,21 @@
import asyncio
import json
import re
import tempfile
import urllib.parse
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from nonebot import logger
from ...models import ContentFetchError
from ...utils import get_data_dir, get_temp_root, parse_netscape_cookies, slugify
from ...utils import (
build_author_dir,
build_work_stem,
get_data_dir,
get_temp_root,
parse_netscape_cookies,
unique_media_path,
)
DATA_DIR = get_data_dir()
@@ -214,6 +219,12 @@ async def _build_result(note: dict) -> tuple[str, Union[Path, list[Path]]]:
desc = note.get("desc") or ""
nickname = ((note.get("user") or {}).get("nickname")) or "小红书用户"
text = title or desc or "小红书笔记"
rel_stem = _build_rel_stem(
nickname,
_extract_author_id(note),
text,
source=str(note.get("noteId") or note.get("id") or ""),
)
# 1. 视频笔记 → 无水印原片优先
if note.get("type") == "video" and note.get("video"):
@@ -223,8 +234,7 @@ async def _build_result(note: dict) -> tuple[str, Union[Path, list[Path]]]:
if okey:
video_url = f"https://sns-video-bd.xhscdn.com/{okey}"
logger.info(f"小红书视频: 无水印原片 originVideoKey={okey[:30]}...")
file_name = _build_file_name(nickname, text, "视频")
video_path = await _download_video(video_url, file_name)
video_path = await _download_video(video_url, rel_stem)
return text, video_path
# 1b. 无 originVideoKey(国内站数据)→ 从 stream 分组选无水印原片
@@ -251,8 +261,7 @@ async def _build_result(note: dict) -> tuple[str, Union[Path, list[Path]]]:
f"{best.get('width')}x{best.get('height')} {best.get('fps')}fps "
f"size={best.get('size')} duration={duration}ms"
)
file_name = _build_file_name(nickname, text, "视频")
video_path = await _download_video(video_url, file_name)
video_path = await _download_video(video_url, rel_stem)
return text, video_path
raise ContentFetchError("小红书视频流解析失败")
@@ -266,25 +275,36 @@ async def _build_result(note: dict) -> tuple[str, Union[Path, list[Path]]]:
logger.info(f"小红书文字笔记: {text[:30]}")
return text, []
file_name = _build_file_name(nickname, text, "笔记")
file_paths = await _download_images(images, file_name)
file_paths = await _download_images(images, rel_stem)
logger.info(f"小红书图文笔记: 作者={nickname}, 图片={len(images)} 张")
return text, file_paths
def _build_file_name(nickname: str, title: str, kind: str) -> str:
"""构建文件名 stem: {作者}_{标题}_{类型}_{时间}"""
slug_nickname = slugify(nickname)
slug_title = slugify(title or "", max_length=15)
if not slug_title:
slug_title = datetime.now().strftime("%H%M%S")
time_suffix = datetime.now().strftime("%H%M%S")
return f"{slug_nickname}_{slug_title}_{kind}_{time_suffix}"
def _extract_author_id(note: dict) -> str:
"""小红书作者稳定 id(页面数据字段未实测,逐个兜底;取不到返回空串)"""
user = note.get("user") or {}
for key in ("userId", "user_id", "id"):
value = user.get(key)
if isinstance(value, (str, int)) and str(value).strip() not in ("", "0"):
return str(value).strip()
return ""
async def _download_images(image_urls: list[str], file_name: str) -> list[Path]:
def _build_rel_stem(
nickname: str, author_id: str, title: str, source: str = ""
) -> str:
"""相对平台根的路径词干:`{作者}_{userId}/{作品名}`
昵称/作者 id 都拿不到时用 source(笔记 id)当来源码(见 utils.build_author_dir)。
"""
return (
f"{build_author_dir(nickname, author_id, source=source)}"
f"/{build_work_stem(title)}"
)
async def _download_images(image_urls: list[str], rel_stem: str) -> list[Path]:
"""并发下载图片(复用抖音图文的下载流程)"""
import httpx
from .douyin_api import _process_note_with_parsed
@@ -294,11 +314,11 @@ async def _download_images(image_urls: list[str], file_name: str) -> list[Path]:
"User-Agent": REDNOTE_UA,
}
return await _process_note_with_parsed(
[[u] for u in image_urls], None, tmp_root, file_name, headers
[[u] for u in image_urls], None, tmp_root, rel_stem, headers
)
async def _download_video(video_url: str, file_name: str) -> Path:
async def _download_video(video_url: str, rel_stem: str) -> Path:
"""流式下载视频
注意:sns-video-bd(无水印原片)不带 Referer 或带 xiaohongshu.com
@@ -307,7 +327,7 @@ async def _download_video(video_url: str, file_name: str) -> Path:
import httpx
tmp_root = get_temp_root("xiaohongshu")
output_path = tmp_root / f"{file_name}.mp4"
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
headers = {"User-Agent": REDNOTE_UA}
async with httpx.AsyncClient(headers=headers, timeout=300) as client:
async with client.stream("GET", video_url) as resp:
@@ -3,9 +3,9 @@
import asyncio
import os
import re
import shutil
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional
@@ -14,7 +14,14 @@ from nonebot import logger
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
from ...utils import get_data_dir, get_temp_root, slugify, ensure_unique_path
from ...utils import (
build_author_dir,
build_work_stem,
get_data_dir,
get_temp_root,
slugify,
unique_media_path,
)
def detect_platform(url: str) -> str:
@@ -41,6 +48,16 @@ def extract_uploader(info: dict) -> Optional[str]:
)
def extract_uploader_id(info: dict) -> str:
"""作者稳定 id:channel_id > uploader_id(@handle / B站 mid),拿不到返回空串
不用 `id`(那是作品 id,会把同一作者的作品拆到不同目录)。
"""
if not info:
return ""
return str(info.get("channel_id") or info.get("uploader_id") or "").strip()
def get_ffmpeg_path() -> str:
scripts_dir = os.path.dirname(sys.executable)
ffmpeg_path = os.path.join(scripts_dir, "ffmpeg.exe")
@@ -78,19 +95,36 @@ async def _retry_download(
logger.error(
f"yt-dlp 重试 {max_retries} 次后仍失败: {str(e)[:120]}"
)
except Exception as e:
except Exception:
# 非 DownloadError(如 OSError)不重试,直接抛出
raise
raise last_error # type: ignore[misc]
async def download_video(url: str) -> Optional[Path]:
"""下载视频,支持直链和 yt-dlp"""
async def download_video(
url: str,
*,
author: Optional[str] = None,
author_id: Optional[str] = None,
) -> tuple[Optional[Path], str]:
"""下载视频,支持直链和 yt-dlp
author / author_id 可由调用方覆盖(如 B站 视频动态已从动态数据里拿到 mid,
传进来才能和同一位 up 的图文落在同一个作者目录)。
Returns:
(本地文件, 作品标题) — 失败时 (None, "")。
落盘位置:`temp/{平台}/{作者}_{作者id}/{作品名}[_{短码}].ext`
(直链拿不到作者信息,统一进 `未知作者/`)。
"""
platform = detect_platform(url)
temp_root = get_temp_root(platform)
# ---------- 1. 直链探测 ----------
# 不含 m3u8:HLS 播放列表直下只会得到一个文本文件,交给 yt-dlp 处理
direct_media_ext = re.search(
r"\.(mp4|m3u8|ts|webm|mov|flv)(?:$|\?)", url, re.IGNORECASE
r"\.(mp4|ts|webm|mov|flv)(?:$|\?)", url, re.IGNORECASE
)
is_direct = bool(direct_media_ext)
@@ -105,39 +139,35 @@ async def download_video(url: str) -> Optional[Path]:
is_direct = False
if is_direct:
temp_dir = tempfile.mkdtemp(prefix="direct_ytcache_", dir=get_temp_root("ytcache"))
ext = "mp4"
m = re.search(r"\.([a-zA-Z0-9]{2,5})(?:$|\?)", url)
if m and len(m.group(1)) <= 5:
ext = m.group(1)
url_stem = Path(url.split("?")[0]).stem or "video"
slug_stem = slugify(url_stem, max_length=15)
if not slug_stem:
slug_stem = datetime.now().strftime("%H%M%S")
time_suffix = datetime.now().strftime("%H%M%S")
new_name = f"{slug_stem}_视频_{time_suffix}.{ext}"
filename = os.path.join(temp_dir, new_name)
slug_stem = slugify(url_stem, max_length=15) or "视频"
# 直链拿不到作者信息 → `未知作者_{来源短码}`(同一链接稳定、不同链接不撞)
author_dir = build_author_dir(author, author_id, source=url)
final_path = unique_media_path(temp_root / author_dir / f"{slug_stem}.{ext}")
try:
async with AsyncClient(follow_redirects=True, timeout=300) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
with open(filename, "wb") as fh:
with open(final_path, "wb") as fh:
async for chunk in resp.aiter_bytes(chunk_size=8192):
fh.write(chunk)
final_path = ensure_unique_path(Path(filename))
logger.info(f"直接下载完成: {final_path}")
return final_path
return final_path, ""
except Exception:
logger.exception("直接下载失败,回退 yt-dlp")
if os.path.exists(filename):
os.remove(filename)
final_path.unlink(missing_ok=True)
# ---------- 2. yt-dlp 下载 ----------
platform = detect_platform(url)
temp_dir = tempfile.mkdtemp(prefix="ytcache_", dir=get_temp_root("ytcache"))
# 先下到 scratch 目录(outtmpl 必须在拿到 info 之前给定),拿到 info 后再
# 按作者归位到 temp/{平台}/{作者}_{作者id}/
temp_dir = tempfile.mkdtemp(prefix="_dl_", dir=temp_root)
output_path = os.path.join(temp_dir, "%(title).80s.%(ext)s")
base_opts = {
@@ -188,7 +218,10 @@ async def download_video(url: str) -> Optional[Path]:
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
base_opts["extractor_args"] = {"twitter": {"api": ["syndication"]}}
# 不要强制 twitter:api=syndication:该端点为未登录视角,对敏感/受限推文
# 只返回 tombstone(无 mediaDetails),且会无条件覆盖已登录 GraphQL 的结果,
# 表现为 "No video could be found in this tweet"。默认走 GraphQL + cookies,
# 遇 429 yt-dlp 会自行回退 syndication。
elif platform == "youtube":
base_opts["http_headers"] = {
"User-Agent": ua,
@@ -206,54 +239,53 @@ async def download_video(url: str) -> Optional[Path]:
info = await _retry_download(loop, url, base_opts)
except Exception:
logger.exception("yt-dlp 下载失败")
# YouTube: cookies 可能触发 bot 检测导致只返回图片无视频格式
# 回退无 cookie 模式重试
if platform == "youtube" and "cookiefile" in base_opts:
# YouTube: cookies 可能触发 bot 检测导致只返回图片无视频格式
# 回退无 cookie 模式重试
logger.info("YouTube 回退无 cookies 模式重试...")
base_opts.pop("cookiefile", None)
base_opts.pop("http_headers", None)
# 清理失败残留
for f in Path(temp_dir).glob("*.*"):
try:
f.unlink()
except Exception:
pass
try:
info = await _retry_download(loop, url, base_opts, max_retries=2)
except Exception:
logger.exception("yt-dlp 无 cookies 重试也失败")
return None
elif platform == "twitter":
# X 登录态失效(auth_token 过期)时 GraphQL 会直接拒绝请求;
# 退回未登录的 syndication 端点,公开推文仍可下载(敏感推文会失败)
logger.info("Twitter 回退 syndication 端点重试...")
base_opts["extractor_args"] = {"twitter": {"api": ["syndication"]}}
else:
return None
return None, ""
# 清理失败残留
for f in Path(temp_dir).glob("*.*"):
try:
f.unlink()
except Exception:
pass
try:
info = await _retry_download(loop, url, base_opts, max_retries=2)
except Exception:
logger.exception("yt-dlp 回退重试也失败")
return None, ""
files = list(Path(temp_dir).glob("*.*"))
if not files:
return None
return None, ""
original_file = files[0]
# 构建新文件名
uploader = extract_uploader(info or {})
# 归位到作者目录:{作者}_{作者id}/{作品名}[_{短码}].ext
uploader = author or extract_uploader(info or {})
uploader_id = author_id or extract_uploader_id(info or {})
title = ((info or {}).get("title") or "").strip()
slug_title = slugify(title, max_length=15) if title else ""
if not slug_title:
slug_title = datetime.now().strftime("%H%M%S")
time_suffix = datetime.now().strftime("%H%M%S")
if uploader:
slug_uploader = slugify(str(uploader))
new_stem = f"{slug_uploader}_{slug_title}_视频_{time_suffix}"
else:
new_stem = f"{slug_title}_视频_{time_suffix}"
new_path = ensure_unique_path(
original_file.with_name(f"{new_stem}{original_file.suffix}")
author_dir = build_author_dir(uploader, uploader_id, source=url)
new_path = unique_media_path(
temp_root / author_dir / f"{build_work_stem(title)}{original_file.suffix}"
)
original_file.rename(new_path)
shutil.move(str(original_file), str(new_path))
# 下载用的 scratch 目录已空,顺手收掉(cleanup 不删目录)
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(
f"yt-dlp 下载完成, 标题: {title}, "
f"作者: {uploader}, 重命名: {new_path}"
f"作者: {uploader}({uploader_id}), 落盘: {new_path}"
)
return new_path
return new_path, title
@@ -0,0 +1,271 @@
"""群文件上传 —— 与消息发送并行的第二条投递通道。
策略开关见 `policy.Policy.upload_group_file`:开着的时候,媒体照常发到群里,
同时另传一份到群文件(群友可随时下载、不占聊天记录)。
投递前会按全局配置打包(`video_analysis_group_file_zip`)成一个 zip:
配了解压密码(`video_analysis_group_file_password`)就用 AES-256 加密,
**密码设置了但 pyzipper 不可用时直接放弃上传,绝不退化成传明文**。
(无密码的普通 zip 走标准库,零依赖。)
失败只记日志(bot 没有群文件权限、超出群文件大小上限等都属于预期内的失败),
不影响消息发送链路;上传成功后不额外发消息,避免刷屏。
"""
from __future__ import annotations
import asyncio
import re
import zipfile
from collections.abc import Iterable
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from nonebot import get_bot, logger
if TYPE_CHECKING: # 仅类型检查:运行期不导入,单测可裸加载本模块
from ..policy import Policy
try: # 缺失时只影响「加密打包」这一路,见 build_archive
import pyzipper
except ImportError: # pragma: no cover
pyzipper = None # type: ignore[assignment]
def encryption_available() -> bool:
"""加密打包是否可用(pyzipper 已安装)。"""
return pyzipper is not None
def build_archive(
files: Iterable[Path | str],
title: str = "",
*,
password: str = "",
out_dir: Path | None = None,
rel_dir: str = "",
) -> Path:
"""把文件打包成一个 zip,返回产物路径;password 非空则 AES-256 加密。
out_dir 缺省落 `hexi/data/temp/archive/{作者目录}`(rel_dir 由调用方传入,
与源媒体同一套目录结构,见 sender.media_rel_dir_of),由 cleanup 按天清理。
加密需要 pyzipper,缺失时抛 RuntimeError —— 调用方应当**放弃上传**。
"""
paths = [Path(f) for f in files]
if not paths:
raise ValueError("没有可打包的文件")
missing = [p for p in paths if not p.exists()]
if missing:
raise FileNotFoundError(f"待打包文件不存在: {missing[0]}")
if password and pyzipper is None:
raise RuntimeError("配置了解压密码,但 pyzipper 未安装,无法加密打包")
archive = _resolve_target(out_dir, title, rel_dir)
if password:
with pyzipper.AESZipFile(
archive,
"w",
compression=pyzipper.ZIP_DEFLATED,
encryption=pyzipper.WZ_AES,
) as zf:
zf.setpassword(password.encode("utf-8"))
_write_members(zf, paths)
else:
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
_write_members(zf, paths)
logger.info(
f"群文件打包完成: {archive.name}({len(paths)} 个文件"
f"{',已加密' if password else ''})"
)
return archive
def _default_archive_dir() -> Path:
"""默认落 temp/archive(延迟导入 utils:单测裸加载本模块时没有包上下文)。"""
from ...utils import get_temp_root
return get_temp_root("archive")
def _safe_stem(title: str, max_length: int = 15) -> str:
"""标题 → 安全的文件名片段。
原始标题(抖音文案、YouTube 标题)可能带 `/`、换行、控制字符与 `#话题`,
这些进不了文件名,统一换成 `_` 后截断。
"""
cleaned = re.sub(r"#\S+", "", title)
cleaned = re.sub(r'[\\/:*?"<>|\s]+', "_", cleaned.strip())
cleaned = re.sub(r"_+", "_", cleaned).strip("_")
return cleaned[:max_length].strip("_")
def _resolve_target(out_dir: Path | None, title: str, rel_dir: str = "") -> Path:
"""产物最终路径:默认 temp/archive/{rel_dir},重名自动加序号。
空标题 / 占位符 / 已带「群文件」前缀的一律回退成默认名。
"""
base = Path(out_dir) if out_dir is not None else _default_archive_dir()
if rel_dir:
base = base / rel_dir
base.mkdir(parents=True, exist_ok=True)
stamp = f"{datetime.now():%H%M%S}"
stem = _safe_stem(title) if title else ""
if not stem or stem in ("title", "video") or stem.startswith("群文件"):
return _unique_path(base / f"群文件_{stamp}.zip")
return _unique_path(base / f"{stem}_群文件_{stamp}.zip")
def _unique_path(path: Path) -> Path:
"""重名追加 _2/_3…(同 utils.ensure_unique_path 的语义,就地实现)。"""
if not path.exists():
return path
index = 2
while True:
candidate = path.with_name(f"{path.stem}_{index}{path.suffix}")
if not candidate.exists():
return candidate
index += 1
def _write_members(zf: Any, paths: list[Path]) -> None:
"""写入成员:同名文件自动加序号,避免在包里互相覆盖。"""
used: set[str] = set()
for path in paths:
name = path.name
if name in used:
index = 2
while f"{path.stem}_{index}{path.suffix}" in used:
index += 1
name = f"{path.stem}_{index}{path.suffix}"
used.add(name)
zf.write(path, arcname=name)
def _file_uri(path: Path) -> str:
"""本地文件 → `file:///D:/a/b.zip`(正斜杠,中文/空格不转义)。
与图片/视频消息发给 NapCat 的形式一致;直接传 Windows 反斜杠路径会被
它的 realpath 判成 ENOENT(见 upload_group_file 的降级说明)。
"""
return "file:///" + str(path.resolve()).replace("\\", "/")
async def _call_upload(
file_value: str, group_id: int, name: str, folder_id: str | None = None
) -> None:
params: dict = {"group_id": group_id, "file": file_value, "name": name}
if folder_id:
params["folder"] = folder_id
await get_bot().call_api("upload_group_file", **params)
def _s3_url(path: Path, policy: "Policy | None") -> str:
"""把文件传到局域网 S3 换预签名链接(延迟导入 s3,便于单测裸加载本模块)。"""
from .s3 import upload_with_plan
url, _ = upload_with_plan(path, policy=policy)
return url
async def upload_group_file(
file_path: Path | str,
group_id: int,
*,
folder_id: str | None = None,
policy: Policy | None = None,
name: str | None = None,
) -> bool:
"""上传单个文件到群文件,返回是否成功。
两级投递,**S3 链接优先、本地直传兜底**:
本环境实测 NapCat 读不到 bot 进程写的本地文件(`realpath ... ENOENT`,
媒体消息的本地直发 55 次全失败、换 S3 链接后次次成功),所以直传只留作
S3 不可用时的兜底。policy 决定 S3 落到哪个桶(plan)。
本地直传用 `file:///D:/…` 形式(正斜杠、不转义中文)——图片/视频消息就是
这么发的,同机部署时能work。
name 可覆盖群文件列表里显示的名字(多图作品的成员是 001.jpg,调用方会补上
作品名前缀,免得多张图在群文件里全叫 001.jpg)。
"""
path = Path(file_path)
if not path.exists():
logger.warning(f"群文件上传:文件不存在 {path}")
return False
display_name = name or path.name
# 1) 局域网 S3 预签名链接(boto3 同步,丢线程池)
url = await asyncio.to_thread(_s3_url, path, policy)
if url:
try:
await _call_upload(url, group_id, display_name, folder_id)
except Exception as e:
logger.warning(
f"群文件 S3 链接上传失败(群 {group_id} / {display_name}): "
f"{str(e)[:160]};改用本地路径重试"
)
else:
logger.info(f"群文件上传成功(群 {group_id},S3 链接): {display_name}")
return True
else:
logger.warning("群文件:S3 中转没拿到链接,改用本地路径直传")
# 2) 兜底:file:/// 直传本地文件
try:
await _call_upload(_file_uri(path), group_id, display_name, folder_id)
except Exception as e:
logger.warning(
f"群文件上传失败(群 {group_id} / {display_name}): {str(e)[:160]}"
)
return False
logger.info(f"群文件上传成功(群 {group_id},本地直传): {display_name}")
return True
async def upload_group_files(
files: Iterable[Path | str],
group_id: int,
*,
title: str = "",
zip_files: bool = True,
password: str = "",
policy: Policy | None = None,
rel_dir: str = "",
) -> bool:
"""群文件投递入口:按配置打包(可加密)后传一个包,或逐个传原文件。
rel_dir 是源媒体所在的 `{作者}_{作者id}[/{作品名}]` 子目录,打包产物落到
`temp/archive/{rel_dir}`(由调用方从文件路径推出来,见 sender)。
"""
paths = [Path(f) for f in files]
if not paths:
return False
if not zip_files:
# 多图作品逐个传时,成员名是 001.jpg…,补上作品名前缀便于在群文件里辨认
work = Path(rel_dir).name if rel_dir else ""
prefix = f"{work}_" if work and len(paths) > 1 else ""
results = [
await upload_group_file(
p, group_id, policy=policy, name=f"{prefix}{p.name}"
)
for p in paths
]
return any(results)
try:
archive = await asyncio.to_thread(
build_archive, paths, title, password=password, rel_dir=rel_dir
)
except Exception as e:
logger.error(f"群文件打包失败,跳过本次群文件上传: {e}")
return False
return await upload_group_file(archive, group_id, policy=policy)
@@ -1,7 +1,6 @@
"""统一 S3 存储模块 — 合并本地局域网 S3 和公网 MinIO"""
from pathlib import Path
from time import strftime, localtime
import boto3
from botocore.config import Config
@@ -9,6 +8,9 @@ from nonebot import logger
from hexi.web_hub.web_config import get_effective_value
from ...policy import Policy
from ...utils import media_key_of
# 该插件模块名,用于读取统一配置值库(Web 修改后生效)
_PLUGIN_ID = "hexi.plugins.nonebot_plugin_video_analysis"
@@ -171,11 +173,13 @@ def upload_to_public_s3(file_path: str | Path) -> tuple[str, str]:
"""
上传到公网 MinIO,返回 (公网URL, file_key)
私聊场景使用,生成可公网访问的链接
私聊场景使用,生成可公网访问的链接。
key 按本地落盘结构推导:`{作者}_{作者id}/{作品名}[_{短码}].ext`
(不再用 `{年月日}/` 前缀,见 utils.media_key_of)。
"""
file_path = Path(file_path)
current_day = strftime("%Y-%m-%d", localtime())
file_key = f"{current_day}/{file_path.name}"
file_key = media_key_of(file_path)
try:
url = _get_public_s3().upload_public(str(file_path), file_key)
return url, file_key
@@ -184,22 +188,19 @@ def upload_to_public_s3(file_path: str | Path) -> tuple[str, str]:
return "", ""
def upload_to_local_s3(
title: str, image_post: bool, file_path: str | Path, plan: str | None = None
) -> str:
def upload_to_local_s3(file_path: str | Path, plan: str | None = None) -> str:
"""
上传到局域网 S3,返回预签名 URL
plan="A" → PLANA 桶
plan="B" → PLANB 桶
None → PLANC 桶(默认)
key 与公网一致:`{作者}_{作者id}/{作品名}[_{短码}].ext`
(不再有 `{年月日}/` 前缀,也不再重复套一层标题目录)。
"""
file_path = Path(file_path)
current_day = strftime("%Y-%m-%d", localtime())
if image_post:
file_key = f"{current_day}/{title}/{file_path.name}"
else:
file_key = f"{current_day}/{file_path.name}"
file_key = media_key_of(file_path)
if plan == "A":
client = _get_local_s3_plana()
@@ -224,35 +225,24 @@ def delete_from_public_s3(file_key: str) -> bool:
def upload_with_plan(
file_path: str | Path,
*,
plan: str | None = None,
is_private: bool = False,
title: str = "",
image_post: bool = False,
policy: Policy | None = None,
) -> tuple[str, str | None]:
"""
统一上传入口:按 plan 路由到对应的本地桶,并按需上传公网
统一上传入口:按策略路由本地桶,并按需上传公网
plan="A" → PLANA(仅本地)
plan="B" → PLANB + 公网
私聊 → PLANB + 公网
默认 → PLANC(仅本地)
policy.plan → PLANA / PLANB / PLANC(默认 C)
policy.upload_public → 是否额外上传公网(决定能否发下载链接)
key 由文件路径推导(utils.media_key_of),调用方不再传标题。
Returns:
(local_url, public_url_or_none)
"""
# 本地上传
if plan == "A":
local_url = upload_to_local_s3(title, image_post, file_path, plan="A")
elif plan == "B":
local_url = upload_to_local_s3(title, image_post, file_path, plan="B")
elif is_private:
local_url = upload_to_local_s3(title, image_post, file_path, plan="B")
else:
local_url = upload_to_local_s3(title, image_post, file_path) # PLANC
policy = policy or Policy()
local_url = upload_to_local_s3(file_path, plan=policy.plan)
# 公网上传(仅 PLANB 或私聊)
public_url = None
if plan == "B" or is_private:
if policy.upload_public:
public_url, _ = upload_to_public_s3(file_path)
return local_url, public_url
@@ -0,0 +1,508 @@
"""Web 管理台的「链接解析 + 预览」任务层(供 web_hub.py 调用)。
群消息链路(`handlers/entry.py::dispatch_url`)从解析到投递都绑在 event/bot 上,
Web 上下文里第一次 `UniMessage.send()` 就会抛 SerializeFailed,所以这里**只复用
纯函数层**:fetchers 的解析/下载 + `storage/s3.py::upload_with_plan`,
全程不发消息、不碰 event、不读群策略。
存储按**默认策略**走(`STORE.default_policy()` 的 plan / upload_public):
产出什么链接就返回什么链接 —— 开了 `upload_public` 才有公网链接,否则只有
局域网 S3 的预签名链接(1 小时过期,靠 `refresh()` 重传换新)。
任务表在内存里(进程重启即清空,前端按"查不到就算了"处理):
submit(urls, force=False) 新建任务;同 URL 已有 queued/running 任务时**复用**
list_jobs() 全部任务,新的在前
refresh(job_id) 对已落盘文件重跑上传,换一批新链接
抖音每次都新起一个 Chrome,所以并发闸门固定 `Semaphore(2)`;单任务 240s 超时兜底。
单文件上传失败只标该文件(`files[i].error`),不整体判失败 —— 多图作品挂一张
不该让整条任务失败,缺的那个文件点「刷新链接」还能补回来。
进度只有两档(解析中 → 上传中):解析和下载都在 fetcher 内部完成,中间没有可挂的钩子。
"""
from __future__ import annotations
import asyncio
import re
import subprocess
import time
import uuid
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional
from nonebot import logger
if TYPE_CHECKING: # 仅类型检查:运行期不导入,单测可裸加载本模块
from ..policy import Policy
# ─────────────────────────── 常量 ───────────────────────────
#: URL 的终止符:空白 + 中文标点(全角括号/引号/破折号也要断)
URL_STOP = ",。!?、;:()【】《》「」『』…—“”‘’"
#: 从整段文本里挑 http(s) 链接。形制同 handlers/entry.py::URL_PATTERN,
#: 但把中文标点也当成终止符 —— `链接A,链接B` 中间没有空格也能各自成条
#: (`\S+` 会把它们连同后面的中文连成一个词,整段粘进来时更容易踩到)。
URL_PATTERN = re.compile(r"https?://[^" + re.escape(URL_STOP) + r"\s]+")
#: 兜底再洗一遍收尾标点(entry.py::dispatch_url 同款)
URL_TRAILING = URL_STOP
#: 任务表上限与终态任务的保留时长(秒)
MAX_JOBS = 100
TERMINAL_TTL = 30 * 60
#: 单任务超时(含下载,不含排队等闸门的时间)
JOB_TIMEOUT = 240
#: 并发闸门:抖音每次都新起 Chrome,必须限流
MAX_CONCURRENCY = 2
VIDEO_SUFFIXES = {".mp4", ".webm", ".mov", ".flv", ".mkv", ".ts", ".m4v"}
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"}
#: 视频封面(关键帧):宽度压到 480 够卡片用,别把 4K 原帧传上云
POSTER_WIDTH = 480
POSTER_SUFFIX = "_封面.jpg"
POSTER_TIMEOUT = 20
#: 状态机:queued → running → done / failed
ACTIVE_STATUSES = ("queued", "running")
# ─────────────────────────── 纯函数 ───────────────────────────
def extract_urls(text: str) -> list[str]:
"""整段文本 → 去重后的 URL 列表(保序,去掉尾随中文标点)。
多行粘贴时一行一条;URL 后面跟的"。"这类标点不能进链接。
"""
urls: list[str] = []
for raw in URL_PATTERN.findall(text or ""):
url = raw.rstrip(URL_TRAILING)
if url and url not in urls:
urls.append(url)
return urls
def kind_of(path: Path | str) -> str:
"""文件类型(前端据此决定内联预览方式)。"""
suffix = Path(path).suffix.lower()
if suffix in VIDEO_SUFFIXES:
return "video"
if suffix in IMAGE_SUFFIXES:
return "image"
return "file"
# ─────────────────────────── 依赖注入点 ───────────────────────────
#: (标题, 落盘文件列表, 是否图文作品)
FetchResult = tuple[Optional[str], list[Path], bool]
Fetcher = Callable[[str], Awaitable[FetchResult]]
#: (局域网链接, 公网链接) —— 拿不到时返回 "" / None
Uploader = Callable[[Path, "Optional[Policy]"], Awaitable[tuple[str, Optional[str]]]]
#: 视频 → 封面图(抽关键帧);抽不出来返回 None
PosterMaker = Callable[[Path], Awaitable[Optional[Path]]]
def ffmpeg_path() -> str:
"""ffmpeg 可执行文件(解析逻辑复用 video_downloader;单测可替换)"""
from .fetchers.video_downloader import get_ffmpeg_path
return get_ffmpeg_path()
async def make_poster(video: Path) -> Optional[Path]:
"""抽一帧当视频封面,落同目录 `{作品名}_封面.jpg`;失败返回 None(不影响任务)。
先取第 1 秒(躲开黑场/淡入),整段不到 1 秒的视频退回第 0 秒再试一次。
"""
out = video.with_name(f"{video.stem}{POSTER_SUFFIX}")
for seek in ("1", "0"):
cmd = [
ffmpeg_path(),
"-ss",
seek,
"-i",
str(video),
"-frames:v",
"1",
"-vf",
f"scale={POSTER_WIDTH}:-2",
"-q:v",
"4",
"-y",
str(out),
]
try:
# 同步子进程必须丢线程池,否则抽帧期间整个事件循环都停摆
await asyncio.to_thread(
subprocess.run, cmd, capture_output=True, timeout=POSTER_TIMEOUT
)
except Exception: # noqa: BLE001 — 封面失败不该拖累任务
logger.warning(f"抽关键帧失败:{video.name}")
return None
if out.exists() and out.stat().st_size > 0:
return out
return None
def platform_of(url: str) -> Optional[str]:
"""URL → 平台规范标签(延迟导入 policy,便于单测裸加载本模块)。"""
from ..policy import match_platform
return match_platform(url)
def default_policy() -> "Optional[Policy]":
"""Web 解析统一按默认策略走(不是某个群的策略)。"""
from ..policy import STORE
return STORE.default_policy()
async def fetch_media(url: str) -> FetchResult:
"""按平台解析并下载媒体(纯函数层:不发消息、不碰 event、不上传)。
分派规则与 `handlers/entry.py::dispatch_url` 一致:抖音 → parse_douyin;
B站动态/专栏 → fetch_bilibili_content;其余 B站链接 → download_video(yt-dlp);
小红书 → fetch_rednote_content;其它平台 → download_video。
b23 / xhslink / 抖音短链的重定向在各 fetcher 内部自己处理。
"""
from ..handlers.douyin import parse_douyin
from ..policy import BILIBILI, DOUYIN, XHS
from .fetchers.bilibili_content import fetch_bilibili_content
from .fetchers.rednote_content import fetch_rednote_content
from .fetchers.video_downloader import download_video
platform = platform_of(url)
title: Optional[str] = None
parsed: Path | list[Path] | None = None
image_post = False
if platform == DOUYIN:
title, parsed, image_post = await parse_douyin(url)
elif platform == XHS:
title, parsed = await fetch_rednote_content(url)
image_post = isinstance(parsed, list)
elif platform == BILIBILI and any(
kw in url
for kw in (
"bilibili.com/opus",
"bilibili.com/dynamic",
"t.bilibili.com",
"bilibili.com/read",
)
):
title, parsed = await fetch_bilibili_content(url)
image_post = isinstance(parsed, list)
else:
video_file, title = await download_video(url)
parsed = video_file
if isinstance(parsed, list):
files = [Path(p) for p in parsed]
else:
files = [Path(parsed)] if parsed else []
return title, files, image_post
async def upload_file(
path: Path, policy: "Optional[Policy]"
) -> tuple[str, Optional[str]]:
"""上传单个文件 → (局域网链接, 公网链接)。
boto3 是同步的,必须丢线程池:直接在协程里跑会卡死整个事件循环
(Web 轮询、群消息全都停摆)。
"""
from .storage.s3 import upload_with_plan
return await asyncio.to_thread(upload_with_plan, path, policy=policy)
# ─────────────────────────── 任务模型 ───────────────────────────
@dataclass
class WebJob:
"""一条解析任务;`to_dict()` 就是 API 的返回体。"""
id: str
url: str
platform: Optional[str] = None
title: str = ""
status: str = "queued"
#: 运行中的进度文案:解析中 / 上传中(见模块文档:解析与下载不细分)
stage: str = ""
#: 每个文件:{name, kind, size, local_url, public_url, error}
files: list[dict[str, Any]] = field(default_factory=list)
#: 卡片封面:有图取第一张图,全是视频则抽第一帧(见 `_resolve_cover`)
cover_url: str = ""
error: str = ""
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
#: 落盘的源文件(不出 API;`refresh()` 重传要用)
paths: list[Path] = field(default_factory=list, repr=False)
#: 抽出来的封面文件(不出 API;`refresh()` 重传要用)
poster_path: Optional[Path] = field(default=None, repr=False)
@property
def active(self) -> bool:
"""还在排队或运行(终态任务才会被 prune 掉)。"""
return self.status in ACTIVE_STATUSES
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"url": self.url,
"platform": self.platform,
"title": self.title,
"status": self.status,
"stage": self.stage,
"files": self.files,
"cover_url": self.cover_url,
"error": self.error,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
class JobManager:
"""内存任务表:上限 MAX_JOBS 条,终态任务 TTL 30 分钟后自动清掉。
fetch / upload / poster / policy 四个依赖可注入(单测塞假实现,不碰网络与磁盘)。
"""
def __init__(
self,
*,
fetch: Optional[Fetcher] = None,
upload: Optional[Uploader] = None,
poster: Optional[PosterMaker] = None,
policy: Optional[Callable[[], "Optional[Policy]"]] = None,
) -> None:
self._jobs: dict[str, WebJob] = {}
self._tasks: dict[str, asyncio.Task[None]] = {}
self._sem: Optional[asyncio.Semaphore] = None
self._fetch: Fetcher = fetch or fetch_media
self._upload: Uploader = upload or upload_file
self._poster: PosterMaker = poster or make_poster
self._policy: Callable[[], "Optional[Policy]"] = policy or default_policy
# ── 查询 ──────────────────────────────────────────────
def list_jobs(self) -> list[WebJob]:
"""全部任务,新的在前。"""
self._prune()
return sorted(self._jobs.values(), key=lambda j: j.created_at, reverse=True)
def get(self, job_id: str) -> Optional[WebJob]:
return self._jobs.get(job_id)
def _find_active(self, url: str) -> Optional[WebJob]:
for job in self._jobs.values():
if job.url == url and job.active:
return job
return None
# ── 提交与执行 ────────────────────────────────────────
async def submit(self, urls: Iterable[str], *, force: bool = False) -> list[WebJob]:
"""提交一批 URL;同 URL 已有在跑的任务时直接复用(force=True 强行新建)。
复用不只是省一次解析:抖音双开浏览器毫无意义,而且两个任务同时往
`unique_media_path` 的同一个路径写会撞车(它是 exists → 改名的写法)。
"""
self._prune()
jobs: list[WebJob] = []
for url in urls:
job = None if force else self._find_active(url)
if job is None:
job = WebJob(
id=uuid.uuid4().hex[:12], url=url, platform=platform_of(url)
)
self._jobs[job.id] = job
self._spawn(job)
jobs.append(job)
return jobs
async def refresh(self, job_id: str) -> Optional[WebJob]:
"""对已落盘文件重跑上传,换一批新的预签名链接(旧的 1 小时过期)。
任务不存在 / 还没有文件时返回 None,由调用方给提示。
"""
job = self._jobs.get(job_id)
if job is None or not job.files:
return None
policy = self._policy()
job.files = [await self._upload_one(path, policy) for path in job.paths]
job.cover_url = await self._resolve_cover(job, policy)
job.updated_at = time.time()
return job
# ── 清理 ──────────────────────────────────────────────
def remove(self, job_id: str) -> bool:
"""删掉一条任务;还在跑的一并取消(Chrome 那头由 playwright 自己收尾)。"""
job = self._jobs.pop(job_id, None)
if job is None:
return False
task = self._tasks.pop(job_id, None)
if task is not None and not task.done():
task.cancel()
return True
def clear_finished(self) -> int:
"""清掉所有终态任务(排队/运行中的不动),返回清掉几条。"""
finished = [jid for jid, job in self._jobs.items() if not job.active]
for job_id in finished:
self._jobs.pop(job_id, None)
return len(finished)
def _spawn(self, job: WebJob) -> None:
task = asyncio.create_task(self._run(job))
self._tasks[job.id] = task
task.add_done_callback(lambda _t: self._tasks.pop(job.id, None))
async def _run(self, job: WebJob) -> None:
"""排队等闸门 → 执行;任何异常都落到任务状态里,不外抛。"""
try:
async with self._gate():
await asyncio.wait_for(self._execute(job), JOB_TIMEOUT)
except asyncio.CancelledError:
raise
except asyncio.TimeoutError: # 3.11+ 就是内置 TimeoutError,wait_for 抛的
self._update(job, status="failed", error=f"解析超时(超过 {JOB_TIMEOUT}s)")
except Exception as e: # noqa: BLE001 — 任务边界,失败即任务状态
logger.exception(f"Web 解析任务失败:{job.url}")
self._update(job, status="failed", error=str(e) or type(e).__name__)
async def _execute(self, job: WebJob) -> None:
self._update(job, status="running", stage="解析中")
policy = self._policy()
title, files, _ = await self._fetch(job.url)
job.title = title or job.url
if not files:
raise RuntimeError("无法解析到媒体(链接失效 / 风控 / cookies 过期)")
job.paths = list(files)
self._update(job, stage="上传中")
entries: list[dict[str, Any]] = []
for path in files:
entries.append(await self._upload_one(path, policy))
job.files = entries # 落一个刷一个,前端能看着进度出图
job.cover_url = await self._resolve_cover(job, policy)
self._update(job, status="done", stage="")
async def _resolve_cover(self, job: WebJob, policy: "Optional[Policy]") -> str:
"""任务封面:有图就用第一张图;全是视频就抽第一帧上传当封面。
抽帧/上传失败都只返回空串 —— 卡片那边退化成占位块,不影响任务本身。
"""
for entry in job.files:
if entry["kind"] == "image":
url = entry["public_url"] or entry["local_url"]
if url:
return url
if job.poster_path and job.poster_path.exists():
entry = await self._upload_one(job.poster_path, policy)
return entry["public_url"] or entry["local_url"]
for path in job.paths:
if kind_of(path) != "video" or not path.exists():
continue
poster = await self._poster(path)
if poster is None:
return ""
job.poster_path = poster
entry = await self._upload_one(poster, policy)
return entry["public_url"] or entry["local_url"]
return ""
async def _upload_one(
self, path: Path, policy: "Optional[Policy]"
) -> dict[str, Any]:
"""上传单个文件 → files 里的一项;失败只标这项。"""
if not path.exists():
return _file_entry(path, error="本地文件不存在(可能已被 temp 清理)")
try:
local_url, public_url = await self._upload(path, policy)
except Exception as e: # noqa: BLE001 — 单文件失败不拖累整条任务
logger.exception(f"Web 任务上传失败:{path}")
return _file_entry(path, error=f"上传失败:{str(e)[:120]}")
entry = _file_entry(path, local_url=local_url, public_url=public_url)
if not entry["local_url"] and not entry["public_url"]:
entry["error"] = "上传失败(S3 没返回链接)"
return entry
# ── 内部工具 ──────────────────────────────────────────
def _gate(self) -> asyncio.Semaphore:
"""惰性建闸门:构造必须发生在跑着的事件循环里。"""
if self._sem is None:
self._sem = asyncio.Semaphore(MAX_CONCURRENCY)
return self._sem
def _update(
self,
job: WebJob,
*,
status: Optional[str] = None,
stage: Optional[str] = None,
error: Optional[str] = None,
) -> None:
if status is not None:
job.status = status
if stage is not None:
job.stage = stage
if error is not None:
job.error = error
job.updated_at = time.time()
def _prune(self) -> None:
"""先清超龄的终态任务,再按上限砍掉最旧的终态任务(在跑的不动)。"""
now = time.time()
for job_id, job in list(self._jobs.items()):
if not job.active and now - job.updated_at > TERMINAL_TTL:
self._jobs.pop(job_id, None)
overflow = len(self._jobs) - MAX_JOBS
if overflow <= 0:
return
finished = sorted(
(j for j in self._jobs.values() if not j.active),
key=lambda j: j.created_at,
)
for job in finished[:overflow]:
self._jobs.pop(job.id, None)
def _file_entry(
path: Path,
*,
local_url: str = "",
public_url: Optional[str] = None,
error: str = "",
) -> dict[str, Any]:
try:
size = path.stat().st_size
except OSError:
size = 0
return {
"name": path.name,
"kind": kind_of(path),
"size": size,
"local_url": local_url or "",
"public_url": public_url or "",
"error": error,
}
#: 全局单例(web_hub.py 的路由共用)
JOBS = JobManager()
@@ -1,8 +1,9 @@
import hashlib
import re
import time
import unicodedata
from pathlib import Path
from time import strftime, localtime
from typing import List
from typing import List, Optional, Tuple
def get_data_dir() -> Path:
@@ -72,6 +73,9 @@ def ensure_unique_path(base_path: Path) -> Path:
"""
确保路径不冲突:如已存在则追加 _2, _3... 后缀
适用于文件和目录
注:媒体落盘已统一走 `unique_media_path`(同名加 4 位短码),本函数仅作
通用兜底保留。
"""
if not base_path.exists():
return base_path
@@ -88,52 +92,139 @@ def ensure_unique_path(base_path: Path) -> Path:
counter += 1
def clean_filename(filename: str, max_length: int = 120) -> str:
_B36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
#: 同名短码的模数 —— base36 四位(36**4 ≈ 168 万秒 ≈ 19.4 天一轮)
_B36_MOD = 36**4
def _to_base36(value: int, width: int = 4) -> str:
"""整数 → 定宽 base36(不足左侧补 0)"""
if value <= 0:
return "0" * width
digits = []
while value:
value, rem = divmod(value, 36)
digits.append(_B36_ALPHABET[rem])
return "".join(reversed(digits)).rjust(width, "0")
def short_time_code(at: Optional[float] = None) -> str:
"""4 位 base36 短码(同名兜底用,不可读时间,仅作区分码)"""
return _to_base36(int(time.time() if at is None else at) % _B36_MOD)
#: 各平台把"没有 id"写成过这些值,别让它们进目录名
_JUNK_AUTHOR_IDS = {"", "0", "none", "null", "na", "nan", "undefined"}
#: 抓取层拿不到昵称时的兜底串(slugify 后)—— 它们等于"没有作者信息"
_PLACEHOLDER_AUTHORS = {"未知作者", "小红书用户", "b站用户"}
def _clean_author_id(author_id: Optional[str]) -> str:
"""作者 id 归一:剔除占位值(yt-dlp 缺字段常给 `NA`),再 slugify。"""
raw = str(author_id or "").strip()
if raw.lower() in _JUNK_AUTHOR_IDS:
return ""
return slugify(raw, max_length=40)
def short_source_code(source: str) -> str:
"""来源串(作品 URL/id)→ 4 位 base36 短码。
与 `short_time_code` 的区别:同一个来源永远得到同一个码(md5 取摘要,
不能用内置 `hash()` —— 它有随机盐,重启后目录名会变)。
"""
清理文件名并添加时间前缀
支持多扩展名,如 .tar.gz
digest = hashlib.md5(source.encode("utf-8")).digest()
return _to_base36(int.from_bytes(digest[:4], "big") % _B36_MOD)
def build_author_dir(
author: Optional[str],
author_id: Optional[str] = None,
*,
source: Optional[str] = None,
at: Optional[float] = None,
) -> str:
"""`{作者}_{作者id}` 作者目录名(拿不到 id 时追加码值避免同名混目录)。
- 昵称 + 作者 id → `{昵称}_{id}`(昵称上限 30、id 上限 40;**不能**把 id 截到
20:`MS4wLjABAAAA…` 这类 sec_uid 公共前缀就有 17 字符,再截断必撞车)
- 只有昵称 → `{昵称}_{4 位时间短码}`:没有 id 就分不清同名作者,宁可不聚合
(同一作者的不同作品会各成一个目录)也不能把两个人混进同一个目录
- 连昵称都没有 → `未知作者_{来源短码}`(`source` 给作品 URL/id,同一来源
稳定、不同来源不撞);连 source 都没有 → 退化成时间短码
"""
slug_author = slugify(author or "", max_length=30)
if slug_author in _PLACEHOLDER_AUTHORS:
slug_author = ""
slug_id = _clean_author_id(author_id)
current_time = strftime("%H-%M-%S", localtime())
if slug_author and slug_id:
# 某些站点上传者名就是 handle(X 的 @someone),别产出 someone_someone
if slug_author == slug_id:
return slug_author
return f"{slug_author}_{slug_id}"
if slug_id:
return f"{slug_author or '未知作者'}_{slug_id}"
if slug_author:
return f"{slug_author}_{short_time_code(at)}"
if source:
return f"未知作者_{short_source_code(source)}"
return f"未知作者_{short_time_code(at)}"
p = Path(filename)
# 主文件名
name = p.stem
def build_work_stem(title: Optional[str]) -> str:
"""作品名做文件名/子目录名:slugify(沿用 15 字上限),空则 `作品`"""
return slugify(title or "", max_length=15) or "作品"
# 完整扩展名 (.tar.gz)
ext = "".join(p.suffixes)
# Unicode 标准化
name = unicodedata.normalize("NFKC", name)
def unique_media_path(path: Path, *, at: Optional[float] = None) -> Path:
"""同名才加 4 位短码:`{stem}_{码}{后缀}`,仍撞则再叠 `_2/_3…`
# 去掉 #tag
name = re.sub(r"#\S+", "", name)
文件与目录通用(目录无后缀)。命名发生在落盘前,因此"不存在"即直接采用;
顺带确保父目录存在(作者目录是按需创建的)。
"""
if path.exists():
code = short_time_code(at)
candidate = path.with_name(f"{path.stem}_{code}{path.suffix}")
index = 2
while candidate.exists():
candidate = path.with_name(f"{path.stem}_{code}_{index}{path.suffix}")
index += 1
path = candidate
# 非法字符替换
name = re.sub(r'[\\/:*?"<>|]', "_", name)
path.parent.mkdir(parents=True, exist_ok=True)
return path
# 中英文标点
name = re.sub(r"[&'\"。,:?!《》【】|]", "_", name)
# 空白 -> _
name = re.sub(r"\s+", "_", name)
def _temp_rel_parts(file_path: Path | str) -> Tuple[str, ...]:
"""相对 temp 根拆路径:`{平台}/{作者目录}/…`(归档目录同理)
# 只保留:中文、字母、数字、_
name = re.sub(r"[^\w一-鿿_]", "", name)
不在 temp 下、或没到"平台 + 作者目录"这一层(老数据 / 第三方产物)→ 空元组,
调用方退化为只用文件名。
"""
try:
rel = Path(file_path).resolve().relative_to(get_temp_root().resolve())
except (ValueError, OSError):
return ()
parts = rel.parts
return parts[1:] if len(parts) >= 3 else ()
# 合并 _
name = re.sub(r"_+", "_", name)
# 去首尾 _
name = name.strip("_")
def media_key_of(file_path: Path | str) -> str:
"""媒体文件的 S3 对象 key:`{作者}_{作者id}/{作品名}[_{码}].后缀`
# 长度控制
max_name_length = max_length - len(ext) - len(current_time) - 1
if len(name) > max_name_length:
name = name[:max_name_length].rstrip("_")
由本地路径反推(去掉平台层),保证桶里和 temp 里结构一致。
"""
parts = _temp_rel_parts(file_path)
return "/".join(parts) if parts else Path(file_path).name
return f"{current_time}_{name}{ext}"
def media_rel_dir_of(file_path: Path | str) -> str:
"""媒体文件所在的作者/作品子目录(相对 temp 根、去掉平台层),供归档复用"""
parts = _temp_rel_parts(file_path)
return "/".join(parts[:-1]) if len(parts) > 1 else ""
def parse_netscape_cookies(file_path: str) -> List[dict]:
@@ -0,0 +1,176 @@
"""视频解析 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 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 require_admin
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} 条任务")
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), "")
+40 -11
View File
@@ -13,7 +13,8 @@
"@heroui/styles": "^3.2.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
"react-router-dom": "^7.18.3",
"yet-another-react-lightbox": "^3.32.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
@@ -30,6 +31,7 @@
"resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.47.3.tgz",
"integrity": "sha512-tWZG59+xTbXIqeZB4uyuo2qljk3nRK66IkdBmMgbl2p6peApkKbrtJ/YOM8rW4gemdmv7qoJlbIHvy8aJkYsCQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@internationalized/date": "^3.12.3",
"@react-types/shared": "^3.36.1",
@@ -83,7 +85,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.2",
@@ -96,7 +97,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -106,7 +106,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/icu-skeleton-parser": "1.8.16",
@@ -118,7 +117,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"tslib": "^2.8.0"
@@ -129,7 +127,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -204,7 +201,6 @@
"resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.1.10.tgz",
"integrity": "sha512-nc0Or6EdWHqZRcsXb6P9hBIpLsfSl/ILh0rk5h/OVBpzmhdExXtPy2cQtWsq8XKRBpRHwDNnAHt4OpolcB7dog==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.0",
"intl-messageformat": "^10.1.0"
@@ -1163,6 +1159,7 @@
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1173,6 +1170,7 @@
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1257,8 +1255,7 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
@@ -1349,7 +1346,6 @@
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/fast-memoize": "2.2.7",
@@ -1697,6 +1693,7 @@
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1749,6 +1746,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1758,6 +1756,7 @@
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
@@ -1779,6 +1778,7 @@
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/string": "^3.2.10",
@@ -1798,6 +1798,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -1974,7 +1975,8 @@
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.3",
@@ -2051,6 +2053,7 @@
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
@@ -2383,6 +2386,32 @@
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/yet-another-react-lightbox": {
"version": "3.32.2",
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.2.tgz",
"integrity": "sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/igordanchenko"
},
"peerDependencies": {
"@types/react": "^16 || ^17 || ^18 || ^19",
"@types/react-dom": "^16 || ^17 || ^18 || ^19",
"react": "^16.8.0 || ^17 || ^18 || ^19",
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
}
}
}
+2 -1
View File
@@ -14,7 +14,8 @@
"@heroui/styles": "^3.2.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
"react-router-dom": "^7.18.3",
"yet-another-react-lightbox": "^3.32.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
+95 -7
View File
@@ -1,11 +1,17 @@
import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Avatar, Button, Separator, Spinner } from '@heroui/react'
import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons'
import {
Avatar, Button, Dropdown, Modal, Spinner, toast,
} from '@heroui/react'
import {
ArrowRightFromSquare, Bars, ChevronDown, FileText, Gear, House, Power, Rocket,
ArrowRotateLeft,
} from '@gravity-ui/icons'
import { useAuth } from '../auth/auth'
import { hubPlugins } from '../api/client'
import { hubBotAction, hubPlugins } from '../api/client'
import { ErrorBoundary } from '../components/ErrorBoundary'
import { useConnection } from '../connection'
import { BOT_ACTION_TEXT, watchBotReconnect } from '../lib/botAction'
interface NavItem { id: string; label: string; description: string; icon: any }
@@ -198,10 +204,10 @@ export function AppLayout() {
{status}
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
</span>
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="hidden text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
<AccountMenu
username={username}
onLogout={() => { logout(); navigate('/hub/login') }}
/>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
@@ -218,3 +224,85 @@ export function AppLayout() {
</div>
)
}
/** 顶栏账号菜单:头像 + 用户名点开下拉,重启/关闭 bot 与退出登录都收进去。
*
* 重启/关闭都是不可逆的破坏性操作(关掉之后得去服务器手动拉起),
* 所以先弹确认框再发请求;重启还会复用 `watchBotReconnect` 等回连。
*/
function AccountMenu({ username, onLogout }: { username: string; onLogout: () => void }) {
const [pending, setPending] = useState<'stop' | 'restart' | null>(null)
const [busy, setBusy] = useState(false)
const reconnectRef = useRef<(() => void) | null>(null)
// 布局卸载(登出/跳转)时别把等待回连的 SSE 与定时器留在外面
useEffect(() => () => { reconnectRef.current?.() }, [])
const confirm = async () => {
const action = pending
if (!action) return
setBusy(true)
try {
await hubBotAction(action)
setPending(null)
if (action === 'stop') {
toast.warning('已请求关闭 bot;需要时在服务器上重新启动')
return
}
toast.success('已请求重启 bot,等待回连…')
reconnectRef.current?.()
reconnectRef.current = watchBotReconnect({
onReady: () => { reconnectRef.current = null; toast.success('bot 已重启并回连') },
onTimeout: () => { reconnectRef.current = null; toast.danger('等待回连超时,请检查 bot 是否已启动') },
})
} catch (e: any) {
toast.danger(e.message || '操作失败')
} finally { setBusy(false) }
}
return (
<>
<Dropdown>
<Dropdown.Trigger className="flex cursor-pointer items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-slate-100">
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="hidden max-w-24 truncate text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
<ChevronDown className="size-4 shrink-0 text-slate-400" />
</Dropdown.Trigger>
<Dropdown.Popover>
<Dropdown.Menu
onAction={(key) => {
if (key === 'logout') onLogout()
else setPending(key === 'stop' ? 'stop' : 'restart')
}}
>
<Dropdown.Item id="restart">
<span className="flex items-center gap-2"><ArrowRotateLeft className="size-4" />重启 bot</span>
</Dropdown.Item>
<Dropdown.Item id="stop">
<span className="flex items-center gap-2"><Power className="size-4" />关闭 bot</span>
</Dropdown.Item>
<Dropdown.Item id="logout">
<span className="flex items-center gap-2"><ArrowRightFromSquare className="size-4" />退出登录</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
<Modal isOpen={pending !== null} onOpenChange={(open) => { if (!open) setPending(null) }}>
<Modal.Backdrop><Modal.Container><Modal.Dialog>
<Modal.Header><Modal.Heading>{pending ? BOT_ACTION_TEXT[pending].title : ''}</Modal.Heading></Modal.Header>
<Modal.Body>
<p className="text-sm text-slate-600">{pending ? BOT_ACTION_TEXT[pending].confirm : ''}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setPending(null)}>取消</Button>
<Button isDisabled={busy} className={pending === 'stop' ? 'text-red-500' : ''} onPress={confirm}>
{busy ? '执行中…' : '确定'}
</Button>
</Modal.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal>
</>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { hubDashboardStream } from '../api/client'
/** 请求停止/重启 bot 后,等它回连的那段等待逻辑(设置页与顶栏账号菜单共用)。
*
* 重启分两步回来:旧进程先退出(让出端口),新进程才起来,所以先干等一会儿
* 再开始探活;探活以 dashboard SSE **真正收到一帧**为准 —— 只看 HTTP 200 会在
* 「端口起来了但插件还没挂接」时就放行,紧接着的请求全 404。
*
* 返回 dispose:重复触发或组件卸载时调用,内部会撤掉 SSE 与超时定时器。
*/
export function watchBotReconnect(opts: {
onReady: () => void
onTimeout: () => void
/** 等旧进程退出的时间 */
startupDelayMs?: number
/** 探活总超时 */
timeoutMs?: number
}): () => void {
const { onReady, onTimeout, startupDelayMs = 4000, timeoutMs = 60000 } = opts
let stopped = false
let timer: number | null = null
let disposeStream: (() => void) | null = null
const finish = () => {
stopped = true
if (timer != null) { window.clearTimeout(timer); timer = null }
if (disposeStream) { disposeStream(); disposeStream = null }
}
timer = window.setTimeout(() => {
if (stopped) return
disposeStream = hubDashboardStream({
onData: (d) => {
if (stopped) return
if (d && d.ok !== false) { finish(); onReady() }
},
})
timer = window.setTimeout(() => {
if (stopped) return
finish()
onTimeout()
}, timeoutMs)
}, startupDelayMs)
return finish
}
/** 菜单/按钮上共用的文案,避免两处写法漂移 */
export const BOT_ACTION_TEXT = {
restart: { title: '重启 bot', confirm: '确定重启 bot 进程吗?将先拉起新进程再退出旧进程,期间 Web 与 QQ 连接会短暂断开。' },
stop: { title: '关闭 bot', confirm: '确定关闭当前 bot 进程吗?Web 管理台与 QQ 连接都会断开,需要在服务器上手动重新启动。' },
} as const
+184 -68
View File
@@ -1,4 +1,6 @@
import { Avatar, Card, Spinner } from '@heroui/react'
import { useState } from 'react'
import { Avatar, Button, Card, Spinner } from '@heroui/react'
import { Copy, CopyCheck, Eye, EyeSlash } from '@gravity-ui/icons'
import { useConnection } from '../../connection'
import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format'
@@ -75,6 +77,13 @@ interface BackendInfo {
driver: string
driver_type: string
}
interface EndpointInfo {
host: string
port: number | null
ws_path: string
access_token: string
ips: string[]
}
interface DashboardData {
ok: boolean
bots: BotInfo[]
@@ -85,6 +94,68 @@ interface DashboardData {
disk: DiskInfo[]
network: NetworkInfo
backend: BackendInfo
endpoint: EndpointInfo
}
/** 复制的兜底:非 HTTPS/localhost 下 navigator.clipboard 不可用 */
async function copyText(text: string) {
try { await navigator.clipboard.writeText(text); return true }
catch {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select()
const ok = document.execCommand('copy'); ta.remove(); return ok
}
}
function CopyBtn({ text, label }: { text: string; label?: string }) {
const [done, setDone] = useState(false)
const onCopy = async () => {
if (!text || !(await copyText(text))) return
setDone(true); window.setTimeout(() => setDone(false), 1500)
}
return (
<Button size="sm" variant="tertiary" isDisabled={!text} onPress={onCopy} aria-label={label || '复制'}>
<span className="inline-flex items-center gap-1.5">
{done ? <CopyCheck className="size-4 text-emerald-600" /> : <Copy className="size-4" />}
{label ? <span>{done ? '已复制' : label}</span> : null}
</span>
</Button>
)
}
/** 协议端配对信息行:左标签右值 + 复制按钮 */
function EndpointRow({ label, value, copy }: { label: string; value: string; copy: string }) {
return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="shrink-0 text-xs text-slate-400">{label}</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-mono text-sm font-medium text-slate-700 text-right">{value || '—'}</span>
<CopyBtn text={copy} />
</div>
</div>
)
}
/** 访问令牌:默认打码,可显示后复制(协议端配对需要填) */
function TokenRow({ token }: { token: string }) {
const [show, setShow] = useState(false)
if (!token) return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="shrink-0 text-xs text-slate-400">访问令牌</span>
<span className="text-right text-xs text-slate-400">未设置(协议端无需 Authorization)</span>
</div>
)
return (
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="shrink-0 text-xs text-slate-400">访问令牌</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-mono text-sm font-medium text-slate-700">{show ? token : '•'.repeat(8)}</span>
<Button size="sm" variant="tertiary" onPress={() => setShow(s => !s)} aria-label={show ? '隐藏令牌' : '显示令牌'}>
{show ? <EyeSlash className="size-4" /> : <Eye className="size-4" />}
</Button>
<CopyBtn text={token} />
</div>
</div>
)
}
function Bar({ value, color }: { value: number; color?: string }) {
@@ -134,84 +205,129 @@ export default function HomePage() {
const net = data?.network
const backend = data?.backend
// 协议端配对:优先取本机局域网 IP(0.0.0.0 是监听地址,不是可连接地址)
const ep = data?.endpoint
const bindHost = ep?.host || ''
const lanIps = ep?.ips || []
const ip = lanIps[0] || (bindHost && bindHost !== '0.0.0.0' && bindHost !== '::' ? bindHost : '')
const port = ep?.port ?? null
const wsPath = ep?.ws_path || ''
const token = ep?.access_token || ''
const otherIps = lanIps.slice(1)
const wsUrl = ip && port && wsPath ? `ws://${ip}:${port}${wsPath}` : ''
const bindHint = data && bindHost && bindHost !== '0.0.0.0' && bindHost !== '::'
? `监听地址为 ${bindHost},仅本机可连;把 .env 的 HOST 改成 0.0.0.0 并重启后可对局域网开放`
: ''
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
<p className="text-sm text-slate-400">主机与 BOT 综合状态(实时刷新)</p>
<p className="text-sm text-slate-400">主机与 BOT 综合状态</p>
</div>
{error && !data && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
{/* 顶部:BOT 协议端信息 + BOT 后端信息 一行两列 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2 items-stretch">
{/* BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
<Card className="border border-slate-200 bg-white h-full">
<Card.Header>
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
</Card.Header>
<Card.Content>
{!data ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : !data.bots.length ? (
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
) : (
<div className="space-y-2">
{data.bots.map(bot => (
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
<Avatar size="sm">
<Avatar.Image src={bot.avatar} alt={bot.nick} />
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
{/* 顶部:BOT 协议端信息(左半登录账号 / 右半 NoneBot 接入信息,供协议端配对) */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<SectionTitle title="BOT 协议端信息" desc="当前登录账号与配对协议信息" />
</Card.Header>
<Card.Content>
<div className="grid gap-5 lg:grid-cols-2">
{/* 左半:登录账号昵称 / 账号 / 适配器类型 */}
<div>
<div className="mb-2 text-sm font-medium text-slate-700">登录账号</div>
{!data ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : !data.bots.length ? (
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
) : (
<div className="space-y-2">
{data.bots.map(bot => (
<div key={bot.self_id} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-slate-100 px-4 py-3">
<Avatar size="sm">
<Avatar.Image src={bot.avatar} alt={bot.nick} />
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
<span className="inline-flex items-center gap-1.5 text-xs">
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
</span>
</div>
</div>
<div className="flex items-center gap-3">
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
<span className="inline-flex items-center gap-1.5 text-xs">
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
</span>
</div>
</div>
))}
</div>
)}
</Card.Content>
</Card>
{/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
<Card className="border border-slate-200 bg-white h-full">
<Card.Header>
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">NoneBot 版本</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">驱动方式</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">装载插件数</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">Python</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
</div>
))}
</div>
)}
</div>
</Card.Content>
</Card>
</div>
{/* 右半:NoneBot 接入信息(IP / 端口 / WS 路径 / 令牌,均可一键复制) */}
<div className="lg:border-l lg:border-slate-100 lg:pl-5">
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<span className="text-sm font-medium text-slate-700">协议端接入信息</span>
<CopyBtn text={wsUrl} label="复制" />
</div>
<div className="space-y-1.5">
<EndpointRow label="IP 地址" value={ip} copy={ip} />
<EndpointRow label="运行端口" value={port != null ? String(port) : ''} copy={port != null ? String(port) : ''} />
<EndpointRow label="WS 路径" value={wsPath} copy={wsPath} />
<TokenRow token={token} />
</div>
{otherIps.length > 0 && (
<p className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-slate-400">
其它网卡:
{otherIps.map(x => (
<button key={x} onClick={() => copyText(x)} title="点击复制"
className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-500 hover:bg-slate-200">{x}</button>
))}
</p>
)}
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2">
<div className="text-[11px] text-slate-400">反向 WebSocket 地址(协议端侧填写)</div>
<div className="truncate font-mono text-xs text-slate-600" title={wsUrl}>{wsUrl || '—'}</div>
</div>
{bindHint && <p className="mt-2 text-xs text-amber-600">{bindHint}</p>}
</div>
</div>
</Card.Content>
</Card>
{/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">NoneBot 版本</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">驱动方式</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">装载插件数</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">Python</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
</div>
</div>
</Card.Content>
</Card>
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
+86 -67
View File
@@ -281,6 +281,81 @@ export default function PluginsPage() {
return ov ? ov.enabled : p.enabled
}
// 分类:带 Web 页面的在最上,仅带配置的居中,两者都无的在最下
// (同时有 Web 与配置的归入第一类,配置入口在卡片里照常显示)
const sections: { key: string; title: string; hint: string; items: CatalogPlugin[] }[] = [
{ key: 'web', title: '带 Web 页面', hint: '有独立管理页面', items: plugins.filter(p => p.has_web) },
{ key: 'config', title: '带配置项', hint: '无独立页面,可打开「配置」编辑', items: plugins.filter(p => !p.has_web && p.has_config) },
{ key: 'plain', title: '无 Web 页面与配置项', hint: '仅支持全局 / 分群开关', items: plugins.filter(p => !p.has_web && !p.has_config) },
]
const renderPlugin = (p: CatalogPlugin) => {
const on = p.enabled
return (
<Card key={p.id} className="border border-slate-200 bg-white transition-colors hover:border-slate-300">
<Card.Content>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{p.name}</span>
{p.type && (
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (p.type === 'library' ? 'bg-violet-50 text-violet-600' : 'bg-sky-50 text-sky-600')}>
{p.type === 'library' ? 'lib' : 'app'}
</span>
)}
{p.has_web && <span className="inline-block rounded-full bg-indigo-50 text-indigo-600 text-xs px-2 py-0.5">Web</span>}
{p.has_config && <span className="inline-block rounded-full bg-amber-50 text-amber-600 text-xs px-2 py-0.5">配置</span>}
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (on ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-400')}>{on ? '启用' : '停用'}</span>
</div>
<div className="mt-1 text-xs text-slate-400">{p.id}</div>
<div className="mt-1 text-xs text-slate-500">{p.description || '—'}</div>
</div>
<div className="flex flex-wrap items-center gap-4">
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
{p.has_web && p.web_path && <Button size="sm" onPress={() => navigate(p.web_path!)}>打开 Web 页面</Button>}
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
<Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger>
<Select.Popover><ListBox>
{CHAT_OPTIONS.map(o => <ListBox.Item key={o.value} id={o.value}>{o.label}</ListBox.Item>)}
</ListBox></Select.Popover>
</Select>
<Dropdown>
<Dropdown.Trigger className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-800">
分群控制
</Dropdown.Trigger>
<Dropdown.Popover className="p-2" placement="bottom end">
<div className="w-64 max-h-72 overflow-y-auto space-y-1">
<div className="flex items-center justify-between px-1 py-1">
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
<Button size="sm" variant="tertiary" className="text-xs text-slate-500" isDisabled={!Object.keys(p.groups).length} onPress={() => resetAllGroups(p)}>恢复全部</Button>
</div>
{!groups.length && <div className="px-1 py-2 text-xs text-slate-400">未获取到群列表</div>}
{groups.map(g => {
const eff = effectiveGroupEnabled(p, g.group_id)
return (
<div key={g.group_id} className="flex items-center gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
<Switch size="sm" isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control><span className="text-xs text-slate-700">{g.group_name || g.group_id}</span></Switch.Content>
</Switch>
</div>
)
})}
</div>
</Dropdown.Popover>
</Dropdown>
<Switch isSelected={on} onChange={(v) => toggleGlobal(p, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>全局启用</Switch.Content>
</Switch>
</div>
</div>
</Card.Content>
</Card>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -295,73 +370,17 @@ export default function PluginsPage() {
{loading && !plugins.length && <div className="flex justify-center py-10"><Spinner /></div>}
{!loading && !plugins.length && !error && <p className="text-sm text-slate-400">未发现可管理的插件</p>}
<div className="space-y-3">
{plugins.map(p => {
const on = p.enabled
return (
<Card key={p.id} className="border border-slate-200 bg-white transition-colors hover:border-slate-300">
<Card.Content>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{p.name}</span>
{p.type && (
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (p.type === 'library' ? 'bg-violet-50 text-violet-600' : 'bg-sky-50 text-sky-600')}>
{p.type === 'library' ? 'lib' : 'app'}
</span>
)}
{p.has_web && <span className="inline-block rounded-full bg-indigo-50 text-indigo-600 text-xs px-2 py-0.5">Web</span>}
{p.has_config && <span className="inline-block rounded-full bg-amber-50 text-amber-600 text-xs px-2 py-0.5">配置</span>}
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (on ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-400')}>{on ? '启用' : '停用'}</span>
</div>
<div className="mt-1 text-xs text-slate-400">{p.id}</div>
<div className="mt-1 text-xs text-slate-500">{p.description || '—'}</div>
</div>
<div className="flex flex-wrap items-center gap-4">
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
{p.has_web && p.web_path && <Button size="sm" onPress={() => navigate(p.web_path!)}>打开 Web 页面</Button>}
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
<Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger>
<Select.Popover><ListBox>
{CHAT_OPTIONS.map(o => <ListBox.Item key={o.value} id={o.value}>{o.label}</ListBox.Item>)}
</ListBox></Select.Popover>
</Select>
<Dropdown>
<Dropdown.Trigger className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-800">
分群控制
</Dropdown.Trigger>
<Dropdown.Popover className="p-2" placement="bottom end">
<div className="w-64 max-h-72 overflow-y-auto space-y-1">
<div className="flex items-center justify-between px-1 py-1">
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
<Button size="sm" variant="tertiary" className="text-xs text-slate-500" isDisabled={!Object.keys(p.groups).length} onPress={() => resetAllGroups(p)}>恢复全部</Button>
</div>
{!groups.length && <div className="px-1 py-2 text-xs text-slate-400">未获取到群列表</div>}
{groups.map(g => {
const eff = effectiveGroupEnabled(p, g.group_id)
return (
<div key={g.group_id} className="flex items-center gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
<Switch size="sm" isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control><span className="text-xs text-slate-700">{g.group_name || g.group_id}</span></Switch.Content>
</Switch>
</div>
)
})}
</div>
</Dropdown.Popover>
</Dropdown>
<Switch isSelected={on} onChange={(v) => toggleGlobal(p, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>全局启用</Switch.Content>
</Switch>
</div>
</div>
</Card.Content>
</Card>
)
})}
<div className="space-y-8">
{sections.map(s => s.items.length > 0 && (
<section key={s.key} className="space-y-3">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 pb-2">
<h3 className="text-sm font-semibold text-slate-700">{s.title}</h3>
<span className="inline-block rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{s.items.length}</span>
<span className="text-xs text-slate-400">{s.hint}</span>
</div>
{s.items.map(renderPlugin)}
</section>
))}
</div>
<Drawer state={configState}>
+17 -42
View File
@@ -3,8 +3,9 @@ import {
Button, Card, Form, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextField, toast, useOverlayState,
} from '@heroui/react'
import {
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave, hubDashboardStream,
hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave,
} from '../../api/client'
import { watchBotReconnect } from '../../lib/botAction'
interface SettingsData {
ok: boolean
@@ -39,8 +40,6 @@ export default function SettingsPage() {
const [pendingAction, setPendingAction] = useState<'stop' | 'restart' | null>(null)
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
const botState = useOverlayState()
const pollRef = useRef(0)
const pollTimerRef = useRef<number | null>(null)
const restartDisposeRef = useRef<(() => void) | null>(null)
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
@@ -77,10 +76,8 @@ export default function SettingsPage() {
useEffect(() => { load() }, [load])
useEffect(() => () => {
if (restartDisposeRef.current) restartDisposeRef.current()
if (pollTimerRef.current != null) window.clearTimeout(pollTimerRef.current)
}, [])
// dispose 内部会一并撤掉 SSE 与超时定时器
useEffect(() => () => { restartDisposeRef.current?.() }, [])
const changePassword = async () => {
if (!oldPassword) { toast.warning('请输入原密码'); return }
@@ -130,44 +127,22 @@ export default function SettingsPage() {
botState.open()
}
const clearPollTimer = () => {
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
}
const pollForReconnect = async () => {
const id = ++pollRef.current
// 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪)
await new Promise(res => setTimeout(res, 4000))
if (pollRef.current !== id) return
if (restartDisposeRef.current) restartDisposeRef.current()
// 以 dashboard SSE 真正收到数据作为「后端已就绪」信号,避免仅 HTTP 起来但插件未挂接就放行
const dispose = hubDashboardStream({
onData: (d) => {
if (pollRef.current !== id) return
if (d && d.ok !== false) {
restartDisposeRef.current = null
dispose()
clearPollTimer()
pollRef.current += 1 // 让旧定时器即使未被清除也不会误判超时
setRestarting(null)
toast.success('bot 已重启并回连')
load()
}
},
})
restartDisposeRef.current = dispose
pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null
if (pollRef.current === id) {
const pollForReconnect = () => {
// 等待逻辑与顶栏账号菜单共用一份(lib/botAction.ts),别在两边各写一套
restartDisposeRef.current?.()
restartDisposeRef.current = watchBotReconnect({
onReady: () => {
restartDisposeRef.current = null
setRestarting(null)
toast.success('bot 已重启并回连')
load()
},
onTimeout: () => {
restartDisposeRef.current = null
dispose()
setRestarting(null)
toast.danger('等待回连超时,请检查 bot 是否已启动')
}
}, 60000)
},
})
}
const confirmBotAction = async () => {
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button, Card, Drawer, Input, Label, ListBox, Select, Spinner, TextField, toast, useOverlayState,
} from '@heroui/react'
import { Pencil, Plus, Signal, TrashBin } from '@gravity-ui/icons'
import { api, hubGroups } from '../../api/client'
import { PageHead, selectCls, tdCls, thCls } from '../../components/ui'
// 服务器数据的增删改(后端 hexi/plugins/nonebot_plugin_mc_server_status/web_hub.py,
// 和群里的「添加服务器 / 删除服务器」命令改的是同一份数据)
const mcs = api('mc_server_status')
function data(res: any) {
if (res && typeof res === 'object' && typeof res.status === 'number') {
if (res.status !== 0) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
interface ServerRow { name: string; address: string; type: string }
interface GroupRow {
group_id: string
group_name: string
online: boolean
servers: ServerRow[]
}
interface Overview {
groups: GroupRow[]
types: Record<string, string>
address_hint: string
bot_online: boolean
}
interface GroupOption { group_id: number; group_name: string }
interface Draft {
group_id: string
/** 原服务器名:编辑时为改名前的名字,新增时为空 */
old_name: string
name: string
address: string
type: string
}
const TYPE_LABEL: Record<string, string> = { js: 'Java 版', bds: '基岩版' }
// 跟后端 services/servers.py 的 SERVER_TYPES 对齐;接口没回来时用它兜底
const FALLBACK_TYPES = ['js', 'bds']
const EMPTY = '-'
const typeLabel = (t: string) => TYPE_LABEL[t] || t
export default function McServerStatusPage() {
const [ov, setOv] = useState<Overview | null>(null)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState('')
const [draft, setDraft] = useState<Draft | null>(null)
const [botGroups, setBotGroups] = useState<GroupOption[]>([])
const drawer = useOverlayState()
const load = useCallback(async () => {
setLoading(true); setErr('')
try {
setOv(await data(await mcs.get('overview')))
} catch (e: any) {
setErr(e.message || '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// 群列表只用来给「群号」下拉填名字;bot 没连上时退回手输群号
useEffect(() => { hubGroups().then(d => setBotGroups((d && d.items) || [])).catch(() => setBotGroups([])) }, [])
const types = ov?.types && Object.keys(ov.types).length ? Object.keys(ov.types) : FALLBACK_TYPES
const openNew = (group_id = '') => {
setTestResult('')
setDraft({ group_id, old_name: '', name: '', address: '', type: types[0] || 'js' })
drawer.open()
}
const openEdit = (group_id: string, server: ServerRow) => {
setTestResult('')
setDraft({ group_id, old_name: server.name, name: server.name, address: server.address, type: server.type })
drawer.open()
}
const isNew = !draft?.old_name
const renamed = !!draft && !!draft.old_name && draft.old_name !== draft.name
const save = async () => {
if (!draft) return
if (!/^\d+$/.test(draft.group_id)) { toast.danger('群号必须是纯数字'); return }
setSaving(true)
try {
const res = data(await mcs.post('server', {
group_id: draft.group_id,
old_name: draft.old_name,
name: draft.name,
address: draft.address,
type: draft.type,
}))
toast.success(res?.msg || '已保存')
drawer.close()
await load()
} catch (e: any) { toast.danger(e.message || '保存失败') } finally { setSaving(false) }
}
/** 测试连接:编辑时测存量那台(后端按群号+名字取地址),新增时测当前填的地址,都不落库 */
const testConnection = async () => {
if (!draft) return
setTesting(true); setTestResult('')
try {
const d = data(await mcs.post('test', {
group_id: draft.group_id, name: draft.old_name, address: draft.address, type: draft.type,
}))
const msg = (d && d.message) || ''
setTestResult(msg)
if (d && d.ok) {
const version = msg.match(/版本:(\S+)/)
toast.success(version ? `连上了 · 版本 ${version[1]}` : '连上了')
} else {
toast.danger(msg.split('\n')[0] || '连不上')
}
} catch (e: any) {
setTestResult(e.message || '测试失败')
toast.danger(e.message || '测试失败')
} finally { setTesting(false) }
}
const removeServer = async (group: GroupRow, server: ServerRow) => {
const last = group.servers.length === 1
const tip = last
? `确定删除「${server.name}」?这是 ${group.group_name || group.group_id} 的最后一台,删完这个群也会一并移除。`
: `确定删除「${server.name}」(${server.address})?`
if (!window.confirm(tip)) return
try {
const res = data(await mcs.del(`server/${encodeURIComponent(group.group_id)}/${encodeURIComponent(server.name)}`))
toast.success(res?.msg || '已删除')
await load()
} catch (e: any) { toast.danger(e.message || '删除失败') }
}
const removeGroup = async (group: GroupRow) => {
if (!window.confirm(`确定删除 ${group.group_name || group.group_id} 的全部 ${group.servers.length} 台服务器配置?`)) return
try {
const res = data(await mcs.del(`group/${encodeURIComponent(group.group_id)}`))
toast.success(res?.msg || '已删除')
await load()
} catch (e: any) { toast.danger(e.message || '删除失败') }
}
if (loading && !ov) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<>
<div className="space-y-4">
<PageHead
title="MC服务器"
actions={
<div className="flex items-center gap-3">
<span className="text-sm text-slate-400">按群配置服务器,「信息」命令查状态</span>
<Button size="sm" onPress={() => openNew()}>
<Plus className="size-4" />新增服务器
</Button>
</div>
}
/>
{err && <p className="text-xs text-red-500">{err}</p>}
{ov && !ov.bot_online && (
<p className="text-xs text-amber-600">bot 未连接,群名显示不出来(只显示群号,不影响编辑)</p>
)}
{ov && (
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>
服务器列表
<span className="ml-2 text-xs font-normal text-slate-400">
{ov.groups.length} 个群 · {ov.groups.reduce((n, g) => n + g.servers.length, 0)} 台服务器
</span>
</Card.Title>
</Card.Header>
<Card.Content>
<table className="w-full">
<thead>
<tr>
<th className={thCls}>群</th>
<th className={thCls}>服务器名称</th>
<th className={thCls}>地址</th>
<th className={thCls}>类型</th>
<th className={thCls}></th>
</tr>
</thead>
<tbody>
{ov.groups.map(g => g.servers.map((s, i) => (
<tr key={`${g.group_id}-${s.name}`} className="transition-colors hover:bg-slate-50">
{/* 群号 / 群名只在每组第一行显示,靠 rowSpan 合并不重复刷屏 */}
{i === 0 && (
<td className={tdCls + ' align-top whitespace-nowrap'} rowSpan={g.servers.length}>
<span className="text-slate-700">
{g.group_name ? `${g.group_name}(${g.group_id})` : g.group_id}
</span>
{!g.online && <span className="ml-2 text-xs text-slate-400">不在群列表</span>}
<div className="mt-1 flex gap-2">
<button className="text-xs text-indigo-600 hover:underline" onClick={() => openNew(g.group_id)}>加服务器</button>
<button className="text-xs text-red-500 hover:underline" onClick={() => removeGroup(g)}>删整组</button>
</div>
</td>
)}
<td className={tdCls}>{s.name}</td>
<td className={tdCls + ' font-mono text-xs'}>{s.address || EMPTY}</td>
<td className={tdCls + ' whitespace-nowrap'}>{typeLabel(s.type)}</td>
<td className={tdCls + ' whitespace-nowrap text-right'}>
<Button size="sm" variant="tertiary" onPress={() => openEdit(g.group_id, s)}>
<Pencil className="size-4" />编辑
</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeServer(g, s)}>
<TrashBin className="size-4" />删除
</Button>
</td>
</tr>
)))}
{!ov.groups.length && (
<tr>
<td colSpan={5} className={tdCls + ' text-center text-slate-400'}>
还没有配置任何服务器,点右上角「新增服务器」(群里发「添加服务器」也一样)
</td>
</tr>
)}
</tbody>
</table>
</Card.Content>
</Card>
)}
</div>
<Drawer state={drawer}>
<Drawer.Backdrop>
<Drawer.Content placement="right">
<Drawer.Dialog className="w-[560px] max-w-[88vw]">
<Drawer.Header>
<Drawer.Heading>
{isNew ? '新增服务器' : `编辑「${draft?.old_name}」`}
</Drawer.Heading>
<Drawer.CloseTrigger />
</Drawer.Header>
<Drawer.Body>
{draft && (
<div className="space-y-5">
<TextField>
<Label>群号</Label>
{isNew && botGroups.length > 0 ? (
<Select
selectedKey={draft.group_id}
onSelectionChange={k => k && setDraft({ ...draft, group_id: String(k) })}
>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>
{botGroups.map(g => (
<ListBox.Item key={g.group_id} id={String(g.group_id)}>
{`${g.group_name}(${g.group_id})`}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
) : (
<Input
value={draft.group_id}
onChange={e => setDraft({ ...draft, group_id: e.target.value.trim() })}
disabled={!isNew}
placeholder="872490448"
/>
)}
</TextField>
<TextField>
<Label>服务器名称 <span className="text-xs text-slate-400">群里显示的名字,改这里就是改名</span></Label>
<Input
value={draft.name}
onChange={e => setDraft({ ...draft, name: e.target.value })}
placeholder="EMDS"
/>
</TextField>
<TextField>
<Label>服务器地址 <span className="text-xs text-slate-400">主机:端口</span></Label>
<Input
value={draft.address}
onChange={e => setDraft({ ...draft, address: e.target.value })}
placeholder="mc.example.com:25565"
/>
</TextField>
<p className="-mt-3 text-xs text-slate-400">{ov?.address_hint}</p>
<Select selectedKey={draft.type} onSelectionChange={k => k && setDraft({ ...draft, type: String(k) })}>
<Label>类型</Label>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>
{types.map(t => <ListBox.Item key={t} id={t}>{typeLabel(t)}</ListBox.Item>)}
</ListBox>
</Select.Popover>
</Select>
<p className="-mt-3 text-xs text-slate-400">基岩版不写端口时按默认 19132 查</p>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" variant="tertiary" isDisabled={testing} onPress={testConnection}>
<Signal className="size-4" />{testing ? '测试中…' : '测试连接'}
</Button>
<span className="text-xs text-slate-400">
{isNew ? '测当前填的地址,不落库' : '测已保存的地址(改了地址先保存再测)'}
</span>
</div>
{testResult && (
<pre className="max-h-44 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
{testResult}
</pre>
)}
</div>
)}
</Drawer.Body>
<Drawer.Footer>
<Button variant="tertiary" onPress={drawer.close}>取消</Button>
<Button isDisabled={saving} onPress={save}>
{saving ? '保存中…' : renamed ? '保存(改名)' : '保存'}
</Button>
</Drawer.Footer>
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
</>
)
}
@@ -0,0 +1,881 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button, Card, Chip, Drawer, Input, Label, ListBox, Select, Spinner, Switch, TextArea, TextField,
Tooltip, TooltipContent, toast, useOverlayState,
} from '@heroui/react'
import { ArrowDownToLine, ArrowUpRightFromSquare, CirclePlay, TrashBin, Xmark } from '@gravity-ui/icons'
import Lightbox from 'yet-another-react-lightbox'
import type { LightboxProps, Slide } from 'yet-another-react-lightbox'
import Inline from 'yet-another-react-lightbox/plugins/inline'
import Video from 'yet-another-react-lightbox/plugins/video'
import Captions from 'yet-another-react-lightbox/plugins/captions'
import Counter from 'yet-another-react-lightbox/plugins/counter'
import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import 'yet-another-react-lightbox/styles.css'
import 'yet-another-react-lightbox/plugins/captions.css'
import 'yet-another-react-lightbox/plugins/counter.css'
import 'yet-another-react-lightbox/plugins/thumbnails.css'
import { api, hubGroups } from '../../api/client'
import { PageHead, Tabs, selectCls, tdCls, thCls, type TabItem } from '../../components/ui'
import { fmtBytes, fmtTime } from '../../lib/format'
const va = api('video_analysis')
function data(res: any) {
if (res && typeof res === 'object' && typeof res.status === 'number') {
if (res.status !== 0) throw new Error(res.msg || '请求失败')
return res.data
}
return res
}
interface Policy {
auto: boolean
auto_link: string[]
ban_link: string[]
plan: string
upload_public: boolean
send_link: boolean
upload_group_file: boolean
group_file_platforms: string[]
sends_link?: boolean
}
interface GroupRow { group_id: string; group_name: string; online: boolean; policy: Policy }
interface JobFile {
name: string
kind: 'image' | 'video' | 'file'
size: number
local_url: string
public_url: string
error: string
}
interface WebJob {
id: string
url: string
platform: string | null
title: string
status: string
stage: string
files: JobFile[]
cover_url: string
error: string
created_at: number
updated_at: number
}
interface Overview {
groups: GroupRow[]
default: Policy
blacklist: string[]
platforms: string[]
plans: string[]
online: boolean
}
interface GroupOption { group_id: number; group_name: string }
const EMPTY: Policy = {
auto: false, auto_link: [], ban_link: [], plan: 'C',
upload_public: false, send_link: false, upload_group_file: false,
group_file_platforms: [],
}
const groupFileText = (p: Policy) => {
if (!p.upload_group_file) return '关'
return p.group_file_platforms.length ? `开·仅${p.group_file_platforms.join('/')}` : '开'
}
const onOff = (v: boolean) => (v ? '开' : '关')
// ─────────────────────── 链接解析(web_jobs.py) ───────────────────────
const POLL_MS = 1500
const STATUS_TEXT: Record<string, string> = { queued: '排队中', running: '解析中', done: '完成', failed: '失败' }
const isActive = (j: WebJob) => j.status === 'queued' || j.status === 'running'
// 运行中显示 stage(解析中/上传中),其余按状态表
const jobStatusText = (j: WebJob) => (j.status === 'running' ? j.stage || '解析中' : STATUS_TEXT[j.status] || j.status)
// Chip 的语义色(HeroUI 就这五个:accent/danger/default/success/warning)
const STATUS_COLOR: Record<string, 'accent' | 'danger' | 'default' | 'success' | 'warning'> = {
queued: 'default', running: 'warning', done: 'success', failed: 'danger',
}
async function copyText(text: string) {
try { await navigator.clipboard.writeText(text); return true }
catch {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select()
const ok = document.execCommand('copy'); ta.remove(); return ok
}
}
// 浮层(yet-another-react-lightbox)默认文案是英文,跟页面其它部分对齐一下
// (插件各自往 Labels 上加了键:Zoom in/out 来自 Zoom、Caption 来自 Captions)
const LIGHTBOX_LABELS: LightboxProps['labels'] = {
Previous: '上一张',
Next: '下一张',
Close: '关闭',
Slide: '媒体',
Carousel: '媒体列表',
Lightbox: '媒体预览',
Caption: '文件信息',
'Zoom in': '放大',
'Zoom out': '缩小',
}
// 预览用的 MIME:产物统一 mp4(video_downloader 写死了 merge_output_format),
// 其余几种只是兜底,浏览器放不了的容器会在播放器里显示加载失败,不会白屏
const VIDEO_MIME: Record<string, string> = {
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
mkv: 'video/x-matroska', ts: 'video/mp2t', flv: 'video/x-flv',
}
function videoMime(name: string) {
return VIDEO_MIME[name.split('.').pop()?.toLowerCase() || ''] || 'video/mp4'
}
function toSlide(file: JobFile, src: string): Slide {
const title = `${file.name} · ${fmtBytes(file.size)}`
if (file.kind === 'video') {
return { type: 'video', title, sources: [{ src, type: videoMime(file.name) }] }
}
return { src, alt: file.name, title }
}
type MainTab = 'parse' | 'policy'
const MAIN_TABS: TabItem[] = [
{ key: 'parse', label: '链接解析' },
{ key: 'policy', label: '群策略' },
]
export default function VideoAnalysisPage() {
const [tab, setTab] = useState<MainTab>('parse')
// 页面撑满 main 的内容盒(main = 100vh - 顶栏 4rem - 自己的 padding p-4/p-6),
// 下面的工作区才能靠 flex-1 拿到"剩下的高度"——比写死 calc(100vh - 13rem)
// 这种算法稳:页头和顶栏 tab 改高度也不会把布局撑出滚动条。
return (
<div className="flex h-[calc(100vh-6rem)] flex-col gap-4 md:h-[calc(100vh-7rem)]">
<div className="shrink-0 space-y-4">
<PageHead
title="视频解析"
actions={<span className="text-sm text-slate-400">粘链接解析预览 · 群策略配置</span>}
/>
<Tabs tabs={MAIN_TABS} active={tab} onChange={v => setTab(v as MainTab)} />
</div>
{/* 滚动收在内容区里(群策略那个长表格不再撑动整页) */}
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === 'parse' && <ParseView />}
{tab === 'policy' && <PolicyView />}
</div>
</div>
)
}
/** 群策略:白名单群列表 + 默认策略 + 全局黑名单(原页面主体) */
function PolicyView() {
const [ov, setOv] = useState<Overview | null>(null)
const [botGroups, setBotGroups] = useState<GroupOption[]>([])
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<{ group_id: string; policy: Policy; isNew: boolean } | null>(null)
const [saving, setSaving] = useState(false)
const drawer = useOverlayState()
const load = useCallback(async () => {
setLoading(true); setErr('')
try {
setOv(await data(await va.get('overview')))
} catch (e: any) {
setErr(e.message || '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// 群列表只在「添加群」的下拉里用;bot 未连接时用输入框兜底
useEffect(() => { hubGroups().then(d => setBotGroups((d && d.items) || [])).catch(() => setBotGroups([])) }, [])
const openNew = () => {
setEditing({ group_id: botGroups[0] ? String(botGroups[0].group_id) : '', policy: { ...EMPTY }, isNew: true })
drawer.open()
}
const openEdit = (row: GroupRow) => {
const p = row.policy
setEditing({
group_id: row.group_id,
isNew: false,
policy: {
auto: p.auto, auto_link: [...p.auto_link], ban_link: [...p.ban_link], plan: p.plan,
upload_public: p.upload_public, send_link: p.send_link, upload_group_file: p.upload_group_file,
group_file_platforms: [...p.group_file_platforms],
},
})
drawer.open()
}
const saveGroup = async () => {
if (!editing) return
if (!/^\d+$/.test(editing.group_id)) { toast.danger('群号必须是纯数字'); return }
setSaving(true)
try {
await va.post('group', { group_id: editing.group_id, policy: editing.policy })
toast.success(`群 ${editing.group_id} 策略已保存`)
drawer.close()
await load()
} catch (e: any) { toast.danger(e.message || '保存失败') } finally { setSaving(false) }
}
const removeGroup = async (row: GroupRow) => {
if (!window.confirm(`确定把群 ${row.group_name || row.group_id} 移出白名单?该群将不再解析链接。`)) return
try { await va.del(`group/${row.group_id}`); toast.success('已移出白名单'); await load() } catch (e: any) { toast.danger(e.message) }
}
if (loading && !ov) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-slate-400">
{ov && !ov.online
? <span className="text-amber-600">bot 未连接,群名显示不出来</span>
: '白名单:列表里的群才会解析'}
</p>
<Button size="sm" onPress={openNew}>添加群</Button>
</div>
{err && <p className="text-xs text-red-500">{err}</p>}
{ov && (
<>
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>群策略(白名单:列表里的群才会解析)</Card.Title></Card.Header>
<Card.Content>
<table className="w-full">
<thead>
<tr>
<th className={thCls}>群</th><th className={thCls}>自动解析</th><th className={thCls}>存储</th>
<th className={thCls}>公网</th><th className={thCls}>链接</th><th className={thCls}>群文件</th>
<th className={thCls}>自动策略</th><th className={thCls}>禁用策略</th><th className={thCls}></th>
</tr>
</thead>
<tbody>
{ov.groups.map(r => (
<tr key={r.group_id} className="transition-colors hover:bg-slate-50">
<td className={tdCls}>
{r.group_name ? `${r.group_name}(${r.group_id})` : r.group_id}
{!r.online && <span className="ml-2 text-xs text-slate-400">不在群列表</span>}
</td>
<td className={tdCls}>{onOff(r.policy.auto)}</td>
<td className={tdCls}>{r.policy.plan}</td>
<td className={tdCls}>{onOff(r.policy.upload_public)}</td>
<td className={tdCls}>
{r.policy.sends_link ? '开' : (r.policy.send_link ? '开·无公网' : '关')}
</td>
<td className={tdCls + ' whitespace-nowrap'}>{groupFileText(r.policy)}</td>
<td className={tdCls + ' max-w-[180px] truncate'}>{r.policy.auto_link.join('、') || '—'}</td>
<td className={tdCls + ' max-w-[180px] truncate'}>{r.policy.ban_link.join('、') || '—'}</td>
<td className={tdCls + ' whitespace-nowrap text-right'}>
<Button size="sm" variant="tertiary" onPress={() => openEdit(r)}>编辑</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeGroup(r)}>移除</Button>
</td>
</tr>
))}
{!ov.groups.length && (
<tr><td colSpan={9} className={tdCls + ' text-center text-slate-400'}>还没有配置任何群,点右上角「添加群」</td></tr>
)}
</tbody>
</table>
</Card.Content>
</Card>
<DefaultCard ov={ov} onSaved={load} />
<BlacklistCard ov={ov} onSaved={load} />
</>
)}
<Drawer state={drawer}>
<Drawer.Backdrop>
<Drawer.Content placement="right">
<Drawer.Dialog className="w-[560px] max-w-[88vw]">
<Drawer.Header>
<Drawer.Heading>
{editing?.isNew ? '添加群' : `群 ${editing?.group_id} 策略`}
</Drawer.Heading>
<Drawer.CloseTrigger />
</Drawer.Header>
<Drawer.Body>
{editing && ov && (
<div className="space-y-5">
{editing.isNew && (
<div className="space-y-2">
{botGroups.length > 0 ? (
<Select selectedKey={editing.group_id} onSelectionChange={k => k && setEditing({ ...editing, group_id: String(k) })}>
<Label>选择群</Label>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>
{botGroups.map(g => (
<ListBox.Item key={g.group_id} id={String(g.group_id)}>
{`${g.group_name}(${g.group_id})`}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
) : (
<TextField>
<Label>群号</Label>
<Input value={editing.group_id} onChange={e => setEditing({ ...editing, group_id: e.target.value.trim() })} />
</TextField>
)}
</div>
)}
<PolicyEditor
value={editing.policy}
platforms={ov.platforms}
plans={ov.plans}
onChange={p => setEditing({ ...editing, policy: p })}
/>
</div>
)}
</Drawer.Body>
<Drawer.Footer>
<Button variant="tertiary" onPress={drawer.close}>取消</Button>
<Button isDisabled={saving} onPress={saveGroup}>{saving ? '保存中…' : '保存'}</Button>
</Drawer.Footer>
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
</>
)
}
function PolicyEditor({
value, platforms, plans, onChange,
}: {
value: Policy
platforms: string[]
plans: string[]
onChange: (p: Policy) => void
}) {
const set = (patch: Partial<Policy>) => onChange({ ...value, ...patch })
return (
<div className="space-y-5">
<Switch isSelected={value.auto} onChange={v => set({ auto: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>自动解析(群里出现链接就解析,无需 @bot)</Switch.Content>
</Switch>
<div className="grid grid-cols-2 items-end gap-4">
<div>
<Select selectedKey={value.plan} onSelectionChange={k => k && set({ plan: String(k) })}>
<Label>存储策略</Label>
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>{plans.map(p => <ListBox.Item key={p} id={p}>{`局域网 ${p}`}</ListBox.Item>)}</ListBox>
</Select.Popover>
</Select>
</div>
<Switch isSelected={value.upload_public} onChange={v => set({ upload_public: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>上传公网</Switch.Content>
</Switch>
</div>
<div className="space-y-2">
<p className="text-sm text-slate-600">自动策略 <span className="text-xs text-slate-400">(命中平台即使关掉自动解析也会解析)</span></p>
<PlatformChips value={value.auto_link} platforms={platforms} tone="auto" onChange={v => set({ auto_link: v })} />
</div>
<div className="space-y-2">
<p className="text-sm text-slate-600">禁用策略 <span className="text-xs text-slate-400">(命中平台自动/手动都不解析)</span></p>
<PlatformChips value={value.ban_link} platforms={platforms} tone="ban" onChange={v => set({ ban_link: v })} />
</div>
<div className="space-y-3">
<Switch isSelected={value.send_link} isDisabled={!value.upload_public} onChange={v => set({ send_link: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>发送下载链接</Switch.Content>
</Switch>
{!value.upload_public && <p className="text-xs text-slate-400">没上传公网时链接指向局域网 S3,群友打不开,所以这一项需要先开「上传公网」</p>}
<Switch isSelected={value.upload_group_file} onChange={v => set({ upload_group_file: v })}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>同时上传群文件</Switch.Content>
</Switch>
{value.upload_group_file && (
<div className="space-y-2 pl-1">
<p className="text-xs text-slate-500">
限定平台 <span className="text-slate-400">(一个都不选 = 所有平台都传;选了则其余平台只发消息、不传群文件)</span>
</p>
<PlatformChips
value={value.group_file_platforms}
platforms={platforms}
tone="file"
onChange={v => set({ group_file_platforms: v })}
/>
</div>
)}
</div>
</div>
)
}
function PlatformChips({
value, platforms, tone, onChange,
}: {
value: string[]
platforms: string[]
tone: 'auto' | 'ban' | 'file'
onChange: (v: string[]) => void
}) {
const activeCls = tone === 'ban'
? 'bg-red-50 text-red-600 border-red-300'
: tone === 'file'
? 'bg-indigo-50 text-indigo-700 border-indigo-300'
: 'bg-emerald-50 text-emerald-700 border-emerald-300'
return (
<div className="flex flex-wrap gap-1.5">
{platforms.map(p => {
const active = value.includes(p)
return (
<button
key={p}
type="button"
onClick={() => onChange(active ? value.filter(x => x !== p) : [...value, p])}
className={'rounded-full border px-3 py-1 text-xs transition-colors ' + (active ? activeCls : 'border-slate-200 bg-white text-slate-500 hover:border-slate-300')}
>
{p}
</button>
)
})}
</div>
)
}
function DefaultCard({ ov, onSaved }: { ov: Overview; onSaved: () => Promise<void> }) {
const [policy, setPolicy] = useState<Policy>(ov.default)
const [busy, setBusy] = useState(false)
useEffect(() => { setPolicy(ov.default) }, [ov.default])
const save = async () => {
setBusy(true)
try { await va.post('default', { policy }); toast.success('默认策略已保存'); await onSaved() }
catch (e: any) { toast.danger(e.message || '保存失败') } finally { setBusy(false) }
}
return (
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>默认策略 <span className="ml-2 text-xs font-normal text-slate-400">私聊与未配置群使用</span></Card.Title>
</Card.Header>
<Card.Content className="space-y-4">
<PolicyEditor value={policy} platforms={ov.platforms} plans={ov.plans} onChange={setPolicy} />
<Button isDisabled={busy} onPress={save}>{busy ? '保存中…' : '保存默认策略'}</Button>
</Card.Content>
</Card>
)
}
function BlacklistCard({ ov, onSaved }: { ov: Overview; onSaved: () => Promise<void> }) {
const [text, setText] = useState(ov.blacklist.join('\n'))
const [busy, setBusy] = useState(false)
useEffect(() => { setText(ov.blacklist.join('\n')) }, [ov.blacklist])
const save = async () => {
const list = text.split(/[\n,,\s]+/).map(x => x.trim()).filter(Boolean)
setBusy(true)
try { await va.post('blacklist', { blacklist: list }); toast.success('黑名单已保存'); await onSaved() }
catch (e: any) { toast.danger(e.message || '保存失败') } finally { setBusy(false) }
}
return (
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>全局黑名单 <span className="ml-2 text-xs font-normal text-slate-400">这些 QQ 在所有群都不解析</span></Card.Title>
</Card.Header>
<Card.Content className="space-y-3">
<TextField>
<Label>QQ 号(每行一个)</Label>
<TextArea rows={4} value={text} onChange={e => setText(e.target.value)} placeholder="123456789" />
</TextField>
<Button isDisabled={busy} onPress={save}>{busy ? '保存中…' : '保存黑名单'}</Button>
</Card.Content>
</Card>
)
}
/** 链接解析工作区:左栏「输入 + 任务列表」,右栏播放器。
*
* 存储按**默认策略**走(不是某个群的策略),所以这里没有群号可选。
* 任务表在 bot 内存里,重启即清空 —— 前端按"查不到就算了"处理。
*/
function ParseView() {
const [text, setText] = useState('')
const [force, setForce] = useState(false)
const [jobs, setJobs] = useState<WebJob[]>([])
const [busy, setBusy] = useState(false)
const [playingId, setPlayingId] = useState('')
const [refreshing, setRefreshing] = useState('')
const pollRef = useRef(0)
const timerRef = useRef<number | null>(null)
function stopTimer() {
if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null }
}
// setTimeout 递归轮询(不用 setInterval:请求慢时不会堆叠)
function schedule(list: WebJob[]) {
stopTimer()
if (!list.some(isActive)) return // 全部终态 → 停
const id = pollRef.current
timerRef.current = window.setTimeout(() => { if (pollRef.current === id) void tick(++pollRef.current) }, POLL_MS)
}
async function tick(id: number) {
try {
const d = data(await va.get('jobs'))
if (pollRef.current !== id) return // 代际不符:期间又发起了新的一轮
const list: WebJob[] = (d && d.jobs) || []
setJobs(list)
schedule(list)
} catch (e: any) {
if (pollRef.current !== id) return
toast.danger(e.message || '任务列表加载失败')
}
}
useEffect(() => {
void tick(++pollRef.current)
return () => { pollRef.current += 1; stopTimer() }
}, [])
const submit = async () => {
if (!text.trim()) { toast.danger('先粘一条链接'); return }
setBusy(true)
try {
const d = data(await va.post('parse', { text, force }))
toast.success(`已提交 ${((d && d.jobs) || []).length} 个任务`)
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '提交失败')
} finally { setBusy(false) }
}
const refreshJob = async (id: string) => {
setRefreshing(id)
try {
await va.post(`jobs/${id}/refresh`, {})
toast.success('链接已刷新')
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '刷新失败')
} finally { setRefreshing('') }
}
const removeJob = async (id: string) => {
try {
await va.del(`jobs/${id}`)
if (playingId === id) setPlayingId('') // 正在看的那条被清了 → 播放器回到空状态
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '清理失败')
}
}
const clearFinished = async () => {
try {
const d = data(await va.post('jobs/clear', {}))
toast.success(`已清理 ${(d && d.removed) || 0} 条任务`)
await tick(++pollRef.current)
} catch (e: any) {
toast.danger(e.message || '清理失败')
}
}
const playing = jobs.find(j => j.id === playingId) || null
return (
<div className="space-y-4 lg:grid lg:h-full lg:grid-cols-3 lg:items-stretch lg:gap-4 lg:space-y-0">
{/* 左栏:输入框 + 任务列表(列表自己滚动,输入框固定在顶部);整页 1/3 : 2/3 */}
<div className="flex min-h-0 flex-col gap-4 lg:col-span-1">
<Card className="shrink-0 border border-slate-200 bg-white">
<Card.Content className="space-y-3">
<TextField>
<Label>链接(可多行粘贴,自动挑出其中的 URL)</Label>
<TextArea
rows={4}
value={text}
onChange={e => setText(e.target.value)}
placeholder={'https://v.douyin.com/xxxxxxx\nhttps://www.bilibili.com/video/BVxxxxxxxx'}
/>
</TextField>
<div className="flex flex-wrap items-center gap-3">
<Button isDisabled={busy} onPress={submit}>{busy ? '解析中…' : '解析'}</Button>
<Button variant="tertiary" isDisabled={!text} onPress={() => setText('')}>清空</Button>
<Switch isSelected={force} onChange={v => setForce(v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>强制重解析</Switch.Content>
</Switch>
</div>
</Card.Content>
</Card>
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden border border-slate-200 bg-white">
{/* Card.Header 默认是「竖向 + 居中」的列,这里要标题和按钮同一行(flex-row 得显式写) */}
<Card.Header className="flex shrink-0 flex-row flex-wrap items-center gap-2">
<Card.Title className="min-w-0 flex-1">
任务列表
<span className="ml-2 text-xs font-normal text-slate-400">
同一条链接在跑时复用同一个任务 · {jobs.length} 条
</span>
</Card.Title>
{jobs.some(j => !isActive(j)) && (
<Button size="sm" variant="tertiary" onPress={clearFinished}>清理已完成</Button>
)}
</Card.Header>
<Card.Content className="min-h-0 flex-1 space-y-2 overflow-y-auto">
{jobs.map(j => (
<TaskCard
key={j.id}
job={j}
playing={j.id === playingId}
onPlay={() => setPlayingId(j.id)}
onDownload={() => downloadFiles(j)}
onRemove={() => removeJob(j.id)}
/>
))}
{!jobs.length && (
<p className="py-8 text-center text-xs text-slate-400">
还没有任务。任务表在内存里,bot 重启后会清空,重新解析即可。
</p>
)}
</Card.Content>
</Card>
</div>
{/* 右栏:播放器 */}
<div className="flex min-h-0 flex-col lg:col-span-2">
<PlayerPanel
job={playing}
refreshing={!!playing && refreshing === playing.id}
onRefresh={() => playing && refreshJob(playing.id)}
onClose={() => setPlayingId('')}
/>
</div>
</div>
)
}
/** 任务卡:缩略图 + 标题/状态 + 三个动作(播放 / 下载 / 打开作品链接)。
*
* 列表在 380px 的窄栏里,三个按钮只放图标(文案进 title/aria-label),
* 要完整文案的话看右栏播放器的头部按钮。
*/
function TaskCard({
job, playing, onPlay, onDownload, onRemove,
}: {
job: WebJob
playing: boolean
onPlay: () => void
onDownload: () => void
onRemove: () => void
}) {
const first = job.files[0]
const failed = job.files.filter(f => f.error).length
const ready = job.files.length > 0
return (
<div
onClick={onPlay}
className={'flex items-center gap-2 rounded-lg border p-2 transition-colors ' + (playing ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300')}
>
{/* 封面:后端给(有图取第一张图,纯视频是抽的关键帧),没有就退回类型占位 */}
<div className="grid size-14 shrink-0 place-items-center overflow-hidden rounded-md border border-slate-200 bg-slate-50">
{job.cover_url
? <img src={job.cover_url} alt="" className="size-full object-cover" />
: <span className="text-[10px] text-slate-400">{first ? (first.kind === 'video' ? '视频' : '文件') : '···'}</span>}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-slate-700" title={job.title || job.url}>{job.title || job.url}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
<Chip size="sm" color={STATUS_COLOR[job.status] || 'default'}>{jobStatusText(job)}</Chip>
{job.platform && <Chip size="sm" color="accent">{job.platform}</Chip>}
<span className="text-[11px] text-slate-400">{ready ? `${job.files.length} 个文件` : fmtTime(job.updated_at)}</span>
</div>
{job.error && <p className="mt-0.5 truncate text-[11px] text-red-500" title={job.error}>{job.error}</p>}
{!job.error && failed > 0 && <p className="mt-0.5 text-[11px] text-red-500">{failed} 个文件没传上去</p>}
</div>
{/* gap-2:按钮紧挨着(原来 2px)容易点错,按下去的背景动画也会挨到一起 */}
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<Button size="sm" variant="tertiary" isDisabled={!ready} onPress={onPlay} aria-label="播放">
<CirclePlay className="size-4" />
</Button>
<TooltipContent>播放</TooltipContent>
</Tooltip>
<Tooltip>
<Button size="sm" variant="tertiary" isDisabled={!ready} onPress={onDownload} aria-label="下载">
<ArrowDownToLine className="size-4" />
</Button>
<TooltipContent>下载</TooltipContent>
</Tooltip>
<Tooltip>
<Button
size="sm"
variant="tertiary"
onPress={() => window.open(job.url, '_blank', 'noopener,noreferrer')}
aria-label="打开作品链接"
>
<ArrowUpRightFromSquare className="size-4" />
</Button>
<TooltipContent>打开作品链接</TooltipContent>
</Tooltip>
<Tooltip>
<Button size="sm" variant="tertiary" onPress={onRemove} aria-label="清理任务">
<TrashBin className="size-4" />
</Button>
<TooltipContent>清理任务</TooltipContent>
</Tooltip>
</div>
</div>
)
}
/** 右栏播放器:内联的 yet-another-react-lightbox(图片/视频混排 + 缩略图条 + 缩放)。
*
* 用 Inline 插件而不是浮层:它自己接管开合(`open` 恒为 true、去掉关闭按钮、
* 不锁滚动),容器要**确定高度**才能把内部的 100% 撑起来 —— 外层 grid 给了固定高度,
* 这里靠 min-h-0 + flex-1 接力。
*/
function PlayerPanel({
job, refreshing, onRefresh, onClose,
}: {
job: WebJob | null
refreshing: boolean
onRefresh: () => void
onClose: () => void
}) {
const [index, setIndex] = useState(0)
const [copied, setCopied] = useState(false)
const jobId = job?.id || ''
useEffect(() => { setIndex(0) }, [jobId])
// 换成 slides 时顺手记一份「有序文件表」,两边下标才能对上(缺链接的文件会被跳过)
const { slides, files } = useMemo(() => {
const built: Slide[] = []
const mapped: JobFile[] = []
;(job?.files || []).forEach(file => {
const src = file.public_url || file.local_url
if (!src) return
mapped.push(file)
built.push(toSlide(file, src))
})
return { slides: built, files: mapped }
}, [job?.files])
// 单文件任务不需要缩略图条和页码(注册了就会渲染,只能在插件列表上省)
const plugins = useMemo(
() => (slides.length > 1 ? [Inline, Video, Captions, Counter, Thumbnails, Zoom] : [Inline, Video, Captions, Zoom]),
[slides.length],
)
const at = Math.min(index, Math.max(slides.length - 1, 0))
const current = files[at]
const copy = async () => {
if (!current) return
const ok = await copyText(current.public_url || current.local_url)
if (!ok) { toast.danger('复制失败,请手动选中链接'); return }
setCopied(true); window.setTimeout(() => setCopied(false), 2000)
}
return (
<Card className="flex min-h-[360px] flex-col overflow-hidden border border-slate-200 bg-white lg:h-full lg:min-h-0">
<Card.Header className="shrink-0 space-y-1 border-b border-slate-100">
<div className="flex flex-wrap items-center gap-2">
<Card.Title className="min-w-0 flex-1 truncate">{job ? (job.title || job.url) : '播放器'}</Card.Title>
{job?.platform && <Chip size="sm" color="accent">{job.platform}</Chip>}
{slides.length > 0 && <span className="text-xs text-slate-400">{at + 1}/{slides.length}</span>}
{current && <Button size="sm" variant="tertiary" onPress={copy}>{copied ? '已复制' : '复制链接'}</Button>}
{job && slides.length > 0 && !isActive(job) && (
<Button size="sm" variant="tertiary" isDisabled={refreshing} onPress={onRefresh}>
{refreshing ? '刷新中…' : '刷新链接'}
</Button>
)}
{job && (
<Tooltip>
<Button size="sm" variant="tertiary" onPress={onClose} aria-label="关闭播放器">
<Xmark className="size-4" />
</Button>
<TooltipContent>关闭播放器(清空这栏)</TooltipContent>
</Tooltip>
)}
</div>
{current && (
<p className="truncate text-xs text-slate-400" title={current.name}>
{current.name} · {fmtBytes(current.size)}
{current.public_url ? ' · 公网链接' : ' · 局域网链接,1 小时过期,可点「刷新链接」'}
</p>
)}
</Card.Header>
<Card.Content className="min-h-0 flex-1">
{job && slides.length > 0 ? (
<Lightbox
key={jobId}
// inline 容器(宽高 100%)由 Inline 插件自己注入,**别手写 inline 属性**:
// 用户 props 会盖掉插件塞的那份,容器高度塌成 0,播放器就只剩一片白
className="h-full w-full"
slides={slides}
plugins={plugins}
labels={LIGHTBOX_LABELS}
// 页码默认钉在左上角,正好压住 Captions 的标题条(那条通栏、也在顶部)
// → 挪到右下角;容器样式走 counter.container(插件会把它摊到页码 div 上)
counter={{ container: { style: { top: 'auto', left: 'auto', right: 0, bottom: 0 } } }}
index={at}
on={{ view: ({ index: i }) => setIndex(i) }}
/>
) : (
<div className="grid h-full place-items-center p-6 text-center">
<p className="text-sm text-slate-400">
{job
? (job.status === 'failed' ? (job.error || '解析失败') : '这条任务还没有可预览的文件')
: '在左边粘链接、点「解析」,再在任务列表里点「播放」'}
</p>
</div>
)}
</Card.Content>
</Card>
)
}
/** 下载任务的全部文件。
*
* 用隐藏的 `<a download>` 逐个点击,而不是 fetch + blob:局域网 MinIO 给对象带了
* `Content-Disposition: attachment`,顶层导航直接落盘 —— 不吃内存也不用管 CORS。
* 跨域时 `download` 属性会被浏览器忽略,靠服务端那个头兜底(最坏情况只是新标签打开)。
*/
function downloadFiles(job: WebJob) {
const targets = job.files
.map(f => ({ url: f.public_url || f.local_url, name: f.name }))
.filter(f => f.url)
if (!targets.length) return
targets.forEach((f, i) => {
window.setTimeout(() => {
const a = document.createElement('a')
a.href = f.url
a.download = f.name
a.rel = 'noreferrer'
document.body.appendChild(a)
a.click()
a.remove()
}, i * 250)
})
toast.success(targets.length > 1 ? `开始下载 ${targets.length} 个文件` : '开始下载')
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"}
+111 -9
View File
@@ -3,6 +3,7 @@
数据来源与 nonebot_plugin_picstatus 类似:
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- 协议端接入信息:driver.config 的 host/port + 驱动实际注册的反向 WS 路由 + 本机网卡 IP
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
@@ -15,6 +16,7 @@ psutil.cpu_percent/Process.cpu_percent 会真的 sleep 住调用线程,而 SSE
from __future__ import annotations
import asyncio
import os
import platform
import time
@@ -202,6 +204,102 @@ def _backend_sync() -> dict:
return info
def _is_private_ip(ip: str) -> bool:
"""RFC1918 私有网段(192.168/10/172.16-31),用于把局域网地址排在前面。"""
parts = ip.split(".")
if len(parts) != 4:
return False
try:
a, b = int(parts[0]), int(parts[1])
except ValueError:
return False
return a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31)
def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。"""
ips: list[str] = []
try:
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
except Exception:
return ips
for name, addr_list in addrs.items():
st = stats.get(name)
if st is not None and not st.isup:
continue
for addr in addr_list:
ip = (addr.address or "").strip()
# 只保留 IPv4(忽略 MAC / IPv6),排除环回与自动分配地址
if not ip or ":" in ip or ip.count(".") < 3:
continue
if ip.startswith(("127.", "169.254.")):
continue
if ip not in ips:
ips.append(ip)
return sorted(ips, key=lambda ip: (not _is_private_ip(ip), ip))
def _ws_path() -> str:
"""反向 WS 路径:以驱动实际注册的 websocket 路由为准(跟随适配器版本)。"""
default = "/onebot/v11/ws"
try:
from nonebot import get_driver
routes = getattr(get_driver().server_app, "routes", None) or []
except Exception:
return default
paths = [
str(getattr(route, "path", "") or "")
for route in routes
if "WebSocket" in type(route).__name__
]
paths = [p for p in paths if "/onebot/" in p]
if not paths:
return default
# 同一条路由注册了根路径与尾斜杠别名,优先规范路径 /onebot/v11/ws
return sorted(paths, key=lambda p: (not p.rstrip("/").endswith("/ws"), len(p)))[0]
def _access_token() -> str:
"""反向 WS 授权令牌(OneBot V11 适配器配置;未设置时协议端无需带 Authorization)。"""
try:
for bot in get_bots().values():
adapter = getattr(bot, "adapter", None)
cfg = getattr(adapter, "onebot_config", None)
token = getattr(cfg, "onebot_access_token", None)
if token:
return str(token)
except Exception:
pass
return os.environ.get("ONEBOT_V11_ACCESS_TOKEN", "") or ""
def _endpoint_sync() -> dict:
"""协议端接入信息:监听地址 / 端口 / 反向 WS 路径 / 授权令牌 / 可用 IP。
供 Web 首页展示「协议端配对」信息,只读 driver.config 与驱动的路由表,不额外起服务。
"""
host = ""
port = None
try:
from nonebot import get_driver
config = get_driver().config
host = str(getattr(config, "host", "") or "")
raw_port = getattr(config, "port", None)
port = int(raw_port) if raw_port else None
except Exception:
pass
return {
"host": host,
"port": port,
"ws_path": _ws_path(),
"access_token": _access_token(),
"ips": _local_ips(),
}
def _system_sync() -> dict:
boot = psutil.boot_time()
now = time.time()
@@ -303,15 +401,18 @@ async def collect_dashboard() -> dict:
now = time.time()
if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
return _DASH_CACHE["data"]
cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather(
asyncio.to_thread(_cpu_sync),
asyncio.to_thread(_memory_sync),
asyncio.to_thread(_process_sync),
asyncio.to_thread(_system_sync),
asyncio.to_thread(_disk_sync),
asyncio.to_thread(_network_sync),
asyncio.to_thread(_backend_sync),
_collect_bots(), # 需要走 bot API,必须留在事件循环
cpu, memory, process, system, disk, network, backend, endpoint, bots = (
await asyncio.gather(
asyncio.to_thread(_cpu_sync),
asyncio.to_thread(_memory_sync),
asyncio.to_thread(_process_sync),
asyncio.to_thread(_system_sync),
asyncio.to_thread(_disk_sync),
asyncio.to_thread(_network_sync),
asyncio.to_thread(_backend_sync),
asyncio.to_thread(_endpoint_sync),
_collect_bots(), # 需要走 bot API,必须留在事件循环
)
)
data = {
"bots": bots,
@@ -322,6 +423,7 @@ async def collect_dashboard() -> dict:
"disk": disk,
"network": network,
"backend": backend,
"endpoint": endpoint,
}
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data
+4
View File
@@ -51,6 +51,7 @@ numpy = ">=2.2.2"
opencv-contrib-python = ">=4.10.0.84"
psutil = ">=5.9.5"
py-cpuinfo = ">=9.0.0"
pyzipper = ">=0.3.6"
pytz = ">=2025.1"
ruamel.yaml = ">=0.17.40"
yt-dlp = ">=2026.6.9"
@@ -101,6 +102,9 @@ skip_gitignore = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
# 只收 tests/:根目录 debug/ 下有历史调试脚本(test_group_analysis.py 等),
# 默认从 rootdir 全量收集会在导入期就报错中断整轮测试
testpaths = ["tests"]
[tool.pyright]
typeCheckingMode = "basic"
+2 -1
View File
@@ -21,11 +21,12 @@ cjk-textwrap>=0.1.0 # makeaquote
# --- 抓取 / 网络 ---
yt-dlp>=2026.6.9 # video_analysis
boto3>=1.39.11 # video_analysis S3 存储
pyzipper>=0.3.6 # video_analysis 群文件压缩包加密(AES)
cloudscraper>=1.2.71 # picfinder_take
curl-cffi>=0.14.0 # bf_bot (BF6)
lxml>=5.4.0 # picfinder_take
beautifulsoup4>=4.13.3 # steam_info
mcstatus>=11.1.1 # mc_server_status
mcstatus>=13.0.1 # mc_server_status(13 把 JavaStatusResponse.favicon 改名成了 icon)
# --- 文本 / 系统 ---
jieba>=0.42.1 # learning_chat
+340
View File
@@ -0,0 +1,340 @@
"""MC 服务器数据层 (services/servers.py) 单元测试
servers.py 顶层不 import nonebot(运行态 config 是运行时才取),但 config.py
里有 `from .services import ...` 这类相对导入,所以这里**按包结构**用 importlib
加载(而不是像 test_video_policy.py 那样裸加载单文件):造一棵一次性的包骨架
塞进 sys.modules,真正的插件包 `nonebot_plugin_mc_server_status` 全程没被
NoneBot 加载过。
**每个用例重建一份 servers/config 模块**:增删改落在 `config.var.group_list`
上,直接复用进程里已有的 config 会把正在跑的 bot 的内存态改写掉(测试污染运行
数据),所以每次都用全新的模块对象 + tmp_path 下的数据文件。
"""
import base64
import importlib.util
import json
import sys
import types
from pathlib import Path
import pytest
_PLUGIN_DIR = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_mc_server_status"
)
pytestmark = pytest.mark.skipif(
not (_PLUGIN_DIR / "config.py").exists(), reason="MC 插件不存在"
)
_PKG = "mc_status_testpkg"
_SERVERS = f"{_PKG}.services.servers"
_CONFIG = f"{_PKG}.config"
def _load_by_path(name: str, path: Path):
spec = importlib.util.spec_from_file_location(
name, path, submodule_search_locations=[] if path.is_dir() else None
)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
class _StubDriver:
"""config.py 的 @driver.on_startup / on_bot_connect 只登记回调,测试里不触发。"""
def __getattr__(self, _name):
def _decorator(*_args, **_kwargs):
return lambda func: func
return _decorator
def _init_nonebot_stubs():
"""config.py 顶层要 get_driver()/get_plugin_config()/nonebot.log,没有运行时就得打桩。
先真 `import nonebot` 再把这几个入口换掉(而不是塞一个假包进 sys.modules):
假包会让 python 用它的 `__path__` 去找 `nonebot.adapters`,反而找不到真子模块。
"""
import nonebot
pkg = types.ModuleType("nonebot.log")
pkg.logger = types.SimpleNamespace( # type: ignore[attr-defined]
debug=lambda *a, **k: None,
info=lambda *a, **k: None,
warning=lambda *a, **k: None,
error=lambda *a, **k: None,
)
saved = {
"get_driver": nonebot.get_driver, # type: ignore[attr-defined]
"get_plugin_config": nonebot.get_plugin_config, # type: ignore[attr-defined]
"get_bot": nonebot.get_bot, # type: ignore[attr-defined]
"get_bots": nonebot.get_bots, # type: ignore[attr-defined]
"log": sys.modules.get("nonebot.log"),
}
nonebot.get_driver = lambda: _StubDriver() # type: ignore[attr-defined]
nonebot.get_plugin_config = lambda *_a, **_k: types.SimpleNamespace( # type: ignore[attr-defined]
mc_status_data_filename="mc_status_data.json"
)
nonebot.get_bot = lambda *_a, **_k: None # type: ignore[attr-defined]
nonebot.get_bots = lambda: {} # type: ignore[attr-defined]
sys.modules["nonebot.log"] = pkg
def restore() -> None:
nonebot.get_driver = saved["get_driver"] # type: ignore[attr-defined]
nonebot.get_plugin_config = saved["get_plugin_config"] # type: ignore[attr-defined]
nonebot.get_bot = saved["get_bot"] # type: ignore[attr-defined]
nonebot.get_bots = saved["get_bots"] # type: ignore[attr-defined]
if saved["log"] is None:
sys.modules.pop("nonebot.log", None)
else:
sys.modules["nonebot.log"] = saved["log"]
return restore
@pytest.fixture
def mcs(tmp_path):
"""一套干净的数据层:一次性包骨架 + 全新 servers/config 模块 + 临时数据文件。"""
pkg = types.ModuleType(_PKG)
pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined]
sys.modules[_PKG] = pkg
services = types.ModuleType(f"{_PKG}.services")
services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined]
sys.modules[f"{_PKG}.services"] = services
restore = _init_nonebot_stubs()
try:
module = _load_by_path(_SERVERS, _PLUGIN_DIR / "services" / "servers.py")
runtime = _load_by_path(_CONFIG, _PLUGIN_DIR / "config.py")
finally:
restore()
# config.py 的 on_startup 不在测试里跑,内存态与数据文件名手动准备
runtime.var.group_list.clear()
runtime.pc.mc_status_data_filename = str(tmp_path / "mc_status_data.json")
try:
yield module
finally:
for name in (_SERVERS, _CONFIG, f"{_PKG}.services", _PKG):
sys.modules.pop(name, None)
@pytest.fixture
def groups(mcs):
return mcs._runtime().var.group_list
@pytest.fixture
def mc_module():
"""加载 services/mc.py(只测纯函数 _server_icon,不联网)。
mc.py 在模块顶层 import 了 OneBot 的消息类型,这里给个占位桩 —— 测试只关心
图标解码,不会真的构造 Message。
"""
pkg = types.ModuleType(_PKG)
pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined]
sys.modules[_PKG] = pkg
services = types.ModuleType(f"{_PKG}.services")
services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined]
sys.modules[f"{_PKG}.services"] = services
adapter = types.ModuleType("nonebot.adapters.onebot.v11")
adapter.Message = type("Message", (), {})
adapter.MessageSegment = types.SimpleNamespace(image=lambda *_a, **_k: object())
saved = sys.modules.get("nonebot.adapters.onebot.v11")
sys.modules["nonebot.adapters.onebot.v11"] = adapter
try:
module = _load_by_path(
f"{_PKG}.services.mc", _PLUGIN_DIR / "services" / "mc.py"
)
finally:
if saved is None:
sys.modules.pop("nonebot.adapters.onebot.v11", None)
else:
sys.modules["nonebot.adapters.onebot.v11"] = saved
try:
yield module
finally:
for name in (f"{_PKG}.services.mc", f"{_PKG}.services", _PKG):
sys.modules.pop(name, None)
# ───────────────────────── 服务器图标(mcstatus 跨版本) ─────────────────────────
_PNG = b"\x89PNG\r\n\x1a\n" + b"fake-image-bytes"
def test_server_icon_accepts_new_and_old_attribute_names(mc_module):
"""mcstatus ≥13 是 icon,11/12 是 favicon —— 两种都要认(线上 13.0.1 踩过一次)。"""
b64 = base64.b64encode(_PNG).decode()
new_status = types.SimpleNamespace(icon=f"data:image/png;base64,{b64}")
old_status = types.SimpleNamespace(favicon=f"data:image/png;base64,{b64}")
assert mc_module._server_icon(new_status) == _PNG
assert mc_module._server_icon(old_status) == _PNG
def test_server_icon_handles_bare_base64_and_missing(mc_module):
b64 = base64.b64encode(_PNG).decode()
assert mc_module._server_icon(types.SimpleNamespace(icon=b64)) == _PNG # 无 data URI 前缀
assert mc_module._server_icon(types.SimpleNamespace(icon=None)) is None
assert mc_module._server_icon(types.SimpleNamespace()) is None # 两个属性都没有
assert mc_module._server_icon(types.SimpleNamespace(icon="不是 base64!!")) is None
def _file(mcs) -> dict:
return json.loads(Path(mcs._runtime().pc.mc_status_data_filename).read_text(encoding="utf-8"))
# ───────────────────────── 校验 / 归一化 ─────────────────────────
def test_normalize_type_aliases_and_errors(mcs):
assert mcs.normalize_type("js") == "js"
assert mcs.normalize_type("JS") == "js"
assert mcs.normalize_type(" Java ") == "js"
assert mcs.normalize_type("bds") == "bds"
assert mcs.normalize_type("bedrock") == "bds"
assert mcs.normalize_type("") == "js" # 空值取默认
assert mcs.normalize_type(None, "bds") == "bds"
with pytest.raises(mcs.ServerError):
mcs.normalize_type("java版")
def test_normalize_address_strips_scheme_and_rejects_junk(mcs):
assert mcs.normalize_address(" mc.example.com:25565 ") == "mc.example.com:25565"
assert mcs.normalize_address("minecraft://mc.example.com") == "mc.example.com"
assert mcs.normalize_address("http://mc.example.com/") == "mc.example.com"
for bad in ("", " ", "mc example.com", "mc.example.com/foo", ":25565", "host:"):
with pytest.raises(mcs.ServerError):
mcs.normalize_address(bad)
def test_normalize_name_keeps_spaces_and_strips_quotes(mcs):
assert mcs.normalize_name("亡者世界") == "亡者世界"
assert mcs.normalize_name(" 我 的 服 ") == "我 的 服"
assert mcs.normalize_name('"EMDS"') == "EMDS"
with pytest.raises(mcs.ServerError):
mcs.normalize_name(" ")
with pytest.raises(mcs.ServerError):
mcs.normalize_name("x" * (mcs.MAX_NAME_LEN + 2))
def test_parse_group_id(mcs):
assert mcs.parse_group_id("872490448") == 872490448
assert mcs.parse_group_id(304484502) == 304484502
for bad in ("", " ", "abc", "872490448.0", "0", 1234567890123):
with pytest.raises(mcs.ServerError):
mcs.parse_group_id(bad)
# ───────────────────────── 增 / 改 ─────────────────────────
def test_add_writes_file_and_memory(mcs, groups):
assert mcs.set_server(872490448, "EMDS", "sansenhoshi.top:29985", "js") is True
assert groups[872490448]["EMDS"] == ["sansenhoshi.top:29985", "js"]
assert _file(mcs) == {"872490448": {"EMDS": ["sansenhoshi.top:29985", "js"]}}
def test_update_same_name_keeps_position(mcs, groups):
mcs.set_server(872490448, "EMDS", "a.example.com:1", "js")
mcs.set_server(872490448, "星门", "b.example.com:2", "bds")
assert mcs.set_server(872490448, "EMDS", "c.example.com:3", "js") is False # 同名 = 更新
assert list(groups[872490448]) == ["EMDS", "星门"] # 顺序不变
assert groups[872490448]["EMDS"] == ["c.example.com:3", "js"]
def test_rename_moves_entry(mcs, groups):
mcs.set_server(872490448, "旧名", "mc.example.com:25565", "js")
assert mcs.set_server(872490448, "新名", "mc.example.com:25565", "js", old_name="旧名") is True
assert list(groups[872490448]) == ["新名"]
assert _file(mcs)["872490448"]["新名"] == ["mc.example.com:25565", "js"]
def test_rename_conflict_is_rejected_without_touching_data(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
mcs.set_server(872490448, "B", "b.example.com:2", "js")
with pytest.raises(mcs.ServerError):
mcs.set_server(872490448, "B", "c.example.com:3", "js", old_name="A")
assert groups[872490448] == {"A": ["a.example.com:1", "js"], "B": ["b.example.com:2", "js"]}
def test_rename_missing_source_is_rejected(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
with pytest.raises(mcs.ServerError):
mcs.set_server(872490448, "B", "b.example.com:2", "js", old_name="不存在")
assert list(groups[872490448]) == ["A"]
def test_invalid_input_does_not_write_file(mcs, groups, tmp_path):
with pytest.raises(mcs.ServerError):
mcs.set_server(872490448, "A", "a.example.com:1", "不是类型")
assert groups == {}
assert not (tmp_path / "mc_status_data.json").exists()
# ───────────────────────── 删 ─────────────────────────
def test_delete_server_and_cleanup_empty_group(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
mcs.set_server(872490448, "B", "b.example.com:2", "js")
mcs.set_server(304484502, "C", "c.example.com:3", "bds")
mcs.delete_server(872490448, "A")
assert list(groups[872490448]) == ["B"]
mcs.delete_server(872490448, "B")
assert 872490448 not in groups # 删空连带删群
assert _file(mcs) == {"304484502": {"C": ["c.example.com:3", "bds"]}}
def test_delete_unknown_targets(mcs, groups):
with pytest.raises(mcs.ServerError):
mcs.delete_server(872490448, "没有这个群")
mcs.set_server(872490448, "A", "a.example.com:1", "js")
with pytest.raises(mcs.ServerError):
mcs.delete_server(872490448, "没有这台")
def test_delete_group(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
mcs.delete_group(872490448)
assert groups == {}
with pytest.raises(mcs.ServerError):
mcs.delete_group(872490448)
# ───────────────────────── 读 ─────────────────────────
def test_load_servers_handles_missing_broken_and_partial(mcs, tmp_path):
path = tmp_path / "mc_status_data.json"
assert mcs.load_servers(str(path)) == {} # 文件不存在
path.write_text("{ 坏掉的 json", encoding="utf-8")
assert mcs.load_servers(str(path)) == {} # 坏文件不炸
path.write_text(json.dumps({"8123": {"A": ["a:1", "js"], "B": "不是数组"}}), encoding="utf-8")
assert mcs.load_servers(str(path)) == {8123: {"A": ["a:1", "js"]}}
def test_snapshot_is_a_copy(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
snap = mcs.snapshot()
snap[872490448]["A"][0] = "改坏了"
snap[872490448]["新"] = ["x:1", "js"]
assert groups[872490448] == {"A": ["a.example.com:1", "js"]}
def test_lookup(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
assert mcs.lookup(872490448, "A") == ["a.example.com:1", "js"]
assert mcs.lookup(872490448, "没有这台") is None
assert mcs.lookup(999, "A") is None
+303
View File
@@ -0,0 +1,303 @@
"""群文件打包/投递 (group_file.py) 单元测试
group_file.py 只依赖标准库 + nonebot.logger,故用 importlib 按文件路径裸加载
(与 test_video_policy.py 同法)。build_archive 一律显式传 out_dir —— 默认目录
要经相对导入取 utils.get_temp_root(),裸加载模块没有包上下文。
"""
import importlib.util
import sys
import zipfile
from pathlib import Path
import pytest
_MODULE_PATH = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_video_analysis"
/ "services"
/ "storage"
/ "group_file.py"
)
@pytest.fixture(scope="module")
def gf():
"""以独立模块名加载 group_file.py,避免与插件包 __init__ 冲突"""
spec = importlib.util.spec_from_file_location(
"video_group_file_under_test", _MODULE_PATH
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
yield module
sys.modules.pop(spec.name, None)
def _make_files(directory: Path, names: list[str]) -> list[Path]:
directory.mkdir(parents=True, exist_ok=True)
made = []
for name in names:
path = directory / name
path.write_bytes(f"hello {name}".encode())
made.append(path)
return made
def test_plain_archive(gf, tmp_path):
files = _make_files(tmp_path / "src", ["a.txt", "b.txt"])
archive = gf.build_archive(files, "我的作品", out_dir=tmp_path / "out")
assert archive.exists() and archive.suffix == ".zip"
assert "我的作品" in archive.name
with zipfile.ZipFile(archive) as zf:
assert sorted(zf.namelist()) == ["a.txt", "b.txt"]
assert zf.read("a.txt") == b"hello a.txt"
def test_encrypted_archive_requires_password(gf, tmp_path):
pyzipper = pytest.importorskip("pyzipper")
files = _make_files(tmp_path / "src", ["v.mp4"])
archive = gf.build_archive(
files, "加密作品", password="pw123", out_dir=tmp_path / "out"
)
# 标准库打不开加密包
with zipfile.ZipFile(archive) as zf:
with pytest.raises(RuntimeError):
zf.read("v.mp4")
# 正确密码可读,错误密码不行
with pyzipper.AESZipFile(archive) as zf:
zf.setpassword(b"pw123")
assert zf.read("v.mp4") == b"hello v.mp4"
with pyzipper.AESZipFile(archive) as zf:
zf.setpassword(b"wrong")
with pytest.raises(RuntimeError):
zf.read("v.mp4")
def test_duplicate_names_indexed(gf, tmp_path):
first = _make_files(tmp_path / "p1", ["same.txt"])[0]
second = _make_files(tmp_path / "p2", ["same.txt"])[0]
archive = gf.build_archive([first, second], "t", out_dir=tmp_path / "out")
with zipfile.ZipFile(archive) as zf:
assert sorted(zf.namelist()) == ["same.txt", "same_2.txt"]
assert zf.read("same_2.txt") == b"hello same.txt"
def test_output_path_is_unique(gf, tmp_path):
files = _make_files(tmp_path / "src", ["a.txt"])
out = tmp_path / "out"
one = gf.build_archive(files, "t", out_dir=out)
two = gf.build_archive(files, "t", out_dir=out)
assert one != two and one.exists() and two.exists()
def test_placeholder_title_falls_back(gf, tmp_path):
"""universal.py 传的 title 是占位符 "title",不该出现在文件名里"""
files = _make_files(tmp_path / "src", ["a.txt"])
archive = gf.build_archive(files, "title", out_dir=tmp_path / "out")
assert archive.name.startswith("群文件_")
assert "群文件_群文件" not in archive.name
def test_title_with_path_separators_is_sanitized(gf, tmp_path):
"""原始标题(抖音文案/YouTube 标题)可能带 /、换行、#话题,不能进文件名"""
files = _make_files(tmp_path / "src", ["a.txt"])
archive = gf.build_archive(files, "和/或 #话题\n测试", out_dir=tmp_path / "out")
assert "/" not in archive.name and "\\" not in archive.name
assert "\n" not in archive.name and "#" not in archive.name
assert archive.name.startswith("和_或_测试")
def test_title_starting_with_archive_prefix_falls_back(gf, tmp_path):
"""标题本身以「群文件」开头时不要产出 群文件_xxx_群文件_yyy.zip"""
files = _make_files(tmp_path / "src", ["a.txt"])
archive = gf.build_archive(files, "群文件_120606", out_dir=tmp_path / "out")
assert archive.name.startswith("群文件_")
assert "群文件_120606_群文件" not in archive.name
def test_empty_or_missing_input(gf, tmp_path):
with pytest.raises(ValueError):
gf.build_archive([], "t", out_dir=tmp_path / "out")
with pytest.raises(FileNotFoundError):
gf.build_archive([tmp_path / "nope.mp4"], "t", out_dir=tmp_path / "out")
def test_password_without_pyzipper_fails_loud(gf, tmp_path, monkeypatch):
"""设了密码但加密库不可用 → 报错(调用方会放弃上传,绝不能退化传明文)"""
monkeypatch.setattr(gf, "pyzipper", None)
assert gf.encryption_available() is False
files = _make_files(tmp_path / "src", ["a.txt"])
with pytest.raises(RuntimeError):
gf.build_archive(files, "t", password="pw", out_dir=tmp_path / "out")
# 不加密那一路不受影响
assert gf.build_archive(files, "t", out_dir=tmp_path / "out").exists()
# ───────────────────── 上传:URI 直传 + S3 降级 ─────────────────────
def test_file_uri_keeps_unicode_and_uses_slashes(gf, tmp_path):
target = tmp_path / "dir with space" / "群文件_120606.zip"
target.parent.mkdir(parents=True)
target.write_bytes(b"x")
uri = gf._file_uri(target)
assert uri.startswith("file:///")
assert "\\" not in uri
# 中文/空格保持原样(不做百分号转义,NapCat 侧直接当路径解析)
assert uri.endswith("/dir with space/群文件_120606.zip")
async def test_s3_link_is_the_primary_channel(gf, tmp_path, monkeypatch):
"""默认通道就是 S3 链接(本环境本地直传必挂,不先浪费一次调用)"""
calls: list = []
link = "http://192.168.2.15:5246/PLANA/x.zip?sig=1"
async def fake_call(file_value, group_id, name, folder_id=None):
calls.append((file_value, group_id, name))
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: link)
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
assert calls == [(link, 123, "a.zip")]
async def test_s3_call_failure_falls_back_to_local(gf, tmp_path, monkeypatch):
seen: list[str] = []
link = "http://192.168.2.15:5246/PLANA/x.zip?sig=1"
async def fake_call(file_value, group_id, name, folder_id=None):
seen.append(file_value)
if file_value.startswith("http"):
raise RuntimeError("链接不可达")
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: link)
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
assert seen[0] == link
assert seen[1] == gf._file_uri(file)
async def test_no_s3_link_falls_back_to_local(gf, tmp_path, monkeypatch):
seen: list[str] = []
async def fake_call(file_value, group_id, name, folder_id=None):
seen.append(file_value)
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: "")
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
assert seen == [gf._file_uri(file)]
async def test_both_transports_fail(gf, tmp_path, monkeypatch):
async def always_fail(*args, **kwargs):
raise RuntimeError("nope")
monkeypatch.setattr(gf, "_call_upload", always_fail)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: "http://x/y.zip")
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is False
async def test_missing_file_short_circuits(gf, tmp_path, monkeypatch):
called = []
monkeypatch.setattr(gf, "_call_upload", lambda *a, **kw: called.append(1))
assert await gf.upload_group_file(tmp_path / "nope.zip", 123) is False
assert called == []
# ───────────────────────── 投递编排 ─────────────────────────
def _record_uploads(gf, monkeypatch, path: Path, ok: bool = True) -> list:
calls: list = []
async def fake_upload(file_path, group_id, **kwargs):
calls.append((str(file_path), group_id))
return ok
monkeypatch.setattr(gf, "upload_group_file", fake_upload)
return calls
async def test_zip_mode_uploads_single_archive(gf, tmp_path, monkeypatch):
archive = tmp_path / "pack.zip"
archive.write_bytes(b"zip")
calls = _record_uploads(gf, monkeypatch, archive)
monkeypatch.setattr(
gf, "build_archive", lambda *a, **kw: archive
)
files = _make_files(tmp_path / "src", ["a.txt", "b.txt"])
ok = await gf.upload_group_files(files, 123, title="t", password="pw")
assert ok is True
assert calls == [(str(archive), 123)]
async def test_raw_mode_uploads_each_file(gf, tmp_path, monkeypatch):
calls = _record_uploads(gf, monkeypatch, tmp_path)
files = _make_files(tmp_path / "src", ["a.txt", "b.txt"])
ok = await gf.upload_group_files(files, 123, zip_files=False)
assert ok is True
assert [c[0] for c in calls] == [str(p) for p in files]
async def test_raw_mode_prefixes_work_name_for_multi_files(gf, tmp_path, monkeypatch):
"""多图作品逐个传时,001.jpg 这类成员名要带上作品名(单文件不加)"""
seen: list = []
async def fake_upload(file_path, group_id, **kwargs):
seen.append(kwargs.get("name"))
return True
monkeypatch.setattr(gf, "upload_group_file", fake_upload)
images = _make_files(tmp_path / "src", ["001.jpg", "002.jpg"])
await gf.upload_group_files(
images, 123, zip_files=False, rel_dir="作者_9/海边日落"
)
assert seen == ["海边日落_001.jpg", "海边日落_002.jpg"]
seen.clear()
single = _make_files(tmp_path / "src2", ["作品.mp4"])
await gf.upload_group_files(single, 123, zip_files=False, rel_dir="作者_9")
assert seen == ["作品.mp4"] # 单文件时文件名本身就是作品名,不再加前缀
async def test_pack_failure_skips_upload_entirely(gf, tmp_path, monkeypatch):
"""打包失败(例如缺 pyzipper)时不能退化成上传原文件"""
calls = _record_uploads(gf, monkeypatch, tmp_path)
def boom(*args, **kwargs):
raise RuntimeError("pyzipper 未安装")
monkeypatch.setattr(gf, "build_archive", boom)
files = _make_files(tmp_path / "src", ["a.txt"])
ok = await gf.upload_group_files(files, 123, title="t", password="pw")
assert ok is False
assert calls == []
async def test_empty_file_list(gf, tmp_path, monkeypatch):
calls = _record_uploads(gf, monkeypatch, tmp_path)
assert await gf.upload_group_files([], 123) is False
assert calls == []
+214
View File
@@ -0,0 +1,214 @@
"""媒体命名 / S3 key (utils.py 纯函数) 单元测试
utils.py 只依赖标准库,故用 importlib 按文件路径裸加载(与
test_video_group_file.py 同法)。media_key_of / media_rel_dir_of 依赖
get_temp_root 的返回值,测试里 monkeypatch 成临时目录。
"""
import importlib.util
import sys
from pathlib import Path
import pytest
_MODULE_PATH = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_video_analysis"
/ "utils.py"
)
@pytest.fixture(scope="module")
def u():
"""以独立模块名加载 utils.py,避免与插件包 __init__ 冲突"""
spec = importlib.util.spec_from_file_location("video_utils_under_test", _MODULE_PATH)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
yield module
sys.modules.pop(spec.name, None)
@pytest.fixture
def temp_root(u, tmp_path, monkeypatch):
"""把 get_temp_root 指到 tmp_path(media_key_of 只做路径解析,不需 mkdir)"""
root = tmp_path / "temp"
root.mkdir()
monkeypatch.setattr(u, "get_temp_root", lambda sub="": root / sub if sub else root)
return root
# ───────────────────────── 作者目录 ─────────────────────────
def test_author_dir_with_id(u):
"""有作者 id 就是稳定的 `{昵称}_{id}`,不带码"""
assert u.build_author_dir("示例作者", "12345") == "示例作者_12345"
assert u.build_author_dir("示例作者", "12345", source="x", at=1) == (
"示例作者_12345"
)
def test_author_dir_nickname_only_gets_time_code(u):
"""只有昵称 → 追加 4 位时间短码:分不清同名作者,宁可不聚合也不混目录"""
at = 1_700_000_000
code = u.short_time_code(at)
assert u.build_author_dir("示例作者", "", at=at) == f"示例作者_{code}"
assert u.build_author_dir("示例作者", at=at) == f"示例作者_{code}"
for junk in ("0", "NA", "none", "None", " null "):
assert u.build_author_dir("示例作者", junk, at=at) == f"示例作者_{code}"
# 不同时刻 = 不同目录(同一作者的不同作品不会互相覆盖)
other = u.build_author_dir("示例作者", at=at + 3600)
assert other != f"示例作者_{code}"
assert other.startswith("示例作者_") and len(other.split("_")[-1]) == 4
def test_author_dir_uses_source_code_when_anonymous(u):
"""连昵称都没有 → 未知作者 + 来源短码(同一来源稳定,不同来源不撞)"""
url = "https://www.douyin.com/video/7412345678901234567"
first = u.build_author_dir(None, None, source=url)
assert first.startswith("未知作者_")
assert first == u.build_author_dir("", "", source=url) # 同来源稳定
second = u.build_author_dir(None, None, source=url + "8")
assert second != first
# 没有来源时才退化成时间短码
at = 1_700_000_000
assert u.build_author_dir(None, None, at=at) == (
f"未知作者_{u.short_time_code(at)}"
)
def test_author_dir_treats_placeholder_nicknames_as_anonymous(u):
"""抓取层拿不到昵称时的兜底串不算作者名,走来源码"""
url = "https://www.bilibili.com/opus/123"
expected = u.build_author_dir(None, None, source=url)
for placeholder in ("未知作者", "小红书用户", "B站用户", "b站用户"):
assert u.build_author_dir(placeholder, "", source=url) == expected
# 纯 emoji 昵称 slugify 后为空 → 同样按匿名处理
assert u.build_author_dir("🎬🎬", "", source=url) == expected
def test_short_source_code_is_stable_base36(u):
url = "https://www.douyin.com/video/7412345678901234567"
code = u.short_source_code(url)
assert len(code) == 4 and code.isalnum()
assert code == u.short_source_code(url) # 可复现(不能用带盐的 hash())
assert code != u.short_source_code(url + "8")
def test_author_dir_sanitizes_illegal_chars(u):
"""Windows 非法字符不能进目录名(slugify 直接删掉,空白转 -)"""
assert u.build_author_dir("a/b:c*d?", "12 3") == "abcd_12-3"
assert "/" not in u.build_author_dir("a/b", "1/2")
def test_author_dir_dedupes_same_handle(u):
"""X 这类站点上传者名就是 handle,别产出 someone_someone"""
assert u.build_author_dir("@Someone", "@Someone") == "someone"
def test_author_dir_keeps_long_sec_uid_distinct(u):
"""sec_uid 公共前缀就有 17 字符,截断到 20 会让不同作者撞同一目录"""
first = "MS4wLjABAAAA" + "x" * 45
second = "MS4wLjABAAAA" + "y" * 45
assert u.build_author_dir("n", first) != u.build_author_dir("n", second)
# ───────────────────────── 作品名 ─────────────────────────
def test_work_stem_strips_hashtags_and_spaces(u):
assert u.build_work_stem("旅行 #随手拍 vlog") == "旅行-vlog"
def test_work_stem_empty_falls_back(u):
assert u.build_work_stem("") == "作品"
assert u.build_work_stem(None) == "作品"
assert u.build_work_stem("🎬") == "作品"
def test_work_stem_truncates(u):
assert len(u.build_work_stem("标题" * 20)) <= 15
# ───────────────────────── 短码与重名 ─────────────────────────
def test_short_time_code_is_four_char_base36(u):
assert u.short_time_code(0) == "0000"
code = u.short_time_code(1_700_000_000)
assert len(code) == 4 and code.isalnum()
# 相邻秒不同码
assert code != u.short_time_code(1_700_000_001)
def test_unique_media_path_without_collision(u, tmp_path):
target = tmp_path / "作品.mp4"
assert u.unique_media_path(target) == target
def test_unique_media_path_adds_code_then_index(u, tmp_path):
target = tmp_path / "作品.mp4"
target.write_bytes(b"x")
code = u.short_time_code(1_700_000_000)
second = u.unique_media_path(target, at=1_700_000_000)
assert second.name == f"作品_{code}.mp4"
second.write_bytes(b"x")
third = u.unique_media_path(target, at=1_700_000_000)
assert third.name == f"作品_{code}_2.mp4"
def test_unique_media_path_creates_author_dir(u, tmp_path):
"""落盘前才建作者目录:命名函数顺带确保父目录存在"""
target = tmp_path / "作者_123" / "作品.mp4"
assert u.unique_media_path(target) == target
assert target.parent.is_dir()
def test_unique_media_path_works_for_directories(u, tmp_path):
"""多图作品目录同名时同样加短码"""
note_dir = tmp_path / "作者_123" / "作品"
note_dir.mkdir(parents=True)
renamed = u.unique_media_path(note_dir, at=1_700_000_000)
assert renamed.name == f"作品_{u.short_time_code(1_700_000_000)}"
# ───────────────────────── S3 key ─────────────────────────
def test_media_key_keeps_author_dir_and_drops_platform(u, temp_root):
f = temp_root / "douyin" / "作者_123" / "作品.mp4"
assert u.media_key_of(f) == "作者_123/作品.mp4"
assert u.media_rel_dir_of(f) == "作者_123"
def test_media_key_of_multi_image_work(u, temp_root):
f = temp_root / "bilibili" / "作者_9" / "作品_ab12" / "001.jpg"
assert u.media_key_of(f) == "作者_9/作品_ab12/001.jpg"
assert u.media_rel_dir_of(f) == "作者_9/作品_ab12"
def test_media_key_of_archive_follows_same_rule(u, temp_root):
"""群文件 zip 落在 archive/{作者目录} 下,key 规则与媒体一致"""
f = temp_root / "archive" / "作者_9" / "作品_群文件_120606.zip"
assert u.media_key_of(f) == "作者_9/作品_群文件_120606.zip"
def test_media_key_of_outside_temp_falls_back_to_name(u, temp_root, tmp_path):
f = tmp_path / "elsewhere" / "x.mp4"
assert u.media_key_of(f) == "x.mp4"
assert u.media_rel_dir_of(f) == ""
def test_media_key_of_flat_file_under_platform(u, temp_root):
"""老数据/第三方产物:平台层下没有作者层 → 只用文件名"""
f = temp_root / "douyin" / "x.mp4"
assert u.media_key_of(f) == "x.mp4"
assert u.media_rel_dir_of(f) == ""
+315
View File
@@ -0,0 +1,315 @@
"""视频解析插件群策略 (policy.py) 单元测试
policy.py 只依赖标准库 + nonebot.logger,故用 importlib 按文件路径裸加载
(与 test_group_daily_history_store.py 同法),避免触发插件包 __init__ 的
NoneBot 初始化。
"""
import importlib.util
import json
import sys
from pathlib import Path
import pytest
_MODULE_PATH = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_video_analysis"
/ "policy.py"
)
@pytest.fixture(scope="module")
def policy():
"""以独立模块名加载 policy.py,避免与插件包 __init__ 冲突"""
spec = importlib.util.spec_from_file_location("video_policy_under_test", _MODULE_PATH)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
yield module
sys.modules.pop(spec.name, None)
# ───────────────────────── 平台标签 ─────────────────────────
def test_normalize_platform_aliases(policy):
assert policy.normalize_platform("xhs") == "小红书"
assert policy.normalize_platform("小红书") == "小红书"
assert policy.normalize_platform("b23") == "哔哩哔哩"
assert policy.normalize_platform("B站") == "哔哩哔哩"
assert policy.normalize_platform("yt") == "Youtube"
assert policy.normalize_platform("twitter") == "X"
assert policy.normalize_platform(" X ") == "X"
assert policy.normalize_platform("微博") is None
assert policy.normalize_platform(None) is None
def test_normalize_platforms_dedupe_and_order(policy):
# 别名去重 + 按 PLATFORMS 顺序排列(与输入顺序无关)
assert policy.normalize_platforms(["x", "xhs", "twitter"]) == ["小红书", "X"]
# 字符串按分隔符拆
assert policy.normalize_platforms("小红书,xhs 抖音") == ["小红书", "抖音"]
# 非法值丢弃
assert policy.normalize_platforms(["微博", "xhs"]) == ["小红书"]
assert policy.normalize_platforms(None) == []
assert policy.normalize_platforms(123) == []
def test_apply_platform_diff_incremental(policy):
# 全部带 +/- → 增量
assert policy.apply_platform_diff([], ["+xhs"]) == ["小红书"]
assert policy.apply_platform_diff(["小红书"], ["+X"]) == ["小红书", "X"]
assert policy.apply_platform_diff(["小红书", "X"], ["-X"]) == ["小红书"]
assert policy.apply_platform_diff(["小红书"], ["+xhs"]) == ["小红书"] # 去重
assert policy.apply_platform_diff(["小红书"], ["-X"]) == ["小红书"] # 删不存在的
assert policy.apply_platform_diff([], ["+xhs", "+b23"]) == ["小红书", "哔哩哔哩"]
def test_apply_platform_diff_replace_and_invalid(policy):
# 出现裸平台名 → 整体覆盖
assert policy.apply_platform_diff(["小红书"], ["x"]) == ["X"]
assert policy.apply_platform_diff(["小红书"], []) is None
assert policy.apply_platform_diff(["小红书"], ["微博"]) is None
assert policy.apply_platform_diff(["小红书"], ["+微博"]) is None
def test_match_platform_domains(policy):
assert policy.match_platform("https://x.com/a/status/1") == "X"
assert policy.match_platform("https://twitter.com/a/status/1") == "X"
assert policy.match_platform("https://b23.tv/abc") == "哔哩哔哩"
assert policy.match_platform("https://v.douyin.com/abc") == "抖音"
assert policy.match_platform("https://youtu.be/abc") == "Youtube"
assert policy.match_platform("https://example.com/a.mp4") is None
# ───────────────────────── 策略模型 ─────────────────────────
def test_policy_from_dict_sanitizes(policy):
p = policy.Policy.from_dict(
{
"auto": 1,
"auto_link": ["xhs", "xhs", "微博"],
"ban_link": ["twitter"],
"plan": "b",
"upload_public": True,
"send_link": True,
"upload_group_file": True,
"unknown_key": "ignored",
}
)
assert p.auto is True
assert p.auto_link == ["小红书"]
assert p.ban_link == ["X"]
assert p.plan == "B"
assert p.to_dict()["upload_group_file"] is True
assert "unknown_key" not in p.to_dict()
def test_policy_from_dict_bad_values_fall_back(policy):
p = policy.Policy.from_dict({"plan": "Z", "auto_link": "b23"})
assert p.plan == "C" # 非法 plan 回退默认
assert p.auto_link == ["哔哩哔哩"]
# 空/非 dict 输入不炸
assert policy.Policy.from_dict(None) == policy.Policy()
assert policy.Policy.from_dict([1, 2]) == policy.Policy()
def test_sends_link_requires_public(policy):
assert policy.Policy(upload_public=True, send_link=True).sends_link is True
# 没开公网就没有链接可发
assert policy.Policy(upload_public=False, send_link=True).sends_link is False
def test_allows_group_file(policy):
off = policy.Policy(upload_group_file=False)
assert off.allows_group_file("抖音") is False
assert off.allows_group_file(None) is False
# 开了但没限定平台 → 所有平台(含识别不出的)都传
every = policy.Policy(upload_group_file=True)
assert every.allows_group_file("抖音") is True
assert every.allows_group_file(None) is True
# 限定了平台 → 只传清单里的,识别不出平台时按"不在清单里"处理
only_douyin = policy.Policy(
upload_group_file=True, group_file_platforms=["抖音"]
)
assert only_douyin.allows_group_file("抖音") is True
assert only_douyin.allows_group_file("X") is False
assert only_douyin.allows_group_file(None) is False
def test_policy_construct_normalizes(policy):
"""直接构造 Policy(...) 也要归一:绕过 from_dict 传别名不该静默失效"""
p = policy.Policy(
plan="z",
auto_link=["xhs"],
upload_group_file=True,
group_file_platforms=["douyin"],
)
assert p.plan == "C"
assert p.auto_link == ["小红书"]
assert p.group_file_platforms == ["抖音"]
assert p.allows_group_file("抖音") is True
def test_group_file_platforms_normalized(policy):
p = policy.Policy.from_dict(
{"group_file_platforms": ["douyin", "b23", "微博"], "upload_group_file": True}
)
# 别名归一成规范标签、非法值丢弃、顺序按 PLATFORMS
assert p.group_file_platforms == ["哔哩哔哩", "抖音"]
def test_banned_and_auto_matched(policy):
p = policy.Policy(auto_link=["小红书"], ban_link=["X"])
assert p.auto_matched("https://xhslink.com/a") == "小红书"
assert p.auto_matched("https://x.com/a") is None
assert p.banned("https://x.com/a") == "X"
assert p.banned("https://twitter.com/a") == "X"
assert p.banned("https://douyin.com/a") is None
# ───────────────────────── 迁移 ─────────────────────────
def test_migrate_v1_to_v3(policy):
data, changed = policy.migrate(
{
"WHITE_LIST": ["111", "222"],
"AUTO_ANALYSIS": ["111"],
"PLANA": ["111"],
"PLANB": ["222"],
"BLACK_LIST": ["999"],
}
)
assert changed is True
assert set(data["groups"]) == {"111", "222"}
# v1 的 plan=B 隐含公网,迁移后显式补上
assert data["groups"]["222"]["upload_public"] is True
assert data["groups"]["111"]["plan"] == "A"
assert data["groups"]["111"]["auto"] is True
assert data["blacklist"] == ["999"]
assert data["default"] == policy.Policy().to_dict()
def test_migrate_v2_plan_b_gets_public(policy):
data, changed = policy.migrate(
{
"groups": {
"111": {"auto": True, "plan": "B"},
"222": {"plan": "C"},
"333": {},
},
"blacklist": [],
}
)
assert changed is True
assert data["groups"]["111"]["upload_public"] is True
assert data["groups"]["222"]["upload_public"] is False
assert data["groups"]["333"]["auto"] is False
def test_migrate_keeps_explicit_public_flag(policy):
# 显式写了 upload_public 的条目不被迁移覆盖
data, _ = policy.migrate(
{"groups": {"111": {"plan": "B", "upload_public": False}}, "blacklist": []}
)
assert data["groups"]["111"]["upload_public"] is False
def test_migrate_idempotent(policy):
first, _ = policy.migrate({"groups": {"111": {"plan": "B"}}, "blacklist": []})
second, changed = policy.migrate(first)
assert changed is False
assert second == first
# ───────────────────────── 存储 ─────────────────────────
async def test_store_update_and_persist(policy, tmp_path):
path = tmp_path / "list.json"
store = policy.PolicyStore(path)
store.load()
assert path.exists() # 首次 load 落盘
await store.update_group("111", create=True, auto=True, plan="A")
await store.update_group("222", create=True, auto_link=["xhs"])
# 内存读
assert store.get("111").auto is True
assert store.get("222").auto_link == ["小红书"]
# 未配置的群回落 default
assert store.get("999") == store.default_policy()
assert store.is_whitelisted("111") is True
assert store.is_whitelisted("999") is False
on_disk = json.loads(path.read_text(encoding="utf-8"))
assert on_disk["groups"]["111"]["auto"] is True
assert on_disk["groups"]["222"]["auto_link"] == ["小红书"]
assert not path.with_name("list.json.tmp").exists()
async def test_store_update_requires_whitelist_unless_create(policy, tmp_path):
store = policy.PolicyStore(tmp_path / "list.json")
assert await store.update_group("111", auto=True) is None
assert store.is_whitelisted("111") is False
async def test_store_remove_group_and_blacklist(policy, tmp_path):
store = policy.PolicyStore(tmp_path / "list.json")
await store.update_group("111", create=True)
assert await store.remove_group("111") is True
assert await store.remove_group("111") is False
assert store.is_whitelisted("111") is False
assert await store.add_blacklist(999) is True
assert await store.add_blacklist("999") is False # 幂等(int/str 同一个人)
assert store.is_blacklisted(999) is True
assert store.blacklist() == ["999"]
assert await store.remove_blacklist("999") is True
assert store.blacklist() == []
async def test_store_concurrent_updates_keep_all(policy, tmp_path):
"""并发写不能丢群:每次写都从事件循环线程快照,写线程不碰共享状态"""
path = tmp_path / "list.json"
store = policy.PolicyStore(path)
store.load()
import asyncio
await asyncio.gather(
*(store.update_group(str(1000 + i), create=True, auto=True) for i in range(30))
)
on_disk = json.loads(path.read_text(encoding="utf-8"))
assert len(on_disk["groups"]) == 30
assert len(store.all_groups()) == 30
async def test_store_persists_group_file_platforms(policy, tmp_path):
"""POLICY_FIELDS 里漏了新字段的话这次更新会被静默丢掉"""
path = tmp_path / "list.json"
store = policy.PolicyStore(path)
await store.update_group(
"111", create=True, upload_group_file=True, group_file_platforms=["xhs"]
)
assert store.get("111").group_file_platforms == ["小红书"]
on_disk = json.loads(path.read_text(encoding="utf-8"))
assert on_disk["groups"]["111"]["group_file_platforms"] == ["小红书"]
assert on_disk["groups"]["111"]["upload_group_file"] is True
async def test_store_default_policy_edit(policy, tmp_path):
store = policy.PolicyStore(tmp_path / "list.json")
await store.set_default(policy.Policy(plan="B", upload_public=True))
assert store.default_policy().plan == "B"
assert store.default_policy().upload_public is True
# 群未配置时读到的就是 default
assert store.get("111").plan == "B"
+571
View File
@@ -0,0 +1,571 @@
"""Web 管理台解析任务 (services/web_jobs.py) 单元测试
模块本身只依赖标准库 + nonebot.logger,故用 importlib 按文件路径裸加载
(与 test_video_policy.py / test_video_group_file.py 同法)。fetchers 与
上传都是函数内延迟导入的注入点,这里全部塞假实现 —— 不碰网络、不碰 S3。
`platform_of` 走 `..policy` 的包内相对导入,裸加载下不可用,测试里一并替换。
"""
import asyncio
import importlib.util
import subprocess
import sys
import time
from pathlib import Path
import pytest
_MODULE_PATH = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_video_analysis"
/ "services"
/ "web_jobs.py"
)
@pytest.fixture(scope="module")
def wj():
"""以独立模块名加载 web_jobs.py,避免与插件包 __init__ 冲突"""
spec = importlib.util.spec_from_file_location(
"video_web_jobs_under_test", _MODULE_PATH
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
yield module
sys.modules.pop(spec.name, None)
@pytest.fixture
def manager(wj, monkeypatch):
"""造一个依赖全注入的 JobManager(fetch / upload / poster / policy 都传进来)"""
monkeypatch.setattr(wj, "platform_of", lambda url: "抖音")
def build(*, fetch, upload=None, poster=None, policy=None):
return wj.JobManager(
fetch=fetch,
upload=upload or _ok_upload,
poster=poster or _fake_poster,
policy=policy or (lambda: None),
)
return build
async def _fake_poster(video):
"""假抽帧:真的写一个封面文件出来(上传那一环会 stat 它)"""
out = video.with_name(f"{video.stem}_封面.jpg")
out.write_bytes(b"fake-jpeg")
return out
async def _no_poster(video):
return None
# ─────────────────────────── 假实现 ───────────────────────────
async def _ok_upload(path, policy):
return f"http://lan/{path.name}", None
def _touch(directory: Path, name: str, content: bytes = b"x") -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / name
path.write_bytes(content)
return path
def _ok_fetch(*paths, title="作品"):
async def fetch(url):
return title, [Path(p) for p in paths], len(paths) > 1
return fetch
async def _settle(mgr, timeout=5.0):
"""等所有任务进入终态,返回 list_jobs() 的结果。"""
deadline = time.monotonic() + timeout
while True:
jobs = mgr.list_jobs()
if jobs and all(not j.active for j in jobs):
return jobs
if time.monotonic() > deadline:
raise AssertionError("任务没有在超时内结束")
await asyncio.sleep(0.01)
# ─────────────────────── URL 提取 / 文件类型 ───────────────────────
def test_extract_urls_from_multiline_text(wj):
text = (
"看看这个 https://v.douyin.com/abc123。\n"
"还有 https://b23.tv/xyz,https://v.douyin.com/abc123(重复的只留一条)"
)
assert wj.extract_urls(text) == [
"https://v.douyin.com/abc123",
"https://b23.tv/xyz",
]
def test_extract_urls_keeps_query_string(wj):
"""小红书 xsec_token / 抖音带参数的分享链,query 不能被截掉"""
url = "https://www.xiaohongshu.com/explore/abc?xsec_token=XYZ&xsec_source=pc_feed"
assert wj.extract_urls(f"看这个 {url}") == [url]
def test_extract_urls_without_links(wj):
assert wj.extract_urls("这里没有链接,纯文本一段") == []
assert wj.extract_urls("") == []
def test_kind_of(wj):
assert wj.kind_of("001.JPG") == "image"
assert wj.kind_of(Path("作品.mp4")) == "video"
assert wj.kind_of("note.txt") == "file"
# ───────────────────────── 提交与复用 ─────────────────────────
async def test_same_url_reuses_active_job(wj, manager, tmp_path):
"""同 URL 连点两次不能起两个任务(抖音双开浏览器 + 落盘路径撞车)"""
calls: list[str] = []
async def fetch(url):
calls.append(url)
await asyncio.sleep(0.05) # 保持 running,让第二次提交能命中
return "作品", [_touch(tmp_path, "a.jpg")], False
mgr = manager(fetch=fetch)
url = "https://v.douyin.com/abc123"
first = (await mgr.submit([url]))[0]
second = (await mgr.submit([url]))[0]
assert first.id == second.id
assert first.platform == "抖音"
await _settle(mgr)
assert calls == [url]
# 跑完之后再提交同一条 → 是新任务(复用只针对在跑的)
third = (await mgr.submit([url]))[0]
assert third.id != first.id
async def test_force_creates_new_job(wj, manager, tmp_path):
calls: list[str] = []
async def fetch(url):
calls.append(url)
await asyncio.sleep(0.05)
return "作品", [_touch(tmp_path, "a.jpg")], False
mgr = manager(fetch=fetch)
url = "https://v.douyin.com/abc123"
old = (await mgr.submit([url]))[0]
new = (await mgr.submit([url], force=True))[0]
assert new.id != old.id
jobs = await _settle(mgr)
assert len(calls) == 2 and len(jobs) == 2
async def test_batch_submit_keeps_input_order(wj, manager, tmp_path):
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "a.jpg")))
jobs = await mgr.submit(["https://a/1", "https://a/2", "https://a/3"])
assert [j.url for j in jobs] == ["https://a/1", "https://a/2", "https://a/3"]
await _settle(mgr)
# ───────────────────────── 失败与超时 ─────────────────────────
async def test_no_media_marks_job_failed(wj, manager):
async def fetch(url):
return None, [], False
mgr = manager(fetch=fetch)
url = "https://v.douyin.com/gone"
(await mgr.submit([url]))[0]
job = (await _settle(mgr))[0]
assert job.status == "failed"
assert "无法解析到媒体" in job.error
assert job.title == url # 没标题时退回 URL,前端不至于只显示空白
async def test_fetch_exception_marks_job_failed(wj, manager):
async def fetch(url):
raise RuntimeError("被风控了")
mgr = manager(fetch=fetch)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.status == "failed" and job.error == "被风控了"
async def test_job_timeout(wj, manager, monkeypatch):
monkeypatch.setattr(wj, "JOB_TIMEOUT", 0.05)
async def fetch(url):
await asyncio.sleep(5)
mgr = manager(fetch=fetch)
(await mgr.submit(["https://v.douyin.com/slow"]))[0]
job = (await _settle(mgr))[0]
assert job.status == "failed" and "解析超时" in job.error
# ───────────────────────── 上传 ─────────────────────────
async def test_single_upload_failure_does_not_fail_job(wj, manager, tmp_path):
"""多图作品挂一张 → 只标那一张,其余照常出链接"""
files = [_touch(tmp_path, "001.jpg"), _touch(tmp_path, "002.jpg")]
async def upload(path, policy):
if path.name == "002.jpg":
raise RuntimeError("S3 连接超时")
return "http://lan/001.jpg?sig=1", "https://pub/001.jpg"
mgr = manager(fetch=_ok_fetch(*files), upload=upload)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.status == "done"
assert job.files[0]["local_url"].endswith("sig=1")
assert job.files[0]["public_url"] == "https://pub/001.jpg"
assert job.files[0]["error"] == ""
assert "S3 连接超时" in job.files[1]["error"]
assert job.files[1]["kind"] == "image" and job.files[1]["size"] == 1
async def test_upload_without_links_is_marked(wj, manager, tmp_path):
async def upload(path, policy):
return "", None # s3.py 失败时就是返回空串
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "a.mp4")), upload=upload)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.status == "done" and "上传失败" in job.files[0]["error"]
assert job.files[0]["kind"] == "video"
async def test_upload_gets_default_policy(wj, manager, tmp_path):
"""存储链路按默认策略走:policy 原样传给 upload_with_plan"""
seen: list = []
sentinel = object()
async def upload(path, policy):
seen.append(policy)
return "http://lan/a.jpg", None
mgr = manager(
fetch=_ok_fetch(_touch(tmp_path, "a.jpg")),
upload=upload,
policy=lambda: sentinel,
)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
await _settle(mgr)
assert seen == [sentinel]
# ───────────────────────── 刷新链接 ─────────────────────────
async def test_refresh_reruns_upload(wj, manager, tmp_path):
files = [_touch(tmp_path, "001.jpg"), _touch(tmp_path, "002.jpg")]
links = iter(
[
"http://lan/a?sig=1",
"http://lan/b?sig=1",
"http://lan/a?sig=2",
"http://lan/b?sig=2",
]
)
async def upload(path, policy):
return next(links), None
mgr = manager(fetch=_ok_fetch(*files), upload=upload)
job = (await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert [f["local_url"] for f in job.files] == [
"http://lan/a?sig=1",
"http://lan/b?sig=1",
]
refreshed = await mgr.refresh(job.id)
assert refreshed is not None
assert [f["local_url"] for f in refreshed.files] == [
"http://lan/a?sig=2",
"http://lan/b?sig=2",
]
async def test_refresh_marks_files_gone_from_disk(wj, manager, tmp_path):
"""temp 被 cleanup 清掉后再点刷新 → 只标文件没了,不炸"""
path = _touch(tmp_path, "001.jpg")
async def upload(p, policy):
return "http://lan/a.jpg", None
mgr = manager(fetch=_ok_fetch(path), upload=upload)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
path.unlink()
refreshed = await mgr.refresh(job.id)
assert refreshed is not None
assert "本地文件不存在" in refreshed.files[0]["error"]
assert refreshed.files[0]["local_url"] == ""
async def test_refresh_unknown_job_returns_none(wj, manager):
mgr = manager(fetch=_ok_fetch())
assert await mgr.refresh("nope") is None
async def fetch(url):
return None, [], False
(await mgr.submit(["https://a/nothing"]))[0]
job = (await _settle(mgr))[0]
assert job.files == []
assert await mgr.refresh(job.id) is None
# ───────────────────────── 封面 ─────────────────────────
async def test_cover_is_first_image(wj, manager, tmp_path):
"""图文作品:封面直接取第一张图,不抽帧"""
poster_calls: list = []
async def poster(video):
poster_calls.append(video)
return await _fake_poster(video)
files = [_touch(tmp_path, "001.jpg"), _touch(tmp_path, "002.jpg")]
mgr = manager(fetch=_ok_fetch(*files), poster=poster)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.cover_url == "http://lan/001.jpg"
assert poster_calls == []
async def test_cover_is_extracted_for_video_only_post(wj, manager, tmp_path):
"""纯视频作品:抽第一帧、上传、当封面"""
uploaded: list[str] = []
async def upload(path, policy):
uploaded.append(path.name)
return f"http://lan/{path.name}", None
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "作品.mp4")), upload=upload)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.cover_url == "http://lan/作品_封面.jpg"
assert uploaded == ["作品.mp4", "作品_封面.jpg"]
assert job.poster_path is not None and job.poster_path.exists()
# 封面是单独的文件,不能混进 files(否则播放器里会多出一个"文件")
assert [f["name"] for f in job.files] == ["作品.mp4"]
async def test_cover_prefers_image_over_video_in_mixed_post(wj, manager, tmp_path):
"""混排(图 + 视频):按用户定的规则取第一张图"""
files = [_touch(tmp_path, "图文.jpg"), _touch(tmp_path, "动图.mp4")]
mgr = manager(fetch=_ok_fetch(*files))
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.cover_url == "http://lan/图文.jpg"
assert job.poster_path is None
async def test_poster_failure_keeps_job_done(wj, manager, tmp_path):
"""抽帧失败 → 封面留空,任务照常完成"""
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "作品.mp4")), poster=_no_poster)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.status == "done"
assert job.cover_url == "" and job.poster_path is None
async def test_make_poster_extracts_real_frame(wj, tmp_path, monkeypatch):
"""真跑一次 ffmpeg:确认真能抽出 JPEG(假 poster 只覆盖接缝,覆盖不到命令本身)"""
ffmpeg = Path(__file__).resolve().parents[1] / ".venv" / "Scripts" / "ffmpeg.exe"
if not ffmpeg.exists():
pytest.skip("没有 .venv/Scripts/ffmpeg.exe")
monkeypatch.setattr(wj, "ffmpeg_path", lambda: str(ffmpeg))
video = tmp_path / "作品.mp4"
subprocess.run(
[str(ffmpeg), "-f", "lavfi", "-i", "testsrc=size=320x240:rate=10",
"-t", "2", "-pix_fmt", "yuv420p", "-y", str(video)],
capture_output=True, check=True,
)
poster = await wj.make_poster(video)
assert poster is not None
assert poster.name == "作品_封面.jpg"
assert poster.read_bytes()[:2] == b"\xff\xd8" # JPEG magic
async def test_make_poster_falls_back_to_first_frame(wj, tmp_path, monkeypatch):
"""不足 1 秒的视频取不到"第 1 秒"那一帧,要退回第 0 秒再试"""
ffmpeg = Path(__file__).resolve().parents[1] / ".venv" / "Scripts" / "ffmpeg.exe"
if not ffmpeg.exists():
pytest.skip("没有 .venv/Scripts/ffmpeg.exe")
monkeypatch.setattr(wj, "ffmpeg_path", lambda: str(ffmpeg))
video = tmp_path / "短片.mp4"
subprocess.run(
[str(ffmpeg), "-f", "lavfi", "-i", "testsrc=size=160x120:rate=10",
"-t", "0.5", "-pix_fmt", "yuv420p", "-y", str(video)],
capture_output=True, check=True,
)
poster = await wj.make_poster(video)
assert poster is not None and poster.stat().st_size > 0
async def test_make_poster_missing_video_returns_none(wj, tmp_path, monkeypatch):
ffmpeg = Path(__file__).resolve().parents[1] / ".venv" / "Scripts" / "ffmpeg.exe"
if not ffmpeg.exists():
pytest.skip("没有 .venv/Scripts/ffmpeg.exe")
monkeypatch.setattr(wj, "ffmpeg_path", lambda: str(ffmpeg))
assert await wj.make_poster(tmp_path / "不存在.mp4") is None
async def test_refresh_refreshes_cover(wj, manager, tmp_path):
"""刷新链接时封面一起换新(否则卡片封面 1 小时后变裂图)"""
links = iter(["http://lan/a?sig=1", "http://lan/p?sig=1", "http://lan/a?sig=2", "http://lan/p?sig=2"])
async def upload(path, policy):
return next(links), None
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "作品.mp4")), upload=upload)
(await mgr.submit(["https://v.douyin.com/x"]))[0]
job = (await _settle(mgr))[0]
assert job.cover_url == "http://lan/p?sig=1"
refreshed = await mgr.refresh(job.id)
assert refreshed is not None
assert refreshed.cover_url == "http://lan/p?sig=2"
# ───────────────────────── 清理 ─────────────────────────
async def test_remove_job(wj, manager, tmp_path):
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "a.jpg")))
job = (await mgr.submit(["https://v.douyin.com/x"]))[0]
await _settle(mgr)
assert mgr.remove(job.id) is True
assert mgr.list_jobs() == []
assert mgr.remove(job.id) is False # 再删一次:不存在
async def test_remove_running_job_cancels_it(wj, manager, tmp_path):
"""删掉在跑的任务要顺手取消,不能让它继续跑完再写回任务表"""
started = asyncio.Event()
cancelled = asyncio.Event()
async def fetch(url):
started.set()
try:
await asyncio.sleep(30)
except asyncio.CancelledError:
cancelled.set()
raise
return "作品", [_touch(tmp_path, "a.jpg")], False
mgr = manager(fetch=fetch)
job = (await mgr.submit(["https://v.douyin.com/x"]))[0]
await asyncio.wait_for(started.wait(), timeout=2)
assert mgr.remove(job.id) is True
await asyncio.wait_for(cancelled.wait(), timeout=2)
assert mgr.list_jobs() == []
async def test_clear_finished_keeps_running(wj, manager, tmp_path):
release = asyncio.Event()
async def fetch(url):
if "slow" in url:
await release.wait()
return "作品", [_touch(tmp_path, "a.jpg")], False
mgr = manager(fetch=fetch)
fast = (await mgr.submit(["https://v.douyin.com/fast"]))[0]
slow = (await mgr.submit(["https://v.douyin.com/slow"]))[0]
for _ in range(500):
if not fast.active:
break
await asyncio.sleep(0.01)
assert mgr.clear_finished() == 1
assert [j.id for j in mgr.list_jobs()] == [slow.id]
release.set()
await _settle(mgr)
# ───────────────────────── 上限与回收 ─────────────────────────
async def test_job_count_is_capped(wj, manager, tmp_path, monkeypatch):
monkeypatch.setattr(wj, "MAX_JOBS", 3)
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "a.jpg")))
await mgr.submit([f"https://v.douyin.com/{i}" for i in range(6)])
jobs = await _settle(mgr)
assert len(jobs) == 3
async def test_expired_terminal_jobs_are_pruned(wj, manager, tmp_path):
mgr = manager(fetch=_ok_fetch(_touch(tmp_path, "a.jpg")))
job = (await mgr.submit(["https://v.douyin.com/x"]))[0]
await _settle(mgr)
assert len(mgr.list_jobs()) == 1
job.updated_at -= wj.TERMINAL_TTL + 1
assert mgr.list_jobs() == []
async def test_running_jobs_survive_prune(wj, manager, monkeypatch):
"""在跑的任务不受上限/TTL 影响(TTL 设为负数,终态一律该清掉)"""
monkeypatch.setattr(wj, "TERMINAL_TTL", -1)
release = asyncio.Event()
async def fetch(url):
await release.wait()
return None, [], False
mgr = manager(fetch=fetch)
job = (await mgr.submit(["https://v.douyin.com/x"]))[0]
await asyncio.sleep(0.05)
assert [j.id for j in mgr.list_jobs()] == [job.id]
assert job.status == "running" and job.stage == "解析中"
release.set()
# 终态任务会被 TTL=-1 立即回收,所以这里盯 job 本身而不是 list_jobs()
for _ in range(500):
if not job.active:
break
await asyncio.sleep(0.01)
assert job.status == "failed"