From 1f2f23616dc3f515acc415b28a8d6bf26df5ea58 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 14:03:54 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(bilibili):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=9B=BE=E6=96=87=EF=BC=88opus=EF=BC=89=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E7=AE=A1=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bilibili): opus 标识解析、详情拉取与合并转发发送 - 新增 bilibili_opus 管线:命中 bilibili.com/opus/、t.bilibili.com/、 b23.tv 短链或 QQ 小程序分享卡片后自动发送一次合并转发; - 节点结构固定为「元数据 → 内容 → 嵌套」:第一条是图文信息(封面、标题、 UP主、时间、阅读点赞评论转发、原文链接),第二条起是正文; - 正文按单节点 4000 字切分追加内容节点(正文 1/3),不截断、不丢内容, 图片保留在原文位置;图文/视频卡片各自成为独立嵌套合并转发并递归展开, 其余卡片渲染为单个链接卡片节点; - 详情优先请求 x/polymer/web-dynamic/v1/opus/detail,失败(风控、旧版专栏) 回退 x/polymer/web-dynamic/v1/detail(带 WBI);两种 modules 形态 (列表 / 字典)与 major.opus.summary 摘要结构都在解析层兼容。 * feat(bilibili): 剥离可复用的视频节点构建与共享格式化助手 - sender 抽出 build_bilibili_video_nodes,供图文嵌套视频卡片复用, 视频自动提取路径行为保持不变; - 新增 bilibili/format.py 承载数字缩写、时长与时间戳、4000 字切分, sender 改为复用同一实现。 * feat(config): 新增 opus 提取开关与嵌套边界 - [bilibili].opus_enabled / opus_max_items / opus_nested_depth / opus_nested_max_cards,白名单与 auto_extract_enabled 复用视频侧; - 越界值按约定回退或截断(max_items 3/10、depth 5、cards 20)。 * test(bilibili): 覆盖解析、渲染、发送与管线 - 新增 4 个测试文件共 54 个用例:标识解析(含短链与卡片)、双响应结构解析、 节点结构与分段不丢内容、递归与边界降级、投递不确定异常不吞; - 修补既有测试替身,补充新 mixin 方法与 bilibili_opus 管线注册断言。 * docs(config,pipelines): 补充 opus 配置项与管线说明 - config.toml.example、docs/configuration.md 补 4 个配置键与图文行为说明; - docs/pipelines.md 新增「内置 Bilibili 图文(opus)管线」并更新目录树, docs/development.md 目录职责同步。 * chore(mypy): 排除仓库内 uv 缓存目录 本机 uv 缓存在只读盘上无法写入时需要 UV_CACHE_DIR 指向 data/cache/uv, 该目录被 mypy 误扫出重复模块错误,加入 exclude 保持一致。 --- config.toml.example | 12 + docs/configuration.md | 11 + docs/development.md | 4 +- docs/pipelines.md | 23 +- pyproject.toml | 2 +- src/Undefined/bilibili/__init__.py | 8 +- src/Undefined/bilibili/api_client.py | 111 +++- src/Undefined/bilibili/downloader.py | 17 + src/Undefined/bilibili/errors.py | 4 + src/Undefined/bilibili/format.py | 76 +++ src/Undefined/bilibili/models.py | 97 +++ src/Undefined/bilibili/opus_parser.py | 126 ++++ src/Undefined/bilibili/opus_render.py | 613 ++++++++++++++++++ src/Undefined/bilibili/opus_sender.py | 533 +++++++++++++++ src/Undefined/bilibili/sender.py | 85 ++- src/Undefined/config/config_class.py | 5 + .../config/load_sections/integrations.py | 29 + src/Undefined/handlers/auto_extract.py | 59 ++ .../pipelines/bilibili_opus/config.json | 6 + .../skills/pipelines/bilibili_opus/handler.py | 51 ++ src/Undefined/skills/pipelines/context.py | 2 + tests/test_ai_client_setup_paths.py | 8 +- tests/test_automations.py | 6 + tests/test_bilibili_opus_parser.py | 129 ++++ tests/test_bilibili_opus_pipeline.py | 190 ++++++ tests/test_bilibili_opus_render.py | 480 ++++++++++++++ tests/test_bilibili_opus_sender.py | 506 +++++++++++++++ tests/test_handlers_pipelines.py | 12 + 28 files changed, 3174 insertions(+), 31 deletions(-) create mode 100644 src/Undefined/bilibili/format.py create mode 100644 src/Undefined/bilibili/opus_parser.py create mode 100644 src/Undefined/bilibili/opus_render.py create mode 100644 src/Undefined/bilibili/opus_sender.py create mode 100644 src/Undefined/skills/pipelines/bilibili_opus/config.json create mode 100644 src/Undefined/skills/pipelines/bilibili_opus/handler.py create mode 100644 tests/test_bilibili_opus_parser.py create mode 100644 tests/test_bilibili_opus_pipeline.py create mode 100644 tests/test_bilibili_opus_render.py create mode 100644 tests/test_bilibili_opus_sender.py diff --git a/config.toml.example b/config.toml.example index c41ccfb2..1f146956 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1473,6 +1473,18 @@ auto_extract_group_ids = [] # zh: 自动提取功能的私聊白名单(空=跟随全局 access.allowed_private_ids)。 # en: Private chat allowlist for auto-extraction (empty = follow global access.allowed_private_ids). auto_extract_private_ids = [] +# zh: 是否在自动提取中处理 B 站图文(opus / 动态)链接。仍需 auto_extract_enabled 打开。 +# en: Whether Bilibili opus/dynamic links are handled during auto-extraction. auto_extract_enabled must also be on. +opus_enabled = true +# zh: 单条消息最多自动处理几篇图文。<=0 回退 3,>10 截断到 10。 +# en: Max number of opus posts to auto-process from one message. <=0 falls back to 3, >10 is capped to 10. +opus_max_items = 3 +# zh: 图文正文里的图文/视频卡片最多再展开几层(每层都是嵌套合并转发)。0=不展开,只给链接;上限 5。 +# en: Max nesting depth for opus/video cards inside a post body (each level is a nested forward). 0=link only; capped at 5. +opus_nested_depth = 5 +# zh: 单篇图文最多展开多少张嵌套卡片,超出部分降级为一行文本+链接。上限 20。 +# en: Max nested cards expanded per post; extras degrade to a single text line with the link. Capped at 20. +opus_nested_max_cards = 8 # zh: 抖音视频自动提取配置。 # en: Douyin video auto-extraction settings. diff --git a/docs/configuration.md b/docs/configuration.md index cfa58793..c14d99eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1016,6 +1016,10 @@ api_key = "replace-with-your-key" | `danmaku_max_count` | `0` | 最多提取多少条弹幕,`0` 不限 | `<0` 回退 `0` | | `auto_extract_group_ids` | `[]` | 功能级群白名单 | 空时跟随全局 access | | `auto_extract_private_ids` | `[]` | 功能级私聊白名单 | 空时跟随全局 access | +| `opus_enabled` | `true` | 是否在自动提取中处理 B 站图文(opus / 动态)链接 | 仍需 `auto_extract_enabled` 打开 | +| `opus_max_items` | `3` | 单条消息最多自动处理几篇图文 | `<=0` 回退 `3`,`>10` 截断到 `10` | +| `opus_nested_depth` | `5` | 正文里图文/视频卡片最多再展开几层 | `<0` 回退 `0`,`>5` 截断到 `5` | +| `opus_nested_max_cards` | `8` | 单篇图文最多展开多少张嵌套卡片 | `<0` 回退 `0`,`>20` 截断到 `20` | 自动提取行为: - 命中 B 站链接、BV 号或 AV 号后,自动提取会发送一次外层合并转发,固定包含三个节点:视频信息、视频文件或视频状态、弹幕列表。 @@ -1023,6 +1027,13 @@ api_key = "replace-with-your-key" - 弹幕列表节点会按每 100 条弹幕生成一个内层合并转发;每条弹幕对应内层合并转发中的一个节点,便于在客户端逐条查看。 - 视频文件下载、清晰度、时长和体积限制仍由本节配置控制;自动提取的转发消息会通过统一发送层写入历史。后续实时 AI 上下文遇到合并转发时只看到 `forward_...` UID,需要内容时按层调用 `messages.get_forward_msg` 读取。 +图文(opus)自动提取行为: +- 命中 `bilibili.com/opus/`、`t.bilibili.com/`、`b23.tv` 短链或 QQ 小程序分享卡片后发送一次外层合并转发:**第一条节点是图文元数据**(封面、标题、UP主、时间、阅读/点赞/评论/转发、原文链接),**第二条起是正文内容节点**(文本与图片按原始顺序混排)。 +- 正文按单节点 4000 字切分,超出部分追加新的内容节点(节点名形如 `正文 1/3`),不做截断、不丢内容;图片始终保留在原文位置。 +- 正文里的图文卡片与视频卡片各自成为**独立的嵌套合并转发节点**(`嵌套图文: ...` / `嵌套视频: ...`),按 `opus_nested_depth` 与 `opus_nested_max_cards` 递归展开;超出边界的卡片降级为一行 `标题 — 链接` 文本节点,不再发请求。其余卡片类型(链接、商品、直播、投票等)渲染为单个 `链接卡片` 节点。 +- 嵌套视频会真实下载视频文件,复用 `prefer_quality` / `max_duration` / `max_file_size` / `oversize_strategy` 限制;超限或下载失败时只发视频信息节点,不影响整条转发的发送。 +- 图文详情优先请求 `x/polymer/web-dynamic/v1/opus/detail`,失败(含风控、旧版专栏)时自动回退 `x/polymer/web-dynamic/v1/detail`(带 WBI 签名);两者返回的 `modules` 结构不同,解析层都会兼容。 + --- ### 4.21.1 `[douyin]` 自动提取 diff --git a/docs/development.md b/docs/development.md index 77544b72..66d1a373 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,7 +18,7 @@ src/Undefined/ │ └── multimodal/# 多模态检测、解析与分析 ├── attachments/ # 附件注册、渲染、作用域隔离 ├── arxiv/ # arXiv 论文解析、元信息获取、PDF 下载与发送 -├── bilibili/ # B站视频流解析、分段下载与异步发送 +├── bilibili/ # B站视频流解析、分段下载、异步发送;图文(opus)解析与合并转发 ├── cognitive/ # 认知记忆系统(service/ 门面 + historian/ 史官后台) ├── config/ # 配置系统(parsers/ 域解析 + load_sections/ 分段加载 + loader shim) ├── handlers/ # OneBot 消息分流(message_flow / poke / repeat / auto_extract) @@ -29,7 +29,7 @@ src/Undefined/ │ │ └── cognitive/ # 认知记忆主动暴露工具 (search_events, get_profile 等) │ ├── agents/ # 智能体 (含 runner/ 通用循环子包) │ ├── commands/ # 中心化斜杠指令系统 (实现如 /help, /stats, /admin 等平台功能) -│ ├── pipelines/ # 自动提取管线 (bilibili / arxiv / github 等) +│ ├── pipelines/ # 自动提取管线 (bilibili / bilibili_opus / arxiv / github 等) │ └── anthropic_skills/# Anthropic 协议集成的外部 Skills (兼容 SKILL.md 格式) ├── api/ # Management API + Runtime API │ ├── routes/ # 路由子模块 (chat, tools, naga/, system, memes, memory, cognitive, health) diff --git a/docs/pipelines.md b/docs/pipelines.md index 6086daef..8a796e50 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -1,6 +1,6 @@ # 自动处理管线开发指南 -自动处理管线位于 `src/Undefined/skills/pipelines/`,用于在普通消息进入 AI 自动回复前执行自动提取,例如 Bilibili 视频、抖音视频、arXiv 论文和 GitHub 仓库卡片。斜杠命令优先级高于自动处理管线,命中命令后不会继续触发自动提取或 AI 回复。 +自动处理管线位于 `src/Undefined/skills/pipelines/`,用于在普通消息进入 AI 自动回复前执行自动提取,例如 Bilibili 视频、Bilibili 图文(opus)、抖音视频、arXiv 论文和 GitHub 仓库卡片。斜杠命令优先级高于自动处理管线,命中命令后不会继续触发自动提取或 AI 回复。 `MessageHandler` 启动时会通过异步初始化在线程中加载管线配置和 handler 模块,避免目录扫描、`config.json` 读取和模块导入阻塞事件循环;注册 OneBot 消息回调前会等待首次加载完成,后续热重载也在线程中执行。 @@ -30,6 +30,24 @@ Douyin 自动提取管线命中 `v.douyin.com/...`、`douyin.com/video/` 或 下载链路读取抖音 SSR share 页中的 `window._ROUTER_DATA`,从 `video.play_addr` 提取 token,再按 `[douyin].prefer_ratios` 探测 `aweme/v1/play/`。探测使用 2 字节 Range GET,并优先按 `Content-Range` 中的总长度对重复文件去重,缺失时回退 `Content-Length`。 +## 内置 Bilibili 图文(opus)管线 + +Bilibili 图文管线命中 `bilibili.com/opus/`、`t.bilibili.com/`、`b23.tv` 短链或 QQ 小程序分享卡片后,发送一次外层合并转发,节点顺序固定: + +1. `图文信息`:封面 + 标题 / UP主 / 时间 / 阅读点赞评论转发 / 原文链接; +2. `正文 …`:正文文本与图片按原始顺序混排,按单节点 4000 字切分(`正文 1/3` 这类节点名),不截断、不丢内容; +3. 嵌套节点:正文里的图文卡片与视频卡片各自成为独立嵌套合并转发(`嵌套图文: …` / `嵌套视频: …`),其它卡片类型渲染为单个 `链接卡片` 节点。 + +嵌套展开受 `[bilibili].opus_nested_depth`(默认 5 层)与 `opus_nested_max_cards`(默认 8 张)约束,超出边界时卡片降级为一行 `标题 — 链接` 文本节点。嵌套视频会真实下载视频文件并复用视频侧的清晰度/时长/体积限制,超限或失败时只发信息节点。 + +数据来源与解析: + +- 优先 `https://api.bilibili.com/x/polymer/web-dynamic/v1/opus/detail`(`modules` 为列表,按 `module_type` 分组); +- 失败时回退 `https://api.bilibili.com/x/polymer/web-dynamic/v1/detail`(`modules` 为字典,正文在 `module_dynamic.desc.text`,图片在 `module_dynamic.major.draw`); +- `OpusInfo` 的 `blocks` 由 `module_content.paragraphs[]` 按 `para_type` 转换而来:文本 1、图片 2、分割线 3、块引用 4、列表 5、链接卡片 6、代码 7。 + +管线只在 `auto_extract_enabled` 与 `opus_enabled` 同时为真、且会话命中白名单时生效;与视频管线相互独立,同一条消息同时包含 BV 号与图文链接时两条管线各自发送。 + ## 目录结构 ```text @@ -41,6 +59,9 @@ src/Undefined/skills/pipelines/ ├── bilibili/ │ ├── config.json │ └── handler.py +├── bilibili_opus/ +│ ├── config.json +│ └── handler.py ├── douyin/ │ ├── config.json │ └── handler.py diff --git a/pyproject.toml b/pyproject.toml index 2797b30e..753d3387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ exclude = [ python_version = "3.12" strict = true ignore_missing_imports = true -exclude = ["^code/", "^apps/.*/src-tauri/target/"] +exclude = ["^code/", "^apps/.*/src-tauri/target/", "^data/cache/uv/"] [tool.ruff] exclude = ["code"] diff --git a/src/Undefined/bilibili/__init__.py b/src/Undefined/bilibili/__init__.py index dbb11b99..08a6f818 100644 --- a/src/Undefined/bilibili/__init__.py +++ b/src/Undefined/bilibili/__init__.py @@ -1,8 +1,12 @@ """Bilibili 视频提取模块 -提供 B 站视频标识符解析、视频下载和发送功能。 +提供 B 站视频标识符解析、视频下载和发送功能,以及图文(opus)解析。 """ +from Undefined.bilibili.opus_parser import ( + extract_opus_from_json_message, + extract_opus_ids_with_shortlinks, +) from Undefined.bilibili.parser import ( extract_bilibili_ids, extract_from_json_message, @@ -12,5 +16,7 @@ __all__ = [ "extract_bilibili_ids", "extract_from_json_message", + "extract_opus_from_json_message", + "extract_opus_ids_with_shortlinks", "normalize_to_bvid", ] diff --git a/src/Undefined/bilibili/api_client.py b/src/Undefined/bilibili/api_client.py index 163548c0..64672a8e 100644 --- a/src/Undefined/bilibili/api_client.py +++ b/src/Undefined/bilibili/api_client.py @@ -2,11 +2,12 @@ from __future__ import annotations +import logging from typing import Any import httpx -from Undefined.bilibili.errors import ApiResponseError +from Undefined.bilibili.errors import ApiResponseError, OpusUnavailableError from Undefined.bilibili.models import VideoInfo, VideoStats from Undefined.bilibili.wbi import build_signed_params_sync, parse_cookie_string from Undefined.skills.http_config import build_httpx_client_kwargs @@ -15,6 +16,39 @@ _BILIBILI_API_VIEW_WBI = "https://api.bilibili.com/x/web-interface/wbi/view" _BILIBILI_API_PLAYURL = "https://api.bilibili.com/x/player/playurl" _BILIBILI_API_PLAYURL_WBI = "https://api.bilibili.com/x/player/wbi/playurl" +_BILIBILI_API_OPUS_DETAIL = ( + "https://api.bilibili.com/x/polymer/web-dynamic/v1/opus/detail" +) +_BILIBILI_API_DYNAMIC_DETAIL = ( + "https://api.bilibili.com/x/polymer/web-dynamic/v1/detail" +) + +# 图文详情接口的 features 参数;htmlNewStyle 用于让旧版专栏返回新版正文结构 +OPUS_DETAIL_FEATURES: tuple[str, ...] = ( + "onlyfansVote", + "onlyfansAssetsV2", + "decorationCard", + "htmlNewStyle", + "ugcDelete", + "editable", + "opusPrivateVisible", + "tribeeEdit", + "avatarAutoTheme", + "avatarTypeOpus", +) + +# 通用动态详情接口的 features 参数;itemOpusStyle 让动态按图文风格返回 +DYNAMIC_DETAIL_FEATURES: tuple[str, ...] = ( + "itemOpusStyle", + "opusBigCover", + "onlyfansVote", + "endFooterHidden", + "decorationCard", + "onlyfansAssetsV2", + "ugcDelete", + "onlyfansQaCard", + "commentsNewVersion", +) DEFAULT_HEADERS: dict[str, str] = { "User-Agent": ( @@ -25,6 +59,8 @@ "Referer": "https://www.bilibili.com", } +logger = logging.getLogger(__name__) + def _api_message(data: dict[str, Any]) -> str: return str(data.get("message") or data.get("msg") or "未知错误") @@ -186,3 +222,76 @@ def get_playurl(self, bvid: str, cid: int) -> dict[str, Any]: if not isinstance(data, dict): raise ApiResponseError("播放流响应缺少 data") return data + + def get_opus_item(self, dynamic_id: str) -> dict[str, Any]: + """获取图文 / 动态的 ``data.item`` 原始结构。 + + 先请求 ``opus/detail``;失败(含 ``-352`` 风控、旧版专栏要求 + ``htmlNewStyle``、以及图文详情接口未覆盖的动态)时回退到通用 + ``web-dynamic/v1/detail``(带 WBI 签名)。 + """ + opus_id = str(dynamic_id).strip() + if not opus_id: + raise OpusUnavailableError("图文 ID 为空") + + primary_error: Exception | None = None + try: + payload = self._request_json( + _BILIBILI_API_OPUS_DETAIL, + { + "id": opus_id, + "features": ",".join(OPUS_DETAIL_FEATURES), + "timezone_offset": -480, + }, + ) + if int(payload.get("code", -1)) == 0: + return self._extract_opus_item(payload, opus_id) + primary_error = OpusUnavailableError( + f"获取图文信息失败: {_api_message(payload)}" + ) + except OpusUnavailableError as exc: + primary_error = exc + except Exception as exc: # 网络 / JSON 异常都走回退 + primary_error = exc + logger.debug("[Bilibili] opus/detail 请求异常,尝试通用动态接口: %s", exc) + + fallback_error: Exception | None = None + try: + payload = self.request_with_wbi_fallback( + endpoint=_BILIBILI_API_DYNAMIC_DETAIL, + params={ + "id": opus_id, + "features": ",".join(DYNAMIC_DETAIL_FEATURES), + "timezone_offset": -480, + }, + ) + if int(payload.get("code", -1)) == 0: + return self._extract_opus_item(payload, opus_id) + fallback_error = OpusUnavailableError( + f"获取动态信息失败: {_api_message(payload)}" + ) + except OpusUnavailableError as exc: + fallback_error = exc + except Exception as exc: + fallback_error = exc + + raise OpusUnavailableError( + f"图文 {opus_id} 不可用: {fallback_error or primary_error or '未知错误'}" + ) + + @staticmethod + def _extract_opus_item(payload: dict[str, Any], opus_id: str) -> dict[str, Any]: + """校验响应并返回 ``data.item``。""" + data = payload.get("data") + if not isinstance(data, dict): + raise OpusUnavailableError(f"图文 {opus_id} 响应缺少 data") + # data.fallback 非空表示该动态无法以图文形态返回(例如过旧的内容) + fallback = data.get("fallback") + item = data.get("item") + if not isinstance(item, dict) or not item: + detail = f"(fallback={fallback!r})" if fallback else "" + raise OpusUnavailableError(f"图文 {opus_id} 没有可用内容{detail}") + modules = item.get("modules") + if not isinstance(modules, (list, dict)) or not modules: + raise OpusUnavailableError(f"图文 {opus_id} 响应缺少 modules") + return item diff --git a/src/Undefined/bilibili/downloader.py b/src/Undefined/bilibili/downloader.py index 65df4f51..ffacff78 100644 --- a/src/Undefined/bilibili/downloader.py +++ b/src/Undefined/bilibili/downloader.py @@ -6,6 +6,7 @@ from functools import partial import logging from pathlib import Path +from typing import Any import uuid from Undefined.bilibili.api_client import BilibiliApiClient @@ -23,6 +24,7 @@ "VideoInfo", "cleanup_file", "download_video", + "get_opus_item", "get_video_info", ] @@ -39,6 +41,21 @@ async def get_video_info( return await asyncio.to_thread(partial(_get_video_info_sync, bvid, cookie=cookie)) +async def get_opus_item( + dynamic_id: str, + cookie: str = "", +) -> dict[str, Any]: + """获取图文 / 动态的原始 ``data.item`` 结构。""" + return await asyncio.to_thread( + partial(_get_opus_item_sync, dynamic_id, cookie=cookie) + ) + + +def _get_opus_item_sync(dynamic_id: str, *, cookie: str = "") -> dict[str, Any]: + with BilibiliApiClient(cookie=cookie, timeout=_DEFAULT_TIMEOUT_SECONDS) as client: + return client.get_opus_item(dynamic_id) + + def _get_video_info_sync(bvid: str, *, cookie: str = "") -> VideoInfo: with BilibiliApiClient(cookie=cookie, timeout=_DEFAULT_TIMEOUT_SECONDS) as client: return client.get_video_info(bvid) diff --git a/src/Undefined/bilibili/errors.py b/src/Undefined/bilibili/errors.py index 11506fdd..4e6e680e 100644 --- a/src/Undefined/bilibili/errors.py +++ b/src/Undefined/bilibili/errors.py @@ -11,6 +11,10 @@ class ApiResponseError(BilibiliError): """B 站 API 返回失败或格式异常。""" +class OpusUnavailableError(ApiResponseError): + """图文不存在、已删除,或响应缺少可用内容。""" + + class DownloadError(BilibiliError): """视频流下载或合并失败。""" diff --git a/src/Undefined/bilibili/format.py b/src/Undefined/bilibili/format.py new file mode 100644 index 00000000..4fda71a2 --- /dev/null +++ b/src/Undefined/bilibili/format.py @@ -0,0 +1,76 @@ +"""B 站内容展示用的共享格式化助手。 + +这里只放纯函数:数字缩写、时长、时间戳、以及按长度切分文本。发送层 +(``bilibili.sender`` / ``bilibili.opus_sender``)与 ``utils/sender.py`` +共用同一套语义,避免各写一份。 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +# QQ / OneBot 单条文本的保守上限;与 ``utils.sender.MAX_MESSAGE_LENGTH`` 保持一致。 +# 这里单独定义一份,避免 ``bilibili`` 包反向依赖发送器实现。 +MAX_TEXT_LENGTH = 4000 + +# B 站展示时间统一使用北京时间(API 通过 timezone_offset=-480 对齐) +BEIJING_TIMEZONE = timezone(timedelta(hours=8)) + + +def format_count(value: int) -> str: + """把计数格式化为中文缩写(1.2万 / 1.5亿)。""" + if value < 0: + value = 0 + if value >= 100_000_000: + return f"{value / 100_000_000:.1f}亿" + if value >= 10_000: + return f"{value / 10_000:.1f}万" + return str(value) + + +def format_duration(seconds: int) -> str: + """把秒数格式化为 mm:ss 或 h:mm:ss。""" + seconds = max(0, int(seconds)) + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def format_progress(progress_ms: int) -> str: + """把弹幕时间戳(毫秒)格式化为 mm:ss。""" + return format_duration(max(0, int(progress_ms)) // 1000) + + +def format_timestamp(timestamp: int) -> str: + """把 UNIX 秒级时间戳格式化为北京时间 ``YYYY-MM-DD HH:MM``。""" + if timestamp <= 0: + return "" + try: + moment = datetime.fromtimestamp(int(timestamp), tz=BEIJING_TIMEZONE) + except (OverflowError, OSError, ValueError): + return "" + return moment.strftime("%Y-%m-%d %H:%M") + + +def split_text_chunks(text: str, limit: int = MAX_TEXT_LENGTH) -> list[str]: + """按长度分片,优先在换行后切分并保留原始文本。 + + 与 ``utils.sender._split_text_chunks`` 同语义:任何字符都不会丢失, + 无换行的超长段落会被硬切。 + """ + if not text: + return [] + size = max(1, int(limit)) + chunks: list[str] = [] + start = 0 + while start < len(text): + end = min(start + size, len(text)) + if end < len(text): + newline = text.rfind("\n", start, end) + if newline >= start: + end = newline + 1 + chunks.append(text[start:end]) + start = end + return chunks diff --git a/src/Undefined/bilibili/models.py b/src/Undefined/bilibili/models.py index 773f37fe..50cba83c 100644 --- a/src/Undefined/bilibili/models.py +++ b/src/Undefined/bilibili/models.py @@ -64,3 +64,100 @@ class DanmakuItem: mid_hash: str = "" color: int = 0 weight: int = 0 + + +@dataclass(slots=True, frozen=True) +class OpusStats: + """图文互动统计。""" + + view: int = 0 + like: int = 0 + comment: int = 0 + repost: int = 0 + coin: int = 0 + favorite: int = 0 + + +@dataclass(slots=True, frozen=True) +class OpusAuthor: + """图文作者信息。""" + + mid: int = 0 + name: str = "" + avatar_url: str = "" + + +@dataclass(slots=True, frozen=True) +class TextBlock: + """图文段落中的纯文本。""" + + text: str + + +@dataclass(slots=True, frozen=True) +class ImageBlock: + """图文段落中的图片组。""" + + urls: tuple[str, ...] + + +@dataclass(slots=True, frozen=True) +class VideoCardBlock: + """指向投稿视频的卡片段落。""" + + bvid: str = "" + title: str = "" + cover_url: str = "" + jump_url: str = "" + + +@dataclass(slots=True, frozen=True) +class OpusCardBlock: + """指向另一篇图文的卡片段落。""" + + opus_id: str = "" + title: str = "" + cover_url: str = "" + jump_url: str = "" + + +@dataclass(slots=True, frozen=True) +class LinkCardBlock: + """其它类型的卡片段落(商品 / 直播 / 投票 / 通用链接等)。""" + + title: str = "" + jump_url: str = "" + cover_url: str = "" + + +OpusBlock = TextBlock | ImageBlock | VideoCardBlock | OpusCardBlock | LinkCardBlock + + +@dataclass(slots=True, frozen=True) +class OpusInfo: + """图文(opus / 动态)基本信息。""" + + opus_id: str + title: str + blocks: tuple[OpusBlock, ...] + author: OpusAuthor = OpusAuthor() + stats: OpusStats = OpusStats() + pub_ts: int = 0 + cover_url: str = "" + dynamic_type_id: str = "" + is_forward: bool = False + forward_origin: OpusAuthor | None = None + + @property + def url(self) -> str: + """标准图文链接。""" + return f"https://www.bilibili.com/opus/{self.opus_id}" + + @property + def images(self) -> tuple[str, ...]: + """按顺序收集全部图片 URL。""" + urls: list[str] = [] + for block in self.blocks: + if isinstance(block, ImageBlock): + urls.extend(block.urls) + return tuple(urls) diff --git a/src/Undefined/bilibili/opus_parser.py b/src/Undefined/bilibili/opus_parser.py new file mode 100644 index 00000000..770c9dee --- /dev/null +++ b/src/Undefined/bilibili/opus_parser.py @@ -0,0 +1,126 @@ +"""B 站图文(opus)标识符解析。 + +从消息文本 / 消息段中提取图文 ID。支持: + +- ``https://www.bilibili.com/opus/<动态id>``(含 ``m.`` 子域与无协议头写法) +- ``https://t.bilibili.com/<动态id>`` +- ``https://b23.tv/<短链>``(解析后二次提取) +- QQ 小程序 / news 分享卡片中的跳转链接 + +与 ``parser.extract_bilibili_ids`` 独立:图文 ID 与 BV 号互不干扰。 +""" + +from __future__ import annotations + +import html +import json +import logging +import re +from typing import Any + +from Undefined.bilibili.parser import ( + SHORT_URL_PATTERN, + resolve_short_url, +) + +logger = logging.getLogger(__name__) + +# ---------- 正则 ---------- + +# 动态 ID 为纯数字,且带域名上下文,避免把普通数字误判成图文 ID +OPUS_URL_PATTERN = re.compile( + r"(?:https?://)?(?:www\.|m\.)?bilibili\.com/opus/(\d+)", + re.IGNORECASE, +) +DYNAMIC_ID_URL_PATTERN = re.compile( + r"(?:https?://)?(?:www\.)?t\.bilibili\.com/(\d+)", + re.IGNORECASE, +) + + +def _extract_opus_ids_from_text(text: str) -> list[str]: + """从纯文本中提取图文 ID(不做短链解析,同步操作)。""" + opus_ids: list[str] = [] + seen: set[str] = set() + for pattern in (OPUS_URL_PATTERN, DYNAMIC_ID_URL_PATTERN): + for match in pattern.finditer(text): + opus_id = match.group(1) + if opus_id in seen: + continue + seen.add(opus_id) + opus_ids.append(opus_id) + return opus_ids + + +async def extract_opus_ids_with_shortlinks(text: str) -> list[str]: + """从纯文本中提取图文 ID,并解析 b23.tv 短链后二次提取(去重、保序)。""" + opus_ids: list[str] = [] + seen: set[str] = set() + + for opus_id in _extract_opus_ids_from_text(text): + if opus_id not in seen: + seen.add(opus_id) + opus_ids.append(opus_id) + + for match in SHORT_URL_PATTERN.finditer(text): + real_url = await resolve_short_url(match.group(0)) + if not real_url: + continue + for opus_id in _extract_opus_ids_from_text(real_url): + if opus_id not in seen: + seen.add(opus_id) + opus_ids.append(opus_id) + + return opus_ids + + +async def extract_opus_from_json_message( + segments: list[dict[str, Any]], +) -> list[str]: + """从 QQ 消息段中检测 JSON 小程序消息,提取 B 站图文 ID。""" + opus_ids: list[str] = [] + seen: set[str] = set() + + for seg in segments: + if seg.get("type") != "json": + continue + + raw_data = seg.get("data", {}).get("data", "") + if not isinstance(raw_data, str) or not raw_data: + continue + + # 反转义 HTML 实体 + raw_data = html.unescape(raw_data) + + try: + json_data = json.loads(raw_data) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(json_data, dict): + continue + + urls_to_check: list[str] = [] + + meta = json_data.get("meta") + if isinstance(meta, dict): + # detail_1 结构(QQ 小程序卡片) + detail_1 = meta.get("detail_1") + if isinstance(detail_1, dict): + qqdocurl = detail_1.get("qqdocurl", "") + if qqdocurl: + urls_to_check.append(str(qqdocurl)) + + # news 结构 + news = meta.get("news") + if isinstance(news, dict): + jump_url = news.get("jumpUrl", "") + if jump_url: + urls_to_check.append(str(jump_url)) + + for url in urls_to_check: + for opus_id in await extract_opus_ids_with_shortlinks(url): + if opus_id not in seen: + seen.add(opus_id) + opus_ids.append(opus_id) + + return opus_ids diff --git a/src/Undefined/bilibili/opus_render.py b/src/Undefined/bilibili/opus_render.py new file mode 100644 index 00000000..4e3d7e62 --- /dev/null +++ b/src/Undefined/bilibili/opus_render.py @@ -0,0 +1,613 @@ +"""图文响应解析与消息段渲染(纯函数,无 IO)。 + +同一个 ``data.item`` 可能来自两个接口,结构不同: + +- ``x/polymer/web-dynamic/v1/opus/detail``:``modules`` 是 **列表**,按 + ``module_type`` 区分标题 / 作者 / 内容 / 统计等模块。 +- ``x/polymer/web-dynamic/v1/detail``:``modules`` 是 **字典**,正文在 + ``module_dynamic.desc.text``,图片 / 卡片在 ``module_dynamic.major``。 + +本模块同时兼容两种形态,并把段落转换为 :mod:`Undefined.bilibili.models` +里的 ``OpusBlock`` 序列,供发送层拼装合并转发节点。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import re +from typing import Any + +from Undefined.bilibili.format import format_count, format_timestamp +from Undefined.bilibili.models import ( + ImageBlock, + LinkCardBlock, + OpusAuthor, + OpusBlock, + OpusCardBlock, + OpusInfo, + OpusStats, + TextBlock, + VideoCardBlock, +) + +logger = logging.getLogger(__name__) + +OPUS_URL_TEMPLATE = "https://www.bilibili.com/opus/{opus_id}" +_BV_PATTERN = re.compile(r"BV1[1-9A-HJ-NP-Za-km-z]{9}") +_OPUS_ID_PATTERN = re.compile(r"/opus/(\d+)") +_EMOJI_PLACEHOLDER = "[表情]" +_EMPTY_BODY_PLACEHOLDER = "(该图文没有正文内容)" + +# para_type 常量 +_PARA_TEXT = 1 +_PARA_IMAGE = 2 +_PARA_LINE = 3 +_PARA_QUOTE = 4 +_PARA_LIST = 5 +_PARA_LINK_CARD = 6 +_PARA_CODE = 7 + +# 未知卡片类型的兜底文案 +_CARD_TITLE_FALLBACK = "链接卡片" + + +@dataclass(slots=True, frozen=True) +class ParsedOpus: + """图文解析结果。""" + + info: OpusInfo + stats: OpusStats + author: OpusAuthor + + +# ---------- 通用取值助手 ---------- + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _as_str(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, bool): + return "" + if isinstance(value, (int, float)): + return str(value) + return "" + + +def _as_int(value: Any) -> int: + if isinstance(value, bool): + return 0 + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return 0 + return 0 + + +def _first_nonempty(*values: Any) -> str: + for value in values: + text = _as_str(value).strip() + if text: + return text + return "" + + +def _absolutize(url: str) -> str: + """把 B 站返回的无协议 / 协议相对 URL 补齐为 https。""" + text = url.strip() + if not text: + return "" + if text.startswith("//"): + return f"https:{text}" + if text.startswith(("http://", "https://")): + return text + return f"https://{text.lstrip('/')}" + + +# ---------- 富文本节点 ---------- + + +def _render_rich_node(rich: dict[str, Any]) -> str: + """把 ``TEXT_NODE_TYPE_RICH`` 节点渲染成文本。""" + node_type = _as_str(rich.get("type")).upper() + text = _first_nonempty(rich.get("orig_text"), rich.get("text")) + jump_url = _absolutize(_as_str(rich.get("jump_url"))) + + if node_type.endswith("EMOJI"): + return text or _EMOJI_PLACEHOLDER + if node_type.endswith("VIEW_PICTURE"): + return "[图片]" + if node_type.endswith("FORMULA"): + latex = _first_nonempty(_as_dict(rich.get("formula")).get("latex_content")) + return f"${latex}$" if latex else text + if jump_url and text and jump_url not in text: + return f"{text} ({jump_url})" + return text or jump_url + + +def _render_text_nodes(nodes: Any) -> str: + """把段落里的文本节点数组渲染成一段文本。""" + parts: list[str] = [] + for raw_node in _as_list(nodes): + node = _as_dict(raw_node) + node_type = _as_str(node.get("type")).upper() + if node_type == "TEXT_NODE_TYPE_WORD": + parts.append(_as_str(_as_dict(node.get("word")).get("words"))) + elif node_type == "TEXT_NODE_TYPE_RICH": + parts.append(_render_rich_node(_as_dict(node.get("rich")))) + elif node_type == "TEXT_NODE_TYPE_FORMULA": + latex = _first_nonempty(_as_dict(node.get("formula")).get("latex_content")) + parts.append(f"${latex}$" if latex else "") + return "".join(parts) + + +# ---------- 卡片 ---------- + + +def _card_title_and_url(card: dict[str, Any]) -> tuple[str, str, str]: + """从任意卡片结构中尽力取出「标题 / 跳转链接 / 封面」。""" + title = "" + jump_url = "" + cover = "" + + for key in ("opus", "common", "ugc", "goods", "live", "music", "vote"): + candidate = _as_dict(card.get(key)) + if not candidate: + continue + title = title or _first_nonempty(candidate.get("title"), candidate.get("name")) + jump_url = jump_url or _as_str(candidate.get("jump_url")) + cover = cover or _as_str(candidate.get("cover")) + item_null = _as_dict(card.get("item_null")) + if not title and item_null: + title = _first_nonempty(item_null.get("text")) + + return ( + title or _CARD_TITLE_FALLBACK, + _absolutize(jump_url), + _absolutize(cover), + ) + + +def _opus_card_ref(card: dict[str, Any], opus: dict[str, Any]) -> OpusCardBlock: + """把 ``LINK_CARD_TYPE_OPUS`` 卡片转成图文引用。""" + title, jump_url, cover = _card_title_and_url(card) + opus_id = "" + oid = _as_str(card.get("oid")).strip() + if oid.isdigit(): + opus_id = oid + if not opus_id: + match = _OPUS_ID_PATTERN.search(jump_url) + if match: + opus_id = match.group(1) + author = _as_dict(opus.get("author")) + if not title: + title = _first_nonempty(opus.get("title"), author.get("name")) + return OpusCardBlock( + opus_id=opus_id, + title=title or _CARD_TITLE_FALLBACK, + cover_url=cover or _absolutize(_as_str(opus.get("cover"))), + jump_url=jump_url + or (OPUS_URL_TEMPLATE.format(opus_id=opus_id) if opus_id else ""), + ) + + +def _parse_link_card(paragraph: dict[str, Any]) -> OpusBlock | None: + """解析 ``para_type=6`` 的链接卡片段落。""" + card = _as_dict(_as_dict(paragraph.get("link_card")).get("card")) + if not card: + return None + + card_type = _as_str(card.get("type")).upper() + if card_type == "LINK_CARD_TYPE_OPUS": + return _opus_card_ref(card, _as_dict(card.get("opus"))) + + if card_type == "LINK_CARD_TYPE_UGC": + ugc = _as_dict(card.get("ugc")) + title, jump_url, cover = _card_title_and_url(card) + bvid = _first_nonempty(ugc.get("bvid")) + if not bvid: + match = _BV_PATTERN.search(jump_url) + if match: + bvid = match.group(0) + return VideoCardBlock( + bvid=bvid, + title=title or _CARD_TITLE_FALLBACK, + cover_url=cover or _absolutize(_as_str(ugc.get("cover"))), + jump_url=jump_url, + ) + + title, jump_url, cover = _card_title_and_url(card) + return LinkCardBlock(title=title, jump_url=jump_url, cover_url=cover) + + +# ---------- 段落 ---------- + + +def _parse_list_paragraph(paragraph: dict[str, Any]) -> OpusBlock | None: + list_data = _as_dict(paragraph.get("list")) + items = _as_list(list_data.get("items")) + if not items: + return None + + ordered = _as_int(list_data.get("style")) == 1 + lines: list[str] = [] + for index, raw_item in enumerate(items, start=1): + item = _as_dict(raw_item) + text = _render_text_nodes(item.get("nodes")) + if not text.strip(): + continue + level = max(0, _as_int(item.get("level"))) + indent = " " * level + if ordered: + order = _as_int(item.get("order")) or index + lines.append(f"{indent}{order}. {text}") + else: + lines.append(f"{indent}- {text}") + return TextBlock("\n".join(lines)) if lines else None + + +def _parse_paragraph(paragraph: dict[str, Any]) -> OpusBlock | None: + para_type = _as_int(paragraph.get("para_type")) + + if para_type == _PARA_IMAGE: + urls = tuple( + url + for url in ( + _absolutize(_as_str(_as_dict(pic).get("url"))) + for pic in _as_list(_as_dict(paragraph.get("pic")).get("pics")) + ) + if url + ) + return ImageBlock(urls) if urls else None + + if para_type == _PARA_LINE: + line_pic = _as_dict(_as_dict(paragraph.get("line")).get("pic")) + pic_url = _absolutize(_as_str(line_pic.get("url"))) + return ImageBlock((pic_url,)) if pic_url else TextBlock("———") + + if para_type == _PARA_QUOTE: + blockquote = _as_dict(paragraph.get("blockquote")) + source = blockquote or paragraph + text = _render_text_nodes(_as_dict(source.get("text")).get("nodes")) + if not text.strip(): + return None + quoted = "\n".join(f"> {line}" if line else ">" for line in text.split("\n")) + return TextBlock(quoted) + + if para_type == _PARA_LIST: + return _parse_list_paragraph(paragraph) + + if para_type == _PARA_LINK_CARD: + return _parse_link_card(paragraph) + + if para_type == _PARA_CODE: + code = _as_dict(paragraph.get("code")) + content = _as_str(code.get("content")) + if not content: + return None + lang = _as_str(code.get("lang")).replace("language-", "").strip() + return TextBlock(f"```{lang}\n{content}\n```") + + if para_type in (0, _PARA_TEXT): + text = _render_text_nodes(_as_dict(paragraph.get("text")).get("nodes")) + return TextBlock(text) if text.strip() else None + + # 未知段落类型:尽力按文本处理 + text = _render_text_nodes(_as_dict(paragraph.get("text")).get("nodes")) + return TextBlock(text) if text.strip() else None + + +def _parse_blocks_from_paragraphs(paragraphs: Any) -> tuple[OpusBlock, ...]: + blocks: list[OpusBlock] = [] + for raw_paragraph in _as_list(paragraphs): + block = _parse_paragraph(_as_dict(raw_paragraph)) + if block is not None: + blocks.append(block) + return tuple(blocks) + + +# ---------- 模块 ---------- + + +def _author_from_module(module_author: dict[str, Any]) -> OpusAuthor: + return OpusAuthor( + mid=_as_int(module_author.get("mid")), + name=_as_str(module_author.get("name")).strip(), + avatar_url=_absolutize(_as_str(module_author.get("face"))), + ) + + +def _stats_from_module(module_stat: dict[str, Any]) -> OpusStats: + def count(key: str) -> int: + return _as_int(_as_dict(module_stat.get(key)).get("count")) + + return OpusStats( + view=count("view"), + like=count("like"), + comment=count("comment"), + repost=count("forward"), + coin=count("coin"), + favorite=count("favorite"), + ) + + +def _first_cover(blocks: tuple[OpusBlock, ...]) -> str: + for block in blocks: + if isinstance(block, ImageBlock) and block.urls: + return block.urls[0] + if isinstance(block, (VideoCardBlock, OpusCardBlock, LinkCardBlock)): + if block.cover_url: + return block.cover_url + return "" + + +def _self_opus_block(opus: dict[str, Any], opus_id: str) -> OpusBlock | None: + """``MAJOR_TYPE_OPUS`` 的 major 块:指向自己时不是卡片,避免自引用递归。""" + jump_url = _absolutize(_as_str(opus.get("jump_url"))) + match = _OPUS_ID_PATTERN.search(jump_url) + ref_id = match.group(1) if match else "" + if ref_id and ref_id == opus_id: + return None + return OpusCardBlock( + opus_id=ref_id, + title=_as_str(opus.get("title")), + cover_url=_absolutize(_as_str(opus.get("cover"))), + jump_url=jump_url, + ) + + +def _major_block(major: dict[str, Any], opus_id: str = "") -> OpusBlock | None: + major_type = _as_str(major.get("type")).upper() + if major_type == "MAJOR_TYPE_ARCHIVE": + archive = _as_dict(major.get("archive")) + return VideoCardBlock( + bvid=_as_str(archive.get("bvid")), + title=_as_str(archive.get("title")), + cover_url=_absolutize(_as_str(archive.get("cover"))), + jump_url=_absolutize(_as_str(archive.get("jump_url"))), + ) + if major_type == "MAJOR_TYPE_OPUS": + return _self_opus_block(_as_dict(major.get("opus")), opus_id) + if major_type in ("MAJOR_TYPE_COMMON", "MAJOR_TYPE_LIVE_RCMD"): + title, jump_url, cover = _card_title_and_url(major) + return LinkCardBlock(title=title, jump_url=jump_url, cover_url=cover) + if major_type in ("MAJOR_TYPE_DRAW", "MAJOR_TYPE_ARTICLE"): + return None + return None + + +def _major_opus_blocks(major: dict[str, Any], opus_id: str) -> tuple[OpusBlock, ...]: + """通用动态接口下正文位于 ``major.opus.summary``,长文只给摘要。""" + opus = _as_dict(major.get("opus")) + if not opus: + return () + summary = _as_dict(opus.get("summary")) + + blocks = _parse_blocks_from_paragraphs(summary.get("paragraphs")) + if not blocks: + text = ( + _as_str(summary.get("text")).strip() + or _render_text_nodes(summary.get("rich_text_nodes")).strip() + ) + if text: + blocks = (TextBlock(text),) + + pic_urls = tuple( + url + for url in ( + _absolutize(_as_str(_as_dict(pic).get("url"))) + for pic in _as_list(opus.get("pics")) + ) + if url + ) + if pic_urls: + blocks = (*blocks, ImageBlock(pic_urls)) + if summary.get("has_more"): + blocks = (*blocks, TextBlock("(仅摘要,完整正文请见原文链接)")) + return blocks + + +def _parse_dict_modules(modules: dict[str, Any], opus_id: str) -> ParsedOpus: + """解析 ``web-dynamic/v1/detail`` 的字典形态 modules。""" + module_author = _as_dict(modules.get("module_author")) + author = _author_from_module(module_author) + stats = _stats_from_module(_as_dict(modules.get("module_stat"))) + + dynamic = _as_dict(modules.get("module_dynamic")) + blocks = _parse_blocks_from_paragraphs(dynamic.get("paragraphs")) + + major = _as_dict(dynamic.get("major")) + major_type = _as_str(major.get("type")).upper() + if major_type == "MAJOR_TYPE_OPUS": + # 通用动态接口把正文放在 major.opus.summary 里 + blocks = (*blocks, *_major_opus_blocks(major, opus_id)) + if not blocks: + desc_text = _as_str(_as_dict(dynamic.get("desc")).get("text")) + if desc_text.strip(): + blocks = (TextBlock(desc_text),) + if not blocks: + major_block = _major_block(major, opus_id) + if major_block is not None: + blocks = (major_block,) + + draw_items = _as_list(_as_dict(major.get("draw")).get("items")) + if draw_items: + urls = tuple( + url + for url in ( + _absolutize(_as_str(_as_dict(item).get("src"))) for item in draw_items + ) + if url + ) + if urls: + blocks = (*blocks, ImageBlock(urls)) + + info = OpusInfo( + opus_id=opus_id, + title="", + blocks=blocks, + author=author, + stats=stats, + pub_ts=_as_int(module_author.get("pub_ts")), + cover_url=_first_cover(blocks), + dynamic_type_id=_as_str(dynamic.get("type")), + ) + return ParsedOpus(info=info, stats=stats, author=author) + + +def _parse_list_modules(modules: list[Any], opus_id: str) -> ParsedOpus: + """解析 ``opus/detail`` 的列表形态 modules。""" + by_type: dict[str, dict[str, Any]] = {} + for raw_module in modules: + module = _as_dict(raw_module) + module_type = _as_str(module.get("module_type")).upper() + if module_type: + by_type[module_type] = module + + author_module = _as_dict(by_type.get("MODULE_TYPE_AUTHOR")) + module_author = _as_dict(author_module.get("module_author")) + author = _author_from_module(module_author) + stats = _stats_from_module( + _as_dict(_as_dict(by_type.get("MODULE_TYPE_STAT")).get("module_stat")) + ) + title = _as_str( + _as_dict(_as_dict(by_type.get("MODULE_TYPE_TITLE")).get("module_title")).get( + "text" + ) + ).strip() + + content_module = _as_dict(by_type.get("MODULE_TYPE_CONTENT")) + module_content = _as_dict(content_module.get("module_content")) + blocks = _parse_blocks_from_paragraphs(module_content.get("paragraphs")) + if not blocks: + blocks = (TextBlock(_EMPTY_BODY_PLACEHOLDER),) + + info = OpusInfo( + opus_id=opus_id, + title=title, + blocks=blocks, + author=author, + stats=stats, + pub_ts=_as_int(module_author.get("pub_ts")), + cover_url=_first_cover(blocks), + ) + return ParsedOpus(info=info, stats=stats, author=author) + + +def parse_opus_item(item: dict[str, Any]) -> ParsedOpus: + """把 ``data.item`` 解析成 :class:`ParsedOpus`。""" + if not isinstance(item, dict) or not item: + raise ValueError("图文 item 为空") + + opus_id = _first_nonempty(item.get("id_str"), item.get("id")) + basic = _as_dict(item.get("basic")) + modules = item.get("modules") + + if isinstance(modules, list): + parsed = _parse_list_modules(modules, opus_id) + elif isinstance(modules, dict): + parsed = _parse_dict_modules(modules, opus_id) + else: + raise ValueError("图文 item 缺少 modules") + + info = parsed.info + title = _first_nonempty(info.title, basic.get("title")) + dynamic_type_id = info.dynamic_type_id or _as_str(item.get("type")) + if title != info.title: + info = OpusInfo( + opus_id=info.opus_id, + title=title, + blocks=info.blocks, + author=info.author, + stats=info.stats, + pub_ts=info.pub_ts, + cover_url=info.cover_url, + dynamic_type_id=dynamic_type_id, + is_forward=info.is_forward, + forward_origin=info.forward_origin, + ) + return ParsedOpus(info=info, stats=parsed.stats, author=parsed.author) + + +# ---------- 文本摘要与消息段 ---------- + + +def format_opus_stats(stats: OpusStats, *, view_label: str = "阅读") -> str: + """格式化互动数据行。""" + parts = [ + f"{view_label} {format_count(stats.view)}", + f"点赞 {format_count(stats.like)}", + f"评论 {format_count(stats.comment)}", + f"转发 {format_count(stats.repost)}", + ] + return "数据: " + " | ".join(parts) + + +def format_opus_info(info: OpusInfo) -> str: + """工具结果 / 日志用的纯文本摘要。""" + lines = [ + f"「{info.title or '无标题'}」", + f"图文 ID: {info.opus_id}", + f"UP主: {info.author.name or '未知'}", + ] + published = format_timestamp(info.pub_ts) + if published: + lines.append(f"发布时间: {published}") + lines.append(format_opus_stats(info.stats)) + if info.cover_url: + lines.append(f"封面: {info.cover_url}") + if info.images: + lines.append(f"图片: {len(info.images)} 张") + lines.append(info.url) + return "\n".join(lines) + + +def format_opus_history_message(info: OpusInfo, *, video_status: str = "") -> str: + """写入历史的可读摘要(含正文文本,便于后续 AI 检索)。""" + lines = [ + f"[Bilibili 图文] 「{info.title or '无标题'}」", + f"图文 ID: {info.opus_id}", + f"UP主: {info.author.name or '未知'}", + ] + published = format_timestamp(info.pub_ts) + if published: + lines.append(f"发布时间: {published}") + lines.append(format_opus_stats(info.stats)) + if video_status: + lines.append(f"视频: {video_status}") + lines.append(f"图片: {len(info.images)} 张") + text = format_blocks_text(info.blocks) + if text: + lines.extend(["---", text]) + lines.append(info.url) + return "\n".join(lines) + + +def format_blocks_text(blocks: tuple[OpusBlock, ...]) -> str: + """把块序列渲染成纯文本(图片以 ``[图片]`` 占位)。""" + parts: list[str] = [] + for block in blocks: + if isinstance(block, TextBlock): + parts.append(block.text) + elif isinstance(block, ImageBlock): + parts.append(f"[图片 x{len(block.urls)}]") + elif isinstance(block, VideoCardBlock): + parts.append(f"[视频] {block.title} {block.jump_url}".strip()) + elif isinstance(block, OpusCardBlock): + parts.append(f"[图文] {block.title} {block.jump_url}".strip()) + else: + parts.append(f"[卡片] {block.title} {block.jump_url}".strip()) + return "\n\n".join(part for part in parts if part) diff --git a/src/Undefined/bilibili/opus_sender.py b/src/Undefined/bilibili/opus_sender.py new file mode 100644 index 00000000..2555d6b0 --- /dev/null +++ b/src/Undefined/bilibili/opus_sender.py @@ -0,0 +1,533 @@ +"""B 站图文(opus)合并转发发送。 + +节点结构固定为: + +1. 第一条节点:图文元数据(标题 / UP主 / 时间 / 数据 / 链接,含封面图) +2. 之后为内容节点:正文文本 + 图片,按单节点字数上限切分,顺序不变 +3. 内容之后再追加嵌套节点:图文卡片 / 视频卡片各自成为独立转发节点, + 其它卡片渲染为单独一行节点 + +嵌套展开受 ``depth``(层数)与 ``budget``(单条图文最多展开多少张卡片) +约束,超出边界的卡片降级为纯文本节点,不再发请求。 +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +from Undefined.bilibili.downloader import ( + QUALITY_MAP, + cleanup_file, + download_video, + get_opus_item, + get_video_info, +) +from Undefined.bilibili.errors import OpusUnavailableError +from Undefined.bilibili.format import ( + MAX_TEXT_LENGTH, + format_timestamp, + split_text_chunks, +) +from Undefined.bilibili.models import ( + ImageBlock, + LinkCardBlock, + OpusBlock, + OpusCardBlock, + OpusInfo, + TextBlock, + VideoCardBlock, + VideoInfo, +) +from Undefined.bilibili.opus_render import ( + format_opus_history_message, + format_opus_stats, + parse_opus_item, +) +from Undefined.bilibili.sender import build_bilibili_video_nodes +from Undefined.utils.io import get_file_size + +if TYPE_CHECKING: + from Undefined.utils.sender import MessageSender + +logger = logging.getLogger(__name__) + +_BOT_NAME = "Undefined" +_DEFAULT_BOT_UIN = "10000" + +# 获取失败时可降级的语义(投递不确定 / 文件传输错误必须上抛) +_FATAL_ERROR_FLAGS = ("delivery_uncertain", "file_transfer_error") + + +class _ExpansionBudget: + """单条图文的嵌套卡片展开预算。""" + + def __init__(self, limit: int) -> None: + self.remaining = max(0, int(limit)) + + def claim(self) -> bool: + if self.remaining <= 0: + return False + self.remaining -= 1 + return True + + +def _node( + content: str | list[dict[str, Any]], *, name: str = _BOT_NAME +) -> dict[str, Any]: + return { + "type": "node", + "data": { + "name": name, + "uin": _DEFAULT_BOT_UIN, + "content": content, + }, + } + + +# ---------- 节点内容构建 ---------- + + +def _meta_lines(info: OpusInfo) -> list[str]: + lines = [ + f"「{info.title or '无标题'}」", + f"UP主: {info.author.name or '未知'}", + ] + published = format_timestamp(info.pub_ts) + if published: + lines.append(f"时间: {published}") + lines.append(format_opus_stats(info.stats, view_label="阅读")) + if info.is_forward and info.forward_origin is not None: + origin = info.forward_origin.name or "未知" + lines.append(f"转发自: {origin}") + lines.extend(["---", info.url]) + return lines + + +def _build_meta_node(info: OpusInfo) -> dict[str, Any]: + segments: list[dict[str, Any]] = [] + if info.cover_url: + segments.append({"type": "image", "data": {"file": info.cover_url}}) + segments.append({"type": "text", "data": {"text": "\n".join(_meta_lines(info))}}) + return _node(segments, name="图文信息") + + +def render_blocks_to_nodes( + blocks: tuple[OpusBlock, ...], + *, + limit: int = MAX_TEXT_LENGTH, + node_name: str = "正文", +) -> list[dict[str, Any]]: + """把块序列渲染成内容节点列表。 + + - 文本块按 ``limit`` 切分,切分点之后的内容落在新节点; + - 图片块作为消息段插入当前位置,可与文本共处同一节点; + - 节点只有在非空时才会产生。 + """ + node_segments_list: list[list[dict[str, Any]]] = [] + current: list[dict[str, Any]] = [] + + def flush() -> None: + nonlocal current + if current: + node_segments_list.append(current) + current = [] + + def append_text(text: str) -> None: + nonlocal current + chunks = split_text_chunks(text, limit) + for index, chunk in enumerate(chunks): + if index > 0: + flush() + if chunk: + current.append({"type": "text", "data": {"text": chunk}}) + + for block in blocks: + if isinstance(block, TextBlock): + append_text(block.text) + elif isinstance(block, ImageBlock): + for url in block.urls: + if url: + current.append({"type": "image", "data": {"file": url}}) + else: + append_text(_card_block_text(block)) + flush() + + if not node_segments_list: + node_segments_list = [[{"type": "text", "data": {"text": "(无内容)"}}]] + + total = len(node_segments_list) + return [ + _node( + segments, + name=f"{node_name} {index}/{total}" if total > 1 else node_name, + ) + for index, segments in enumerate(node_segments_list, start=1) + ] + + +def _card_block_text(block: OpusBlock) -> str: + if isinstance(block, VideoCardBlock): + label = "视频" + elif isinstance(block, OpusCardBlock): + label = "图文" + else: + label = "卡片" + + parts = [f"[{label}]"] + if isinstance(block, (VideoCardBlock, OpusCardBlock, LinkCardBlock)): + if block.title: + parts.append(block.title) + if block.jump_url: + parts.append(block.jump_url) + return " ".join(parts) + + +def _link_card_node(block: LinkCardBlock) -> dict[str, Any]: + segments: list[dict[str, Any]] = [] + if block.cover_url: + segments.append({"type": "image", "data": {"file": block.cover_url}}) + parts = [part for part in (block.title, block.jump_url) if part] + segments.append({"type": "text", "data": {"text": " — ".join(parts) or "链接卡片"}}) + return _node(segments, name="链接卡片") + + +# ---------- 嵌套卡片 ---------- + + +async def _nested_opus_node( + block: OpusCardBlock, + *, + sender: "MessageSender", + target_type: Literal["group", "private"], + target_id: int, + cookie: str, + config: Any, + budget: _ExpansionBudget, + depth: int, +) -> dict[str, Any]: + label = f"嵌套图文: {block.title}" if block.title else "嵌套图文" + try: + info = await _fetch_opus_info(block.opus_id, cookie=cookie, config=config) + nested_nodes = await build_opus_nodes( + info, + sender=sender, + target_type=target_type, + target_id=target_id, + cookie=cookie, + config=config, + budget=budget, + depth=depth, + ) + return _node(nested_nodes, name=label) + except Exception as exc: + logger.warning( + "[Bilibili] 嵌套图文获取失败: opus=%s err=%s", block.opus_id, exc + ) + return _node(f"嵌套图文获取失败: {exc}", name=label) + + +async def _resolve_nested_video( + block: VideoCardBlock, + *, + cookie: str, + config: Any, +) -> tuple[Path | None, VideoInfo | None, str, str]: + """下载嵌套视频卡片对应的视频文件。 + + Returns: + (视频文件路径 | None, 视频信息 | None, 视频状态文案, 信息节点前缀) + """ + if not block.bvid: + return None, None, "未下载视频(卡片缺少 BV 号)", "" + + prefer_quality = int(_config_value(config, "bilibili_prefer_quality", 80)) + max_duration = int(_config_value(config, "bilibili_max_duration", 600)) + max_file_size = int(_config_value(config, "bilibili_max_file_size", 100)) + oversize_strategy = str( + _config_value(config, "bilibili_oversize_strategy", "downgrade") + ) + + video_path: Path | None = None + video_info: VideoInfo | None = None + video_status = "未发送视频" + info_prefix = "" + try: + video_path, video_info, actual_qn = await download_video( + bvid=block.bvid, + cookie=cookie, + prefer_quality=prefer_quality, + max_duration=max_duration, + ) + if video_path is None: + video_status = f"视频时长 {video_info.duration}s 超过限制,仅发送信息" + info_prefix = f"({video_status})" + else: + file_size_mb = await get_file_size(video_path) / 1024 / 1024 + max_size = max_file_size if max_file_size > 0 else float("inf") + if file_size_mb > max_size: + if oversize_strategy == "downgrade" and actual_qn > 32: + cleanup_file(video_path) + video_path = None + video_path, video_info, actual_qn = await download_video( + bvid=block.bvid, + cookie=cookie, + prefer_quality=_lower_quality(actual_qn), + max_duration=max_duration, + ) + if video_path is not None: + file_size_mb = await get_file_size(video_path) / 1024 / 1024 + if video_path is not None and file_size_mb > max_size: + cleanup_file(video_path) + video_path = None + video_status = f"视频文件 {file_size_mb:.1f}MB 超过限制,仅发送信息" + info_prefix = f"({video_status})" + if video_path is not None: + quality_name = QUALITY_MAP.get(actual_qn, str(actual_qn)) + video_status = f"已附加视频 ({quality_name}, {file_size_mb:.1f}MB)" + except Exception as exc: + logger.warning("[Bilibili] 嵌套视频下载失败: bvid=%s err=%s", block.bvid, exc) + if video_path is not None: + cleanup_file(video_path) + video_path = None + video_status = f"视频处理失败: {exc}" + info_prefix = f"({video_status})" + try: + video_info = await get_video_info(block.bvid, cookie=cookie) + except Exception: + video_info = None + + return video_path, video_info, video_status, info_prefix + + +def _lower_quality(current_qn: int) -> int: + for qn in sorted(QUALITY_MAP.keys(), reverse=True): + if qn < current_qn: + return qn + return 32 + + +async def _nested_video_node( + block: VideoCardBlock, + *, + cookie: str, + config: Any, +) -> dict[str, Any]: + label = f"嵌套视频: {block.title}" if block.title else "嵌套视频" + + video_path, video_info, video_status, info_prefix = await _resolve_nested_video( + block, cookie=cookie, config=config + ) + try: + if video_info is None: + # 拿不到视频信息时退化为卡片信息节点 + lines = [f"「{block.title or '视频'}」", f"BV: {block.bvid or '未知'}"] + if block.jump_url: + lines.extend(["---", block.jump_url]) + segments: list[dict[str, Any]] = [] + if block.cover_url: + segments.append({"type": "image", "data": {"file": block.cover_url}}) + segments.append( + { + "type": "text", + "data": {"text": f"{video_status}\n" + "\n".join(lines)}, + } + ) + return _node(segments, name=label) + + danmaku_enabled = bool(_config_value(config, "bilibili_danmaku_enabled", True)) + nodes, _danmaku, _error = await build_bilibili_video_nodes( + video_info, + video_path=video_path, + video_status=video_status, + info_prefix=info_prefix, + cookie=cookie, + danmaku_enabled=danmaku_enabled, + danmaku_batch_size=int( + _config_value(config, "bilibili_danmaku_batch_size", 100) + ), + danmaku_max_count=int( + _config_value(config, "bilibili_danmaku_max_count", 0) + ), + info_node_name=label, + ) + return _node(nodes, name=label) + finally: + if video_path is not None: + cleanup_file(video_path) + + +def _config_value(config: Any, name: str, default: Any) -> Any: + value = getattr(config, name, None) + return default if value is None else value + + +# ---------- 顶层构建 ---------- + + +async def build_opus_nodes( + info: OpusInfo, + *, + sender: "MessageSender", + target_type: Literal["group", "private"], + target_id: int, + cookie: str = "", + config: Any = None, + budget: _ExpansionBudget | None = None, + depth: int = 0, +) -> list[dict[str, Any]]: + """按「元数据 → 内容 → 嵌套」顺序构建合并转发节点。""" + max_depth = int(_config_value(config, "bilibili_opus_nested_depth", 5)) + max_cards = int(_config_value(config, "bilibili_opus_nested_max_cards", 8)) + if budget is None: + budget = _ExpansionBudget(max_cards) + + nodes: list[dict[str, Any]] = [_build_meta_node(info)] + nodes.extend(render_blocks_to_nodes(info.blocks)) + + for block in info.blocks: + if isinstance(block, LinkCardBlock): + nodes.append(_link_card_node(block)) + continue + if not isinstance(block, (OpusCardBlock, VideoCardBlock)): + continue + if depth >= max_depth or not budget.claim(): + logger.info( + "[Bilibili] 嵌套展开达到边界,降级为文本节点: depth=%s/%s remaining=%s", + depth, + max_depth, + budget.remaining, + ) + nodes.append(_link_card_node(_block_to_link_card(block))) + continue + if isinstance(block, OpusCardBlock): + nodes.append( + await _nested_opus_node( + block, + sender=sender, + target_type=target_type, + target_id=target_id, + cookie=cookie, + config=config, + budget=budget, + depth=depth + 1, + ) + ) + else: + nodes.append(await _nested_video_node(block, cookie=cookie, config=config)) + return nodes + + +def _block_to_link_card(block: OpusBlock) -> LinkCardBlock: + if isinstance(block, VideoCardBlock): + return LinkCardBlock( + title=f"[视频] {block.title}".strip() or "视频卡片", + jump_url=block.jump_url, + cover_url=block.cover_url, + ) + if isinstance(block, OpusCardBlock): + return LinkCardBlock( + title=f"[图文] {block.title}".strip() or "图文卡片", + jump_url=block.jump_url, + cover_url=block.cover_url, + ) + if isinstance(block, LinkCardBlock): + return block + return LinkCardBlock(title="链接卡片") + + +async def _fetch_opus_info(opus_id: str, *, cookie: str, config: Any) -> OpusInfo: + item = await get_opus_item(opus_id, cookie=cookie) + return parse_opus_item(item).info + + +# ---------- 发送 ---------- + + +async def _send_forward( + sender: "MessageSender", + target_type: Literal["group", "private"], + target_id: int, + nodes: list[dict[str, Any]], + *, + history_message: str, +) -> None: + if target_type == "group": + await sender.send_group_forward_message( + target_id, nodes, history_message=history_message + ) + else: + await sender.send_private_forward_message( + target_id, nodes, history_message=history_message + ) + + +def _is_fatal_error(exc: BaseException) -> bool: + return any(bool(getattr(exc, flag, False)) for flag in _FATAL_ERROR_FLAGS) + + +async def send_opus( + opus_id: str, + *, + sender: "MessageSender", + target_type: Literal["group", "private"], + target_id: int, + cookie: str = "", + config: Any = None, + budget: _ExpansionBudget | None = None, + depth: int = 0, +) -> str: + """获取图文并发送合并转发,返回可读结果文案。""" + info: OpusInfo | None = None + try: + info = await _fetch_opus_info(opus_id, cookie=cookie, config=config) + nodes = await build_opus_nodes( + info, + sender=sender, + target_type=target_type, + target_id=target_id, + cookie=cookie, + config=config, + budget=budget, + depth=depth, + ) + await _send_forward( + sender, + target_type, + target_id, + nodes, + history_message=format_opus_history_message(info), + ) + return f"已发送 Bilibili 图文合并转发「{info.title or info.opus_id}」" + except Exception as exc: + if _is_fatal_error(exc): + raise + logger.exception("[Bilibili] 图文处理失败: %s", opus_id) + if info is None: + raise OpusUnavailableError(f"图文处理失败: {exc}") from exc + failure_status = f"图文处理失败: {exc}" + try: + nodes = [ + _build_meta_node(info), + _node(failure_status, name="正文"), + ] + await _send_forward( + sender, + target_type, + target_id, + nodes, + history_message=format_opus_history_message(info), + ) + except Exception as fallback_exc: + if _is_fatal_error(fallback_exc): + raise + raise OpusUnavailableError(f"图文处理失败: {exc}") from fallback_exc + return f"处理失败,已发送 Bilibili 图文信息合并转发: {exc}" + + +__all__ = [ + "build_opus_nodes", + "render_blocks_to_nodes", + "send_opus", +] diff --git a/src/Undefined/bilibili/sender.py b/src/Undefined/bilibili/sender.py index ea1ec134..76fc3575 100644 --- a/src/Undefined/bilibili/sender.py +++ b/src/Undefined/bilibili/sender.py @@ -14,6 +14,11 @@ download_video, get_video_info, ) +from Undefined.bilibili.format import ( + format_count as _format_count, + format_duration as _format_duration, + format_progress as _format_progress, +) from Undefined.bilibili.models import DanmakuItem, VideoStats from Undefined.bilibili.parser import normalize_to_bvid from Undefined.utils.io import get_file_size @@ -29,30 +34,6 @@ _DEFAULT_BOT_UIN = "10000" -def _format_count(value: int) -> str: - if value < 0: - value = 0 - if value >= 100_000_000: - return f"{value / 100_000_000:.1f}亿" - if value >= 10_000: - return f"{value / 10_000:.1f}万" - return str(value) - - -def _format_duration(seconds: int) -> str: - seconds = max(0, seconds) - hours, remainder = divmod(seconds, 3600) - minutes, secs = divmod(remainder, 60) - if hours: - return f"{hours}:{minutes:02d}:{secs:02d}" - return f"{minutes}:{secs:02d}" - - -def _format_progress(progress_ms: int) -> str: - seconds = max(0, progress_ms) // 1000 - return _format_duration(seconds) - - def _format_stats_line(stats: VideoStats) -> str: return ( "数据: " @@ -158,6 +139,11 @@ def format_bilibili_video_info(info: "VideoInfo") -> str: return "\n".join(lines) +def format_bilibili_video_stats(stats: VideoStats) -> str: + """格式化视频互动数据行(图文嵌套视频节点复用)。""" + return _format_stats_line(stats) + + def _build_uid_message( info: "VideoInfo", *, @@ -208,6 +194,57 @@ def _build_danmaku_groups( return groups +async def build_bilibili_video_nodes( + info: "VideoInfo", + *, + video_path: Path | None = None, + video_status: str = "未发送视频", + info_prefix: str = "", + cookie: str = "", + danmaku_enabled: bool = True, + danmaku_batch_size: int = 100, + danmaku_max_count: int = 0, + info_node_name: str = "视频信息", +) -> tuple[list[dict[str, Any]], list[DanmakuItem], str | None]: + """构建视频合并转发节点(视频信息 / 视频文件或状态 / 弹幕)。 + + 供视频自动提取与图文嵌套视频卡片复用;返回 + ``(nodes, danmaku, danmaku_error)``,拉取弹幕失败时降级为错误文案节点。 + """ + danmaku, danmaku_error = await _fetch_danmaku_best_effort( + info, + cookie=cookie, + enabled=danmaku_enabled, + max_count=danmaku_max_count, + ) + + info_node = _node( + _build_info_segments(info, prefix=info_prefix), name=info_node_name + ) + + if video_path is not None: + video_content: str | list[dict[str, Any]] = [ + { + "type": "video", + "data": {"file": f"file://{video_path.resolve()}"}, + } + ] + else: + video_content = video_status + video_node = _node(video_content, name="视频") + + if not danmaku_enabled: + return [info_node, video_node], danmaku, danmaku_error + + danmaku_content: str | list[dict[str, Any]] + if danmaku_error: + danmaku_content = f"弹幕获取失败: {danmaku_error}" + else: + danmaku_content = _build_danmaku_groups(danmaku, batch_size=danmaku_batch_size) + danmaku_node = _node(danmaku_content, name="弹幕") + return [info_node, video_node, danmaku_node], danmaku, danmaku_error + + def _build_forward_nodes( info: "VideoInfo", *, diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py index 1724dc4f..ea401152 100644 --- a/src/Undefined/config/config_class.py +++ b/src/Undefined/config/config_class.py @@ -209,6 +209,11 @@ class Config: bilibili_danmaku_max_count: int bilibili_auto_extract_group_ids: list[int] bilibili_auto_extract_private_ids: list[int] + # Bilibili 图文(opus)提取;与视频共用 auto_extract_enabled 与白名单 + bilibili_opus_enabled: bool + bilibili_opus_max_items: int + bilibili_opus_nested_depth: int + bilibili_opus_nested_max_cards: int # Douyin 视频提取 douyin_use_proxy: bool douyin_auto_extract_enabled: bool diff --git a/src/Undefined/config/load_sections/integrations.py b/src/Undefined/config/load_sections/integrations.py index bf248dd3..f275d36f 100644 --- a/src/Undefined/config/load_sections/integrations.py +++ b/src/Undefined/config/load_sections/integrations.py @@ -79,6 +79,31 @@ def load_integrations( bilibili_auto_extract_private_ids = _coerce_int_list( _get_value(data, ("bilibili", "auto_extract_private_ids"), None) ) + # 图文(opus):与视频共用 auto_extract_enabled 与白名单,只额外提供独立开关 + bilibili_opus_enabled = _coerce_bool( + _get_value(data, ("bilibili", "opus_enabled"), None), True + ) + bilibili_opus_max_items = _coerce_int( + _get_value(data, ("bilibili", "opus_max_items"), None), 3 + ) + if bilibili_opus_max_items <= 0: + bilibili_opus_max_items = 3 + elif bilibili_opus_max_items > 10: + bilibili_opus_max_items = 10 + bilibili_opus_nested_depth = _coerce_int( + _get_value(data, ("bilibili", "opus_nested_depth"), None), 5 + ) + if bilibili_opus_nested_depth < 0: + bilibili_opus_nested_depth = 0 + elif bilibili_opus_nested_depth > 5: + bilibili_opus_nested_depth = 5 + bilibili_opus_nested_max_cards = _coerce_int( + _get_value(data, ("bilibili", "opus_nested_max_cards"), None), 8 + ) + if bilibili_opus_nested_max_cards < 0: + bilibili_opus_nested_max_cards = 0 + elif bilibili_opus_nested_max_cards > 20: + bilibili_opus_nested_max_cards = 20 # Douyin 配置 douyin_use_proxy = _coerce_bool( @@ -334,6 +359,10 @@ def load_integrations( "bilibili_danmaku_max_count": bilibili_danmaku_max_count, "bilibili_auto_extract_group_ids": bilibili_auto_extract_group_ids, "bilibili_auto_extract_private_ids": bilibili_auto_extract_private_ids, + "bilibili_opus_enabled": bilibili_opus_enabled, + "bilibili_opus_max_items": bilibili_opus_max_items, + "bilibili_opus_nested_depth": bilibili_opus_nested_depth, + "bilibili_opus_nested_max_cards": bilibili_opus_nested_max_cards, "douyin_use_proxy": douyin_use_proxy, "douyin_auto_extract_enabled": douyin_auto_extract_enabled, "douyin_max_duration": douyin_max_duration, diff --git a/src/Undefined/handlers/auto_extract.py b/src/Undefined/handlers/auto_extract.py index 1e751b64..cb03edf2 100644 --- a/src/Undefined/handlers/auto_extract.py +++ b/src/Undefined/handlers/auto_extract.py @@ -38,6 +38,20 @@ async def _extract_bilibili_ids( bvids = await extract_from_json_message(message_content) return list(bvids) + async def _extract_bilibili_opus_ids( + self, text: str, message_content: list[dict[str, Any]] + ) -> list[str]: + """从文本和消息段中提取 B 站图文(opus / 动态)ID。""" + from Undefined.bilibili.opus_parser import ( + extract_opus_from_json_message, + extract_opus_ids_with_shortlinks, + ) + + opus_ids = await extract_opus_ids_with_shortlinks(text) + if not opus_ids: + opus_ids = await extract_opus_from_json_message(message_content) + return list(opus_ids) + def _extract_douyin_ids( self, text: str, message_content: list[dict[str, Any]] ) -> list[str]: @@ -159,6 +173,51 @@ async def _handle_bilibili_extract( except Exception: pass + async def _handle_bilibili_opus_extract( + self, + target_id: int, + opus_ids: list[str], + target_type: str, + sender: Any | None = None, + ) -> None: + """处理 bilibili 图文(opus)自动提取和发送。""" + from Undefined.bilibili.opus_sender import send_opus + + max_items = max(1, int(getattr(self.config, "bilibili_opus_max_items", 3))) + resolved_sender = sender or self.sender + for opus_id in opus_ids[:max_items]: + try: + result = await send_opus( + opus_id, + sender=resolved_sender, + target_type=target_type, # type: ignore[arg-type] + target_id=target_id, + cookie=self.config.bilibili_cookie, + config=self.config, + ) + logger.info( + "[Bilibili] 图文自动提取完成 %s → %s:%s: %s", + opus_id, + target_type, + target_id, + result, + ) + except Exception as exc: + logger.exception( + "[Bilibili] 图文自动提取失败 %s → %s:%s", + opus_id, + target_type, + target_id, + ) + try: + error_msg = f"图文提取失败: {exc}" + if target_type == "group": + await resolved_sender.send_group_message(target_id, error_msg) + else: + await resolved_sender.send_private_message(target_id, error_msg) + except Exception: + pass + async def _handle_douyin_extract( self, target_id: int, diff --git a/src/Undefined/skills/pipelines/bilibili_opus/config.json b/src/Undefined/skills/pipelines/bilibili_opus/config.json new file mode 100644 index 00000000..49da189a --- /dev/null +++ b/src/Undefined/skills/pipelines/bilibili_opus/config.json @@ -0,0 +1,6 @@ +{ + "name": "bilibili_opus", + "description": "检测并处理 Bilibili 图文(opus / 动态)链接自动提取。", + "order": 11, + "enabled": true +} diff --git a/src/Undefined/skills/pipelines/bilibili_opus/handler.py b/src/Undefined/skills/pipelines/bilibili_opus/handler.py new file mode 100644 index 00000000..60f0d4b5 --- /dev/null +++ b/src/Undefined/skills/pipelines/bilibili_opus/handler.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any + +from Undefined.skills.pipelines.models import ( + PipelineContext, + PipelineDetection, +) + + +def _is_allowed(config: Any, target_type: str, target_id: int) -> bool: + # 图文复用视频的白名单与总开关,只额外受 opus_enabled 控制 + if not getattr(config, "bilibili_auto_extract_enabled", False): + return False + if not getattr(config, "bilibili_opus_enabled", True): + return False + if target_type == "group": + return bool(config.is_bilibili_auto_extract_allowed_group(target_id)) + return bool(config.is_bilibili_auto_extract_allowed_private(target_id)) + + +async def detect(context: PipelineContext) -> PipelineDetection | None: + target_id = int(context["target_id"]) + target_type = str(context["target_type"]) + config = context["config"] + if not _is_allowed(config, target_type, target_id): + return None + + extractor = context["extract_bilibili_opus_ids"] + opus_ids = await extractor(context["text"], context["message_content"]) + if not opus_ids: + return None + return PipelineDetection( + name="bilibili_opus", items=tuple(str(item) for item in opus_ids) + ) + + +async def process( + detection: PipelineDetection, + context: PipelineContext, +) -> None: + handler = context["handle_bilibili_opus_extract"] + args = ( + int(context["target_id"]), + list(detection.items), + str(context["target_type"]), + ) + if context.get("address") is None: + await handler(*args) + else: + await handler(*args, context["sender"]) diff --git a/src/Undefined/skills/pipelines/context.py b/src/Undefined/skills/pipelines/context.py index 065bbf2b..791e5543 100644 --- a/src/Undefined/skills/pipelines/context.py +++ b/src/Undefined/skills/pipelines/context.py @@ -30,10 +30,12 @@ def build_pipeline_context( "text": text, "message_content": message_content, "extract_bilibili_ids": handler._extract_bilibili_ids, + "extract_bilibili_opus_ids": handler._extract_bilibili_opus_ids, "extract_douyin_ids": handler._extract_douyin_ids, "extract_arxiv_ids": handler._extract_arxiv_ids, "extract_github_repo_ids": handler._extract_github_repo_ids, "handle_bilibili_extract": handler._handle_bilibili_extract, + "handle_bilibili_opus_extract": handler._handle_bilibili_opus_extract, "handle_douyin_extract": handler._handle_douyin_extract, "handle_arxiv_extract": handler._handle_arxiv_extract, "handle_github_extract": handler._handle_github_extract, diff --git a/tests/test_ai_client_setup_paths.py b/tests/test_ai_client_setup_paths.py index a7823ac9..2932a78f 100644 --- a/tests/test_ai_client_setup_paths.py +++ b/tests/test_ai_client_setup_paths.py @@ -112,4 +112,10 @@ async def _load() -> PipelineRegistry: return registry registry = asyncio.run(_load()) - assert set(registry._items) == {"arxiv", "bilibili", "douyin", "github"} + assert set(registry._items) == { + "arxiv", + "bilibili", + "bilibili_opus", + "douyin", + "github", + } diff --git a/tests/test_automations.py b/tests/test_automations.py index 3cc49255..7cabd0f0 100644 --- a/tests/test_automations.py +++ b/tests/test_automations.py @@ -2095,10 +2095,12 @@ def _group_handler() -> Any: handler._collect_message_attachments = AsyncMock(return_value=[]) handler.sender = SimpleNamespace() handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = AsyncMock(return_value=[]) handler._extract_arxiv_ids = AsyncMock(return_value=[]) handler._extract_github_repo_ids = AsyncMock(return_value=[]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() @@ -2217,10 +2219,12 @@ async def test_private_entry_intercepts_ai(monkeypatch: pytest.MonkeyPatch) -> N handler._schedule_forward_meme_scan = MagicMock() handler.sender = SimpleNamespace() handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = AsyncMock(return_value=[]) handler._extract_arxiv_ids = AsyncMock(return_value=[]) handler._extract_github_repo_ids = AsyncMock(return_value=[]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() @@ -2446,10 +2450,12 @@ async def test_private_message_skips_automations_when_processing_disabled( handler._schedule_forward_meme_scan = MagicMock() handler.sender = SimpleNamespace() handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = AsyncMock(return_value=[]) handler._extract_arxiv_ids = AsyncMock(return_value=[]) handler._extract_github_repo_ids = AsyncMock(return_value=[]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() diff --git a/tests/test_bilibili_opus_parser.py b/tests/test_bilibili_opus_parser.py new file mode 100644 index 00000000..a2ce006e --- /dev/null +++ b/tests/test_bilibili_opus_parser.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import html +import json +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from Undefined.bilibili import opus_parser +from Undefined.bilibili.opus_parser import ( + extract_opus_from_json_message, + extract_opus_ids_with_shortlinks, +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("https://www.bilibili.com/opus/933099353259638816", ["933099353259638816"]), + ("https://m.bilibili.com/opus/106514206257210983", ["106514206257210983"]), + ("看这个 bilibili.com/opus/123456789012345", ["123456789012345"]), + ("https://t.bilibili.com/718384798557536290", ["718384798557536290"]), + ("http://t.bilibili.com/718384798557536290", ["718384798557536290"]), + ( + "两个 https://www.bilibili.com/opus/111111111111111 和 " + "https://t.bilibili.com/222222222222222", + ["111111111111111", "222222222222222"], + ), + ( + "重复 https://www.bilibili.com/opus/111111111111111 " + "https://www.bilibili.com/opus/111111111111111", + ["111111111111111"], + ), + ], +) +async def test_extract_opus_ids_from_plain_text(text: str, expected: list[str]) -> None: + assert await extract_opus_ids_with_shortlinks(text) == expected + + +@pytest.mark.asyncio +async def test_extract_opus_ids_ignores_video_links_and_bare_numbers() -> None: + text = "https://www.bilibili.com/video/BV1xx411c7mD 933099353259638816" + assert await extract_opus_ids_with_shortlinks(text) == [] + + +@pytest.mark.asyncio +async def test_extract_opus_ids_resolves_short_link( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_parser, + "resolve_short_url", + AsyncMock(return_value="https://www.bilibili.com/opus/555555555555555"), + ) + assert await extract_opus_ids_with_shortlinks("https://b23.tv/abcd123") == [ + "555555555555555" + ] + + +@pytest.mark.asyncio +async def test_extract_opus_ids_keeps_direct_hits_when_short_link_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(opus_parser, "resolve_short_url", AsyncMock(return_value=None)) + text = "https://b23.tv/abcd123 https://www.bilibili.com/opus/777777777777777" + assert await extract_opus_ids_with_shortlinks(text) == ["777777777777777"] + + +def _json_segment(payload: dict[str, Any]) -> dict[str, Any]: + return { + "type": "json", + "data": {"data": html.escape(json.dumps(payload, ensure_ascii=False))}, + } + + +@pytest.mark.asyncio +async def test_extract_opus_from_json_message_detail_1() -> None: + segments = [ + _json_segment( + { + "app": "com.tencent.structmsg", + "meta": { + "detail_1": { + "qqdocurl": "https://www.bilibili.com/opus/888888888888888?share_source=qq" + } + }, + } + ) + ] + assert await extract_opus_from_json_message(segments) == ["888888888888888"] + + +@pytest.mark.asyncio +async def test_extract_opus_from_json_message_news() -> None: + segments = [ + _json_segment( + {"meta": {"news": {"jumpUrl": "https://t.bilibili.com/999999999999999"}}} + ) + ] + assert await extract_opus_from_json_message(segments) == ["999999999999999"] + + +@pytest.mark.asyncio +async def test_extract_opus_from_json_message_short_link( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_parser, + "resolve_short_url", + AsyncMock(return_value="https://www.bilibili.com/opus/121212121212121"), + ) + segments = [ + _json_segment({"meta": {"detail_1": {"qqdocurl": "https://b23.tv/xyz987"}}}) + ] + assert await extract_opus_from_json_message(segments) == ["121212121212121"] + + +@pytest.mark.asyncio +async def test_extract_opus_from_json_message_skips_invalid_payloads() -> None: + segments: list[dict[str, Any]] = [ + {"type": "text", "data": {"text": "https://www.bilibili.com/opus/1"}}, + {"type": "json", "data": {"data": "not json"}}, + {"type": "json", "data": {"data": '"just a string"'}}, + {"type": "json", "data": {}}, + _json_segment({"meta": {"detail_1": {"qqdocurl": "https://example.com/x"}}}), + ] + assert await extract_opus_from_json_message(segments) == [] diff --git a/tests/test_bilibili_opus_pipeline.py b/tests/test_bilibili_opus_pipeline.py new file mode 100644 index 00000000..e324180e --- /dev/null +++ b/tests/test_bilibili_opus_pipeline.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest + +from Undefined.handlers import MessageHandler +from Undefined.skills.pipelines import PipelineRegistry +from Undefined.utils.message_targets import DeliveryAddress +from Undefined.utils.sender import AddressBoundSender + + +def _handler(config: Any) -> Any: + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = config + handler.sender = SimpleNamespace() + handler.onebot = SimpleNamespace() + handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) + handler._extract_douyin_ids = AsyncMock(return_value=[]) + handler._extract_arxiv_ids = AsyncMock(return_value=[]) + handler._extract_github_repo_ids = AsyncMock(return_value=[]) + handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() + handler._handle_douyin_extract = AsyncMock() + handler._handle_arxiv_extract = AsyncMock() + handler._handle_github_extract = AsyncMock() + handler.pipeline_registry = PipelineRegistry() + handler.pipeline_registry.load_items() + return handler + + +def _config(**overrides: Any) -> Any: + base: dict[str, Any] = { + "bilibili_auto_extract_enabled": True, + "bilibili_opus_enabled": True, + "bilibili_opus_max_items": 3, + "bilibili_cookie": "", + "is_bilibili_auto_extract_allowed_group": lambda _gid: True, + "is_bilibili_auto_extract_allowed_private": lambda _uid: True, + "douyin_auto_extract_enabled": False, + "arxiv_auto_extract_enabled": False, + "github_auto_extract_enabled": False, + } + base.update(overrides) + return SimpleNamespace(**base) + + +@pytest.mark.asyncio +async def test_bilibili_opus_pipeline_dispatches_to_handler() -> None: + handler = _handler(_config()) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=["933099353259638816"]) + + handled = await handler._run_pipelines( + target_id=20001, + target_type="private", + text="看看 https://www.bilibili.com/opus/933099353259638816", + message_content=[], + ) + + assert handled is True + handler._handle_bilibili_opus_extract.assert_awaited_once_with( + 20001, + ["933099353259638816"], + "private", + ) + handler._handle_bilibili_extract.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bilibili_opus_pipeline_skips_when_disabled() -> None: + handler = _handler(_config(bilibili_opus_enabled=False)) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=["933099353259638816"]) + + handled = await handler._run_pipelines( + target_id=20001, + target_type="private", + text="https://www.bilibili.com/opus/933099353259638816", + message_content=[], + ) + + assert handled is False + handler._extract_bilibili_opus_ids.assert_not_awaited() + handler._handle_bilibili_opus_extract.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bilibili_opus_pipeline_skips_when_master_switch_off() -> None: + handler = _handler(_config(bilibili_auto_extract_enabled=False)) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=["933099353259638816"]) + + handled = await handler._run_pipelines( + target_id=20001, + target_type="private", + text="https://www.bilibili.com/opus/933099353259638816", + message_content=[], + ) + + assert handled is False + handler._extract_bilibili_opus_ids.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bilibili_opus_pipeline_respects_allowlist() -> None: + handler = _handler( + _config(is_bilibili_auto_extract_allowed_private=lambda _uid: False) + ) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=["933099353259638816"]) + + handled = await handler._run_pipelines( + target_id=20001, + target_type="private", + text="https://www.bilibili.com/opus/933099353259638816", + message_content=[], + ) + + assert handled is False + handler._extract_bilibili_opus_ids.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bilibili_opus_pipeline_binds_address_sender() -> None: + handler = _handler(_config()) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=["933099353259638816"]) + + await handler._run_pipelines( + target_id=20001, + target_type="private", + text="https://www.bilibili.com/opus/933099353259638816", + message_content=[], + address=DeliveryAddress("wechat", 20001), + ) + + args = handler._handle_bilibili_opus_extract.await_args.args + assert args[:3] == (20001, ["933099353259638816"], "private") + assert isinstance(args[3], AddressBoundSender) + + +def _dummy_mixin(config: Any) -> Any: + """构造一个只带 mixin 所需属性的轻量宿主(mixin 只依赖 config/sender/onebot)。""" + return SimpleNamespace( + config=config, + sender=SimpleNamespace( + send_group_message=AsyncMock(), + send_private_message=AsyncMock(), + ), + onebot=SimpleNamespace(), + ) + + +@pytest.mark.asyncio +async def test_bilibili_opus_handler_respects_max_items( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import Undefined.bilibili.opus_sender as opus_sender + from Undefined.handlers.auto_extract import AutoExtractMixin + + send = AsyncMock(return_value="ok") + monkeypatch.setattr(opus_sender, "send_opus", send) + + await AutoExtractMixin._handle_bilibili_opus_extract( + _dummy_mixin(_config(bilibili_opus_max_items=2)), + 20001, + ["1", "2", "3", "4"], + "private", + ) + + assert send.await_count == 2 + assert [call.args[0] for call in send.await_args_list] == ["1", "2"] + + +@pytest.mark.asyncio +async def test_bilibili_opus_handler_reports_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import Undefined.bilibili.opus_sender as opus_sender + from Undefined.handlers.auto_extract import AutoExtractMixin + + monkeypatch.setattr( + opus_sender, "send_opus", AsyncMock(side_effect=RuntimeError("boom")) + ) + + dummy = _dummy_mixin(_config()) + await AutoExtractMixin._handle_bilibili_opus_extract(dummy, 20001, ["1"], "private") + + sender_mock = cast(Any, dummy.sender) + sender_mock.send_private_message.assert_awaited_once() + assert "图文提取失败" in sender_mock.send_private_message.await_args.args[1] diff --git a/tests/test_bilibili_opus_render.py b/tests/test_bilibili_opus_render.py new file mode 100644 index 00000000..74b87275 --- /dev/null +++ b/tests/test_bilibili_opus_render.py @@ -0,0 +1,480 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from Undefined.bilibili.models import ( + ImageBlock, + LinkCardBlock, + OpusCardBlock, + TextBlock, + VideoCardBlock, +) +from Undefined.bilibili.opus_render import ( + format_opus_history_message, + format_opus_info, + format_opus_stats, + parse_opus_item, +) + + +def _word(text: str) -> dict[str, Any]: + return {"type": "TEXT_NODE_TYPE_WORD", "word": {"words": text}} + + +def _rich(node_type: str, text: str, **extra: Any) -> dict[str, Any]: + return { + "type": "TEXT_NODE_TYPE_RICH", + "rich": {"type": node_type, "orig_text": text, "text": text, **extra}, + } + + +def _text_para(*nodes: dict[str, Any]) -> dict[str, Any]: + return {"para_type": 1, "text": {"nodes": list(nodes)}} + + +def _list_item(nodes: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: + return {"nodes": nodes, **extra} + + +def _opus_item(paragraphs: list[dict[str, Any]], **overrides: Any) -> dict[str, Any]: + item: dict[str, Any] = { + "id_str": "933099353259638816", + "type": 2, + "basic": {"title": "基础标题", "uid": 293793435}, + "modules": [ + {"module_type": "MODULE_TYPE_TITLE", "module_title": {"text": "模块标题"}}, + { + "module_type": "MODULE_TYPE_AUTHOR", + "module_author": { + "mid": 293793435, + "name": "测试UP", + "face": "//i0.hdslb.com/bfs/face/a.jpg", + "pub_ts": 1700000000, + }, + }, + { + "module_type": "MODULE_TYPE_CONTENT", + "module_content": {"paragraphs": paragraphs}, + }, + { + "module_type": "MODULE_TYPE_STAT", + "module_stat": { + "view": {"count": 12000}, + "like": {"count": 88}, + "comment": {"count": 42}, + "forward": {"count": 3}, + "coin": {"count": 5}, + "favorite": {"count": 7}, + }, + }, + ], + } + item.update(overrides) + return item + + +def test_parse_opus_item_list_modules_basic_fields() -> None: + parsed = parse_opus_item(_opus_item([_text_para(_word("正文第一段"))])) + + info = parsed.info + assert info.opus_id == "933099353259638816" + assert info.title == "模块标题" + assert info.author.name == "测试UP" + assert info.author.mid == 293793435 + assert info.author.avatar_url == "https://i0.hdslb.com/bfs/face/a.jpg" + assert info.pub_ts == 1700000000 + assert info.url == "https://www.bilibili.com/opus/933099353259638816" + assert info.blocks == (TextBlock("正文第一段"),) + assert parsed.stats.view == 12000 + assert parsed.stats.repost == 3 + + +def test_parse_opus_item_title_falls_back_to_basic() -> None: + item = _opus_item([_text_para(_word("hi"))]) + modules = [m for m in item["modules"] if m["module_type"] != "MODULE_TYPE_TITLE"] + item["modules"] = modules + + assert parse_opus_item(item).info.title == "基础标题" + + +def test_parse_opus_item_renders_rich_nodes() -> None: + paragraphs = [ + _text_para( + _word("看看 "), + _rich("RICH_TEXT_NODE_TYPE_AT", "@某人"), + _rich( + "RICH_TEXT_NODE_TYPE_BV", + "视频", + jump_url="//www.bilibili.com/video/BV1xx411c7mD", + ), + _rich("RICH_TEXT_NODE_TYPE_EMOJI", ""), + ), + _text_para( + { + "type": "TEXT_NODE_TYPE_FORMULA", + "formula": {"latex_content": "a^2+b^2"}, + } + ), + ] + + blocks = parse_opus_item(_opus_item(paragraphs)).info.blocks + + assert blocks == ( + TextBlock("看看 @某人视频 (https://www.bilibili.com/video/BV1xx411c7mD)[表情]"), + TextBlock("$a^2+b^2$"), + ) + + +def test_parse_opus_item_paragraph_types() -> None: + paragraphs: list[dict[str, Any]] = [ + {"para_type": 2, "pic": {"pics": [{"url": "//i0.hdslb.com/1.jpg"}]}}, + {"para_type": 3, "line": {}}, + { + "para_type": 4, + "blockquote": { + "text": { + "nodes": [ + _word("引用第一行\n引用第二行"), + _word("继续"), + ] + } + }, + }, + { + "para_type": 5, + "list": { + "style": 1, + "items": [ + _list_item([_word("第一项")], order=1, level=0), + _list_item([_word("子项")], order=1, level=1), + ], + }, + }, + { + "para_type": 5, + "list": { + "style": 2, + "items": [_list_item([_word("无序项")], level=0)], + }, + }, + { + "para_type": 7, + "code": {"lang": "language-python", "content": "print(1)"}, + }, + {"para_type": 9, "text": {"nodes": [_word("未知但按文本处理")]}}, + ] + + blocks = parse_opus_item(_opus_item(paragraphs)).info.blocks + + assert blocks == ( + ImageBlock(("https://i0.hdslb.com/1.jpg",)), + TextBlock("———"), + TextBlock("> 引用第一行\n> 引用第二行继续"), + TextBlock("1. 第一项\n 1. 子项"), + TextBlock("- 无序项"), + TextBlock("```python\nprint(1)\n```"), + TextBlock("未知但按文本处理"), + ) + + +def test_parse_opus_item_link_card_types() -> None: + paragraphs: list[dict[str, Any]] = [ + { + "para_type": 6, + "link_card": { + "card": { + "type": "LINK_CARD_TYPE_OPUS", + "oid": "1056353752004427792", + "opus": { + "title": "另一篇图文", + "jump_url": "//www.bilibili.com/opus/1056353752004427792", + "cover": "//i0.hdslb.com/cover.jpg", + "author": {"name": "作者"}, + }, + } + }, + }, + { + "para_type": 6, + "link_card": { + "card": { + "type": "LINK_CARD_TYPE_UGC", + "ugc": {"title": "视频卡片", "bvid": "BV1xx411c7mD"}, + } + }, + }, + { + "para_type": 6, + "link_card": { + "card": { + "type": "LINK_CARD_TYPE_OPUS", + "oid": "undefined", + "opus": {"jump_url": "https://www.bilibili.com/opus/123456"}, + } + }, + }, + { + "para_type": 6, + "link_card": { + "card": { + "type": "LINK_CARD_TYPE_COMMON", + "common": { + "title": "活动链接", + "jump_url": "//www.bilibili.com/blackboard/x.html", + }, + } + }, + }, + { + "para_type": 6, + "link_card": { + "card": { + "type": "LINK_CARD_TYPE_GOODS", + "item_null": {"text": "商品已下架"}, + } + }, + }, + {"para_type": 6, "link_card": {"card": {}}}, + ] + + blocks = parse_opus_item(_opus_item(paragraphs)).info.blocks + + assert blocks[0] == OpusCardBlock( + opus_id="1056353752004427792", + title="另一篇图文", + cover_url="https://i0.hdslb.com/cover.jpg", + jump_url="https://www.bilibili.com/opus/1056353752004427792", + ) + assert blocks[1] == VideoCardBlock(bvid="BV1xx411c7mD", title="视频卡片") + assert blocks[2] == OpusCardBlock( + opus_id="123456", + title="链接卡片", + jump_url="https://www.bilibili.com/opus/123456", + ) + assert blocks[3] == LinkCardBlock( + title="活动链接", jump_url="https://www.bilibili.com/blackboard/x.html" + ) + assert blocks[4] == LinkCardBlock(title="商品已下架") + assert len(blocks) == 5 + + +def test_parse_opus_item_uses_placeholder_for_empty_body() -> None: + parsed = parse_opus_item(_opus_item([])) + assert parsed.info.blocks == (TextBlock("(该图文没有正文内容)"),) + + +def test_parse_opus_item_rejects_broken_items() -> None: + with pytest.raises(ValueError): + parse_opus_item({}) + with pytest.raises(ValueError): + parse_opus_item({"id_str": "1"}) + + +def test_parse_opus_item_dict_modules() -> None: + item = { + "id_str": "967717348014293017", + "type": 2, + "basic": {}, + "modules": { + "module_author": { + "mid": 1, + "name": "动态UP", + "face": "https://i0.hdslb.com/face.jpg", + "pub_ts": 1724986186, + }, + "module_dynamic": { + "type": "DYNAMIC_TYPE_DRAW", + "desc": {"text": "正文来自 desc"}, + "major": { + "type": "MAJOR_TYPE_DRAW", + "draw": { + "items": [ + {"src": "//i0.hdslb.com/a.jpg"}, + {"src": "https://i0.hdslb.com/b.jpg"}, + ] + }, + }, + }, + "module_stat": {"like": {"count": 1}}, + }, + } + + parsed = parse_opus_item(item) + + assert parsed.info.author.name == "动态UP" + assert parsed.info.pub_ts == 1724986186 + assert parsed.info.dynamic_type_id == "DYNAMIC_TYPE_DRAW" + assert parsed.info.blocks == ( + TextBlock("正文来自 desc"), + ImageBlock(("https://i0.hdslb.com/a.jpg", "https://i0.hdslb.com/b.jpg")), + ) + assert parsed.info.cover_url == "https://i0.hdslb.com/a.jpg" + + +def test_parse_opus_item_dict_modules_major_archive() -> None: + item = { + "id_str": "123", + "basic": {}, + "modules": { + "module_author": {"name": "UP"}, + "module_dynamic": { + "type": "DYNAMIC_TYPE_AV", + "major": { + "type": "MAJOR_TYPE_ARCHIVE", + "archive": { + "bvid": "BV1xx411c7mD", + "title": "投稿视频", + "cover": "//i0.hdslb.com/c.jpg", + "jump_url": "//www.bilibili.com/video/BV1xx411c7mD", + }, + }, + }, + }, + } + + blocks = parse_opus_item(item).info.blocks + + assert blocks == ( + VideoCardBlock( + bvid="BV1xx411c7mD", + title="投稿视频", + cover_url="https://i0.hdslb.com/c.jpg", + jump_url="https://www.bilibili.com/video/BV1xx411c7mD", + ), + ) + + +def test_parse_opus_item_dict_modules_major_opus_summary() -> None: + """通用动态接口的正文在 major.opus.summary 里(长文只给摘要)。""" + item = { + "id_str": "933099353259638816", + "basic": {}, + "modules": { + "module_author": {"name": "UP", "pub_ts": 1716092523}, + "module_dynamic": { + "type": "DYNAMIC_TYPE_DRAW", + "desc": None, + "major": { + "type": "MAJOR_TYPE_OPUS", + "opus": { + "title": "摘要标题", + "jump_url": "https://www.bilibili.com/opus/933099353259638816", + "pics": [{"url": "//i0.hdslb.com/cover.jpg"}], + "summary": { + "has_more": True, + "text": "这是摘要正文", + "rich_text_nodes": [ + { + "orig_text": "这是摘要正文", + "type": "RICH_TEXT_NODE_TYPE_TEXT", + } + ], + }, + }, + }, + }, + }, + } + + blocks = parse_opus_item(item).info.blocks + + assert blocks == ( + TextBlock("这是摘要正文"), + ImageBlock(("https://i0.hdslb.com/cover.jpg",)), + TextBlock("(仅摘要,完整正文请见原文链接)"), + ) + + +def test_parse_opus_item_major_opus_pointing_to_self_is_not_a_card() -> None: + """指向自身的 major opus 不能变成嵌套卡片,否则会自引用递归。""" + item = { + "id_str": "123456", + "basic": {}, + "modules": { + "module_author": {"name": "UP"}, + "module_dynamic": { + "type": "DYNAMIC_TYPE_DRAW", + "major": { + "type": "MAJOR_TYPE_OPUS", + "opus": { + "jump_url": "https://www.bilibili.com/opus/123456", + "summary": {"text": "正文"}, + }, + }, + }, + }, + } + + blocks = parse_opus_item(item).info.blocks + + assert blocks == (TextBlock("正文"),) + + +def test_parse_opus_item_major_opus_pointing_elsewhere_is_a_card() -> None: + item = { + "id_str": "123456", + "basic": {}, + "modules": { + "module_author": {"name": "UP"}, + "module_dynamic": { + "type": "DYNAMIC_TYPE_FORWARD", + "major": { + "type": "MAJOR_TYPE_OPUS", + "opus": { + "jump_url": "https://www.bilibili.com/opus/999999", + "title": "另一篇", + "summary": {"text": ""}, + }, + }, + }, + }, + } + + blocks = parse_opus_item(item).info.blocks + + assert blocks == ( + OpusCardBlock( + opus_id="999999", + title="另一篇", + jump_url="https://www.bilibili.com/opus/999999", + ), + ) + + +def test_format_opus_stats_and_info() -> None: + parsed = parse_opus_item(_opus_item([_text_para(_word("正文"))])) + info = parsed.info + + assert ( + format_opus_stats(info.stats) == "数据: 阅读 1.2万 | 点赞 88 | 评论 42 | 转发 3" + ) + summary = format_opus_info(info) + assert "「模块标题」" in summary + assert "图文 ID: 933099353259638816" in summary + assert "UP主: 测试UP" in summary + assert "发布时间: 2023-11-15" in summary + assert summary.endswith("https://www.bilibili.com/opus/933099353259638816") + + +def test_format_opus_history_message_includes_body_text() -> None: + parsed = parse_opus_item( + _opus_item( + [ + _text_para(_word("第一段")), + { + "para_type": 2, + "pic": {"pics": [{"url": "https://i0.hdslb.com/1.jpg"}]}, + }, + ] + ) + ) + + message = format_opus_history_message(parsed.info, video_status="长度超限") + + assert message.startswith("[Bilibili 图文] 「模块标题」") + assert "图片: 1 张" in message + assert "视频: 长度超限" in message + assert "第一段" in message + assert "[图片 x1]" in message diff --git a/tests/test_bilibili_opus_sender.py b/tests/test_bilibili_opus_sender.py new file mode 100644 index 00000000..4875e5c3 --- /dev/null +++ b/tests/test_bilibili_opus_sender.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import Undefined.bilibili.opus_sender as opus_sender +from Undefined.bilibili.errors import OpusUnavailableError +from Undefined.bilibili.models import ( + ImageBlock, + LinkCardBlock, + OpusAuthor, + OpusCardBlock, + OpusInfo, + TextBlock, + VideoCardBlock, + VideoInfo, + VideoStats, +) +from Undefined.onebot.client import OneBotDeliveryUncertainError + + +def _author(name: str = "测试UP") -> OpusAuthor: + return OpusAuthor(mid=293793435, name=name, avatar_url="https://i0.hdslb.com/a.jpg") + + +def _info( + *blocks: Any, + title: str = "测试图文", + cover_url: str = "https://i0.hdslb.com/cover.jpg", + **overrides: Any, +) -> OpusInfo: + payload: dict[str, Any] = { + "opus_id": "933099353259638816", + "title": title, + "blocks": tuple(blocks) or (TextBlock("正文"),), + "author": _author(), + "pub_ts": 1700000000, + "cover_url": cover_url, + } + payload.update(overrides) + return OpusInfo(**payload) + + +def _sender() -> Any: + return SimpleNamespace( + send_group_forward_message=AsyncMock(), + send_private_forward_message=AsyncMock(), + ) + + +def _config(**overrides: Any) -> Any: + base: dict[str, Any] = { + "bilibili_opus_nested_depth": 5, + "bilibili_opus_nested_max_cards": 8, + "bilibili_prefer_quality": 80, + "bilibili_max_duration": 600, + "bilibili_max_file_size": 100, + "bilibili_oversize_strategy": "downgrade", + "bilibili_danmaku_enabled": False, + "bilibili_danmaku_batch_size": 100, + "bilibili_danmaku_max_count": 0, + } + base.update(overrides) + return SimpleNamespace(**base) + + +async def _build(info: OpusInfo, *, config: Any = None, **kwargs: Any) -> list[Any]: + return await opus_sender.build_opus_nodes( + info, + sender=_sender(), + target_type="group", + target_id=10001, + config=config if config is not None else _config(), + **kwargs, + ) + + +def _segment_types(node: dict[str, Any]) -> list[str]: + return [segment["type"] for segment in node["data"]["content"]] + + +# ---------- 节点结构 ---------- + + +@pytest.mark.asyncio +async def test_meta_node_is_first_and_content_follows() -> None: + info = _info(TextBlock("第一段"), ImageBlock(("https://i0.hdslb.com/1.jpg",))) + + nodes = await _build(info) + + assert len(nodes) == 2 + meta, content = nodes + assert meta["data"]["name"] == "图文信息" + assert _segment_types(meta) == ["image", "text"] + meta_text = meta["data"]["content"][1]["data"]["text"] + assert "「测试图文」" in meta_text + assert "UP主: 测试UP" in meta_text + assert "时间: 2023-11-15" in meta_text + assert "数据: 阅读 0 |" in meta_text + assert meta_text.endswith("https://www.bilibili.com/opus/933099353259638816") + + assert content["data"]["name"] == "正文" + assert _segment_types(content) == ["text", "image"] + assert content["data"]["content"][0]["data"]["text"] == "第一段" + assert content["data"]["content"][1]["data"]["file"] == "https://i0.hdslb.com/1.jpg" + + +@pytest.mark.asyncio +async def test_meta_node_omits_cover_when_missing() -> None: + nodes = await _build(_info(TextBlock("正文"), cover_url="")) + assert _segment_types(nodes[0]) == ["text"] + + +@pytest.mark.asyncio +async def test_forward_source_is_rendered_in_meta() -> None: + info = _info( + TextBlock("转发正文"), + is_forward=True, + forward_origin=_author("原作者"), + ) + nodes = await _build(info) + assert "转发自: 原作者" in nodes[0]["data"]["content"][1]["data"]["text"] + + +@pytest.mark.asyncio +async def test_empty_blocks_still_produce_content_node() -> None: + nodes = await _build(_info()) + assert len(nodes) == 2 + assert nodes[1]["data"]["content"][0]["data"]["text"] == "正文" + + +# ---------- 分段 ---------- + + +@pytest.mark.asyncio +async def test_long_text_is_split_and_nothing_is_lost() -> None: + body = "A" * 9000 + nodes = await _build(_info(TextBlock(body))) + + assert len(nodes) == 4 # 1 元数据 + 3 内容 + contents = nodes[1:] + assert [node["data"]["name"] for node in contents] == [ + "正文 1/3", + "正文 2/3", + "正文 3/3", + ] + text = "".join( + segment["data"]["text"] + for node in contents + for segment in node["data"]["content"] + if segment["type"] == "text" + ) + assert text == body + for node in contents: + assert len(node["data"]["content"][0]["data"]["text"]) <= 4000 + + +@pytest.mark.asyncio +async def test_chunking_prefers_newline_boundary() -> None: + body = "第一行\n" + "B" * 5000 + nodes = await _build(_info(TextBlock(body))) + + assert nodes[1]["data"]["content"][0]["data"]["text"] == "第一行\n" + + +@pytest.mark.asyncio +async def test_images_keep_relative_position_across_chunks() -> None: + # 用 8000 字(正好两整块)确保后续图文块落在独立的节点里,便于断言顺序 + long_text = "C" * 8000 + info = _info( + TextBlock(long_text), + ImageBlock(("https://i0.hdslb.com/mid.jpg",)), + TextBlock("结尾"), + ) + + nodes = await _build(info) + contents = nodes[1:] + + # 超长正文按 4000 字切成两块;图片保留在原文位置(尾段之前),不会被提前或丢弃 + assert [_segment_types(node) for node in contents] == [ + ["text"], + ["text", "image", "text"], + ] + assert contents[1]["data"]["content"][1]["data"]["file"] == ( + "https://i0.hdslb.com/mid.jpg" + ) + assert contents[1]["data"]["content"][2]["data"]["text"] == "结尾" + + body = "".join( + segment["data"]["text"] + for node in contents + for segment in node["data"]["content"] + if segment["type"] == "text" + ) + assert body == long_text + "结尾" + + +@pytest.mark.asyncio +async def test_code_block_long_text_is_split_without_loss() -> None: + code = "```python\n" + "x = 1\n" * 2000 + "```" + nodes = await _build(_info(TextBlock(code))) + text = "".join( + segment["data"]["text"] + for node in nodes[1:] + for segment in node["data"]["content"] + ) + assert text == code + + +# ---------- 嵌套边界 ---------- + + +@pytest.mark.asyncio +async def test_nested_cards_degrade_when_depth_is_zero() -> None: + info = _info( + TextBlock("正文"), + OpusCardBlock( + opus_id="1056353752004427792", + title="另一篇图文", + jump_url="https://www.bilibili.com/opus/1056353752004427792", + ), + VideoCardBlock(bvid="BV1xx411c7mD", title="视频卡片"), + ) + config = _config(bilibili_opus_nested_depth=0) + nodes = await _build(info, config=config) + + assert len(nodes) == 4 + assert nodes[2]["data"]["name"] == "链接卡片" + assert "[图文] 另一篇图文" in nodes[2]["data"]["content"][0]["data"]["text"] + assert "[视频] 视频卡片" in nodes[3]["data"]["content"][0]["data"]["text"] + + +@pytest.mark.asyncio +async def test_nested_cards_degrade_when_budget_is_exhausted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fetch = AsyncMock(return_value=_info(TextBlock("嵌套正文"))) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", fetch) + + info = _info( + TextBlock("正文"), + *( + OpusCardBlock(opus_id=str(index), title=f"卡片{index}") + for index in range(1, 4) + ), + ) + config = _config(bilibili_opus_nested_max_cards=2) + nodes = await _build(info, config=config) + + names = [node["data"]["name"] for node in nodes] + assert names[0] == "图文信息" + assert names[1] == "正文" + assert names[2].startswith("嵌套图文") + assert names[3].startswith("嵌套图文") + assert names[4] == "链接卡片" + assert fetch.await_count == 2 + + +@pytest.mark.asyncio +async def test_nested_opus_nodes_are_recursive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + nested = _info( + TextBlock("嵌套正文"), + OpusCardBlock(opus_id="222", title="更深一层"), + title="嵌套图文", + ) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", AsyncMock(return_value=nested)) + + info = _info(TextBlock("正文"), OpusCardBlock(opus_id="111", title="一层")) + nodes = await _build(info, config=_config()) + + outer_nested = nodes[2] + assert outer_nested["data"]["name"] == "嵌套图文: 一层" + inner_forward = outer_nested["data"]["content"] + assert isinstance(inner_forward, list) + assert inner_forward[0]["data"]["name"] == "图文信息" + assert inner_forward[1]["data"]["name"] == "正文" + deeper = inner_forward[2] + assert deeper["data"]["name"] == "嵌套图文: 更深一层" + + +@pytest.mark.asyncio +async def test_nested_opus_failure_renders_error_node( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(side_effect=OpusUnavailableError("gone")), + ) + info = _info(TextBlock("正文"), OpusCardBlock(opus_id="111", title="一层")) + + nodes = await _build(info, config=_config()) + + assert nodes[2]["data"]["name"] == "嵌套图文: 一层" + assert "嵌套图文获取失败" in nodes[2]["data"]["content"] + + +@pytest.mark.asyncio +async def test_link_cards_become_their_own_nodes() -> None: + info = _info( + TextBlock("正文"), + LinkCardBlock(title="活动", jump_url="https://www.bilibili.com/x"), + LinkCardBlock(title="商品", cover_url="https://i0.hdslb.com/g.jpg"), + ) + + nodes = await _build(info, config=_config()) + + assert [node["data"]["name"] for node in nodes] == [ + "图文信息", + "正文", + "链接卡片", + "链接卡片", + ] + assert _segment_types(nodes[2]) == ["text"] + assert ( + nodes[2]["data"]["content"][0]["data"]["text"] + == "活动 — https://www.bilibili.com/x" + ) + assert _segment_types(nodes[3]) == ["image", "text"] + + +# ---------- 嵌套视频 ---------- + + +def _video_info() -> VideoInfo: + return VideoInfo( + bvid="BV1xx411c7mD", + aid=123, + title="投稿视频", + duration=120, + cover_url="https://i0.hdslb.com/v.jpg", + up_name="UP", + desc="简介", + cid=456, + page_duration=120, + stats=VideoStats(view=100, like=1), + ) + + +@pytest.mark.asyncio +async def test_nested_video_downloads_file_and_attaches( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + video_path = tmp_path / "v.mp4" + video_path.write_bytes(b"video") + monkeypatch.setattr( + opus_sender, + "download_video", + AsyncMock(return_value=(video_path, _video_info(), 80)), + ) + monkeypatch.setattr( + opus_sender, + "build_bilibili_video_nodes", + AsyncMock(return_value=(["nested"], [], None)), + ) + monkeypatch.setattr(opus_sender, "cleanup_file", MagicMock()) + + info = _info( + TextBlock("正文"), VideoCardBlock(bvid="BV1xx411c7mD", title="投稿视频") + ) + nodes = await _build(info, config=_config()) + + assert nodes[2]["data"]["name"] == "嵌套视频: 投稿视频" + assert nodes[2]["data"]["content"] == ["nested"] + + +@pytest.mark.asyncio +async def test_nested_video_falls_back_to_card_when_download_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, "download_video", AsyncMock(side_effect=RuntimeError("超时")) + ) + monkeypatch.setattr( + opus_sender, "get_video_info", AsyncMock(side_effect=RuntimeError("超时")) + ) + + info = _info( + TextBlock("正文"), + VideoCardBlock( + bvid="BV1xx411c7mD", + title="投稿视频", + cover_url="https://i0.hdslb.com/v.jpg", + jump_url="https://www.bilibili.com/video/BV1xx411c7mD", + ), + ) + nodes = await _build(info, config=_config()) + + assert nodes[2]["data"]["name"] == "嵌套视频: 投稿视频" + assert _segment_types(nodes[2]) == ["image", "text"] + assert "视频处理失败" in nodes[2]["data"]["content"][1]["data"]["text"] + + +# ---------- 发送与错误语义 ---------- + + +@pytest.mark.asyncio +async def test_send_opus_sends_forward_with_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(return_value=_info(TextBlock("正文"))), + ) + sender = _sender() + + result = await opus_sender.send_opus( + "933099353259638816", + sender=sender, + target_type="private", + target_id=20001, + config=_config(), + ) + + assert result.startswith("已发送 Bilibili 图文合并转发") + sender.send_private_forward_message.assert_awaited_once() + call = sender.send_private_forward_message.await_args + assert call.args[0] == 20001 + assert len(call.args[1]) == 2 + assert call.kwargs["history_message"].startswith("[Bilibili 图文] 「测试图文」") + + +@pytest.mark.asyncio +async def test_send_opus_propagates_delivery_uncertain( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(return_value=_info(TextBlock("正文"))), + ) + sender = _sender() + sender.send_group_forward_message.side_effect = OneBotDeliveryUncertainError( + "send_forward_msg", "timeout" + ) + + with pytest.raises(OneBotDeliveryUncertainError): + await opus_sender.send_opus( + "933099353259638816", + sender=sender, + target_type="group", + target_id=10001, + config=_config(), + ) + + sender.send_group_forward_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_opus_raises_when_fetch_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(side_effect=OpusUnavailableError("图文不存在")), + ) + sender = _sender() + + with pytest.raises(OpusUnavailableError): + await opus_sender.send_opus( + "1", + sender=sender, + target_type="group", + target_id=10001, + config=_config(), + ) + + sender.send_group_forward_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_opus_degrades_when_build_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(return_value=_info(TextBlock("正文"))), + ) + monkeypatch.setattr( + opus_sender, "build_opus_nodes", AsyncMock(side_effect=RuntimeError("构建失败")) + ) + sender = _sender() + + result = await opus_sender.send_opus( + "933099353259638816", + sender=sender, + target_type="group", + target_id=10001, + config=_config(), + ) + + assert result.startswith("处理失败,已发送 Bilibili 图文信息合并转发") + sender.send_group_forward_message.assert_awaited_once() + nodes = sender.send_group_forward_message.await_args.args[1] + assert nodes[0]["data"]["name"] == "图文信息" + assert nodes[1]["data"]["name"] == "正文" + assert "构建失败" in nodes[1]["data"]["content"] diff --git a/tests/test_handlers_pipelines.py b/tests/test_handlers_pipelines.py index 92d17e6b..4ed7ec7f 100644 --- a/tests/test_handlers_pipelines.py +++ b/tests/test_handlers_pipelines.py @@ -70,10 +70,12 @@ async def run(self, context: dict[str, Any]) -> list[object]: handler.onebot = SimpleNamespace() handler.pipeline_registry = registry handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = MagicMock(return_value=[]) handler._extract_arxiv_ids = MagicMock(return_value=[]) handler._extract_github_repo_ids = MagicMock(return_value=[]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() @@ -107,10 +109,12 @@ async def test_pipelines_processes_all_matches() -> None: is_github_auto_extract_allowed_private=lambda _uid: True, ) handler._extract_bilibili_ids = AsyncMock(return_value=["BV1xx411c7mD"]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = MagicMock(return_value=["https://v.douyin.com/abc/"]) handler._extract_arxiv_ids = MagicMock(return_value=["2501.01234"]) handler._extract_github_repo_ids = MagicMock(return_value=["69gg/Undefined"]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() @@ -160,10 +164,12 @@ async def test_wechat_pipeline_receives_address_bound_sender() -> None: is_github_auto_extract_allowed_private=lambda _uid: True, ) handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = MagicMock(return_value=[]) handler._extract_arxiv_ids = MagicMock(return_value=[]) handler._extract_github_repo_ids = MagicMock(return_value=["69gg/Undefined"]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() @@ -288,10 +294,12 @@ async def test_private_model_pool_command_runs_before_command_dispatch( handler._profile_name_refresh_cache = {} handler._collect_message_attachments = AsyncMock(return_value=[]) handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = MagicMock(return_value=[]) handler._extract_arxiv_ids = MagicMock(return_value=[]) handler._extract_github_repo_ids = MagicMock(return_value=[]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() @@ -358,10 +366,12 @@ async def test_private_message_starting_with_select_does_not_touch_model_pool( handler._profile_name_refresh_cache = {} handler._collect_message_attachments = AsyncMock(return_value=[]) handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = MagicMock(return_value=[]) handler._extract_arxiv_ids = MagicMock(return_value=[]) handler._extract_github_repo_ids = MagicMock(return_value=[]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() @@ -424,10 +434,12 @@ async def test_private_model_pool_command_ignored_when_pool_disabled( handler._profile_name_refresh_cache = {} handler._collect_message_attachments = AsyncMock(return_value=[]) handler._extract_bilibili_ids = AsyncMock(return_value=[]) + handler._extract_bilibili_opus_ids = AsyncMock(return_value=[]) handler._extract_douyin_ids = MagicMock(return_value=[]) handler._extract_arxiv_ids = MagicMock(return_value=[]) handler._extract_github_repo_ids = MagicMock(return_value=[]) handler._handle_bilibili_extract = AsyncMock() + handler._handle_bilibili_opus_extract = AsyncMock() handler._handle_douyin_extract = AsyncMock() handler._handle_arxiv_extract = AsyncMock() handler._handle_github_extract = AsyncMock() From 65ea4f4e67f1b1153f35ffb25d8446ac71f9884d Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 14:08:16 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(skills):=20=E6=96=B0=E5=A2=9E=20bili?= =?UTF-8?q?bili=5Fopus=20=E5=B7=A5=E5=85=B7=EF=BC=88=E5=8F=91=E9=80=81=20/?= =?UTF-8?q?=20UID=20/=20=E4=BF=A1=E6=81=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bilibili): opus_sender 支持附件 UID 与纯信息输出 - 新增 fetch_bilibili_opus_attachment:拉取图文后把正文图片逐张 register_remote_url 登记为 pic_xxx,超过附件大小上限时自动降级为 URL 引用;单张失败不中断,返回里逐条列出失败原因; - 新增 fetch_opus_info 与 format_opus_uid_message,供工具的 info / uid 模式复用,避免工具层再拼一遍格式化逻辑。 * feat(skills): 新增 bilibili_opus 主工具 - config.json 定义 opus_id / target_type / target_id / output_mode / max_images,output_mode 语义与 bilibili_video 对齐(send / uid / info); - handler 复用 OPUS_URL_PATTERN、DYNAMIC_ID_URL_PATTERN 与 SHORT_URL_PATTERN 做标识归一化,支持裸动态 ID、图文链接、t.bilibili.com 与 b23.tv 短链; - callable.json 暴露给 file_analysis_agent,图文图片可走 analyze_multimodal; - README 说明三种模式的流程与配置依赖。 * test(bilibili): 覆盖工具三种模式与附件登记边界 - 19 个用例:info 不发送、uid 登记与 max_images 截断、单图失败不中断、 缺少 registry/scope 的报错、send 目标解析与组件校验、标识归一化 (链接 / 动态域 / 短链 / 视频链接不误判); - skills 导入边界基线补充新工具的四条依赖记录。 * docs(skills): file_analysis_agent 与 usage 补充图文入口 - file_analysis_agent 的 prompt 增加“B 站图文先用 bilibili_opus (output_mode=uid) 拿 pic_xxx 再逐张多模态分析”的规则,README 同步; - docs/usage.md 独立原子工具表与 agents README 的共享工具清单补 bilibili_opus。 --- docs/usage.md | 1 + src/Undefined/bilibili/opus_sender.py | 97 +++++ src/Undefined/skills/agents/README.md | 2 +- .../agents/file_analysis_agent/README.md | 4 +- .../agents/file_analysis_agent/prompt.md | 1 + .../skills/tools/bilibili_opus/README.md | 39 ++ .../skills/tools/bilibili_opus/callable.json | 4 + .../skills/tools/bilibili_opus/config.json | 35 ++ .../skills/tools/bilibili_opus/handler.py | 143 +++++++ tests/test_bilibili_opus_tool.py | 400 ++++++++++++++++++ tests/test_skills_import_boundary.py | 5 + 11 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 src/Undefined/skills/tools/bilibili_opus/README.md create mode 100644 src/Undefined/skills/tools/bilibili_opus/callable.json create mode 100644 src/Undefined/skills/tools/bilibili_opus/config.json create mode 100644 src/Undefined/skills/tools/bilibili_opus/handler.py create mode 100644 tests/test_bilibili_opus_tool.py diff --git a/docs/usage.md b/docs/usage.md index 6be14809..5976207f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -369,6 +369,7 @@ QQ/NapCat 在 `sendMsg` 阶段返回超时并不等于消息未送达:服务 | `qq_like` | 给指定 QQ 号的资料卡点赞(默认 10 次) | | `python_interpreter` | 在隔离的 **Docker 容器**中执行 Python 代码,支持按需安装第三方库,可在执行后自动发送生成的文件(图片、CSV 等) | | `bilibili_video` | 发送哔哩哔哩视频、返回视频附件 UID,或只获取视频信息(支持 BV 号、AV 号、链接) | +| `bilibili_opus` | 发送哔哩哔哩图文(opus)合并转发、返回正文图片附件 UID,或只获取图文信息(支持动态 ID、图文链接、b23.tv 短链) | | `douyin_video` | 发送抖音视频、返回视频附件 UID,或只获取视频信息(支持短链、长链、aweme_id) | | `arxiv_paper` | 发送 arXiv 论文 PDF、返回 PDF 附件 UID,或只获取论文信息(支持 arXiv ID、链接) | | `fetch_image_uid` | 将指定 URL 的图片下载并转换为系统内部 uid | diff --git a/src/Undefined/bilibili/opus_sender.py b/src/Undefined/bilibili/opus_sender.py index 2555d6b0..98e6fb32 100644 --- a/src/Undefined/bilibili/opus_sender.py +++ b/src/Undefined/bilibili/opus_sender.py @@ -42,6 +42,7 @@ ) from Undefined.bilibili.opus_render import ( format_opus_history_message, + format_opus_info, format_opus_stats, parse_opus_item, ) @@ -56,6 +57,9 @@ _BOT_NAME = "Undefined" _DEFAULT_BOT_UIN = "10000" +# ``output_mode=uid`` 默认最多登记多少张图片,避免超大图文刷满附件表 +_UID_IMAGE_LIMIT = 9 + # 获取失败时可降级的语义(投递不确定 / 文件传输错误必须上抛) _FATAL_ERROR_FLAGS = ("delivery_uncertain", "file_transfer_error") @@ -442,6 +446,96 @@ async def _fetch_opus_info(opus_id: str, *, cookie: str, config: Any) -> OpusInf return parse_opus_item(item).info +async def fetch_opus_info(opus_id: str, *, cookie: str = "") -> OpusInfo: + """只获取图文信息(``output_mode=info`` 使用,不发送、不注册附件)。""" + return await _fetch_opus_info(opus_id, cookie=cookie, config=None) + + +# ---------- 附件 UID / 纯信息模式 ---------- + + +def format_opus_uid_message( + info: OpusInfo, + *, + uids: list[str], + image_total: int, + failures: list[str] | None = None, +) -> str: + """生成 ``output_mode=uid`` 的返回文案(含 ````)。""" + lines = [ + f"已获取 Bilibili 图文:{info.title or '无标题'}", + f"图文 ID: {info.opus_id}", + f"UP主: {info.author.name or '未知'}", + format_opus_stats(info.stats), + ] + published = format_timestamp(info.pub_ts) + if published: + lines.append(f"发布时间: {published}") + lines.append(f"图片: 已登记 {len(uids)}/{image_total} 张") + lines.extend( + f'图片 {index}: ' + for index, uid in enumerate(uids, start=1) + ) + for failure in failures or []: + lines.append(f"图片登记失败: {failure}") + lines.append(info.url) + return "\n".join(lines) + + +async def fetch_bilibili_opus_attachment( + opus_id: str, + *, + attachment_registry: Any, + scope_key: str, + cookie: str = "", + config: Any = None, + max_images: int = _UID_IMAGE_LIMIT, +) -> str: + """获取图文并把图片注册为当前会话附件 UID(不发送消息)。""" + if not str(opus_id or "").strip(): + return "图文 ID 不能为空" + if attachment_registry is None: + return "缺少必要的运行时组件(attachment_registry)" + if not str(scope_key or "").strip(): + return "无法确定附件作用域,不能注册图文图片" + + info = await _fetch_opus_info(str(opus_id), cookie=cookie, config=config) + images = info.images + if not images: + return f"{format_opus_info(info)}\n\n(该图文没有可登记的图片)" + + limit = max(1, int(max_images)) + uids: list[str] = [] + failures: list[str] = [] + for index, url in enumerate(images[:limit], start=1): + try: + record = await attachment_registry.register_remote_url( + scope_key, + url, + kind="image", + source_kind="bilibili_opus", + source_ref=info.url, + segment_data={"opus_id": info.opus_id, "title": info.title}, + ) + except Exception as exc: + logger.warning( + "[Bilibili] 图文图片登记失败: opus=%s #%s err=%s", opus_id, index, exc + ) + failures.append(f"#{index} {exc}") + continue + uids.append(str(record.uid)) + + message = format_opus_uid_message( + info, + uids=uids, + image_total=len(images), + failures=failures, + ) + if len(images) > limit: + message = f"{message}\n(仅登记前 {limit} 张图片,其余请见原文链接)" + return message + + # ---------- 发送 ---------- @@ -528,6 +622,9 @@ async def send_opus( __all__ = [ "build_opus_nodes", + "fetch_bilibili_opus_attachment", + "fetch_opus_info", + "format_opus_uid_message", "render_blocks_to_nodes", "send_opus", ] diff --git a/src/Undefined/skills/agents/README.md b/src/Undefined/skills/agents/README.md index 231822da..1690dde9 100644 --- a/src/Undefined/skills/agents/README.md +++ b/src/Undefined/skills/agents/README.md @@ -278,7 +278,7 @@ mv skills/tools/my_tool skills/agents/my_agent/tools/ - **功能**:分析用户提供的附件、内部 UID、URL、legacy file_id、arXiv 论文标识或 Bilibili 视频标识,提取文件内容。 - **适用场景**:PDF/Word/Excel/PPT/文本/代码/压缩包解析,图片、音频、视频等多模态内容识别,arXiv 论文 PDF 分析,Bilibili 视频内容分析。 - **不适用**:没有文件来源的开放式搜索、需要联网查资料的问题、执行文件或安全鉴定。 -- **子工具**:`download_file`, `detect_file_type`, `read_text_file`, `extract_pdf`, `describe_pdf_page`, `extract_docx`, `extract_xlsx`, `extract_pptx`, `extract_archive`, `analyze_code`, `analyze_multimodal`, `cleanup_temp`;还可调用共享主工具 `arxiv_paper(output_mode=uid)`、`bilibili_video(output_mode=uid)` 与 `douyin_video(output_mode=uid)` 获取待分析附件 UID。 +- **子工具**:`download_file`, `detect_file_type`, `read_text_file`, `extract_pdf`, `describe_pdf_page`, `extract_docx`, `extract_xlsx`, `extract_pptx`, `extract_archive`, `analyze_code`, `analyze_multimodal`, `cleanup_temp`;还可调用共享主工具 `arxiv_paper(output_mode=uid)`、`bilibili_video(output_mode=uid)`、`bilibili_opus(output_mode=uid)` 与 `douyin_video(output_mode=uid)` 获取待分析附件 UID(图文返回 `pic_xxx`)。 ### naga_code_analysis_agent(NagaAgent 代码分析助手) - **功能**:只读分析 NagaAgent 项目的结构、源码、配置、构建、部署和实现细节。 diff --git a/src/Undefined/skills/agents/file_analysis_agent/README.md b/src/Undefined/skills/agents/file_analysis_agent/README.md index 9f2fad80..5711033a 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/README.md +++ b/src/Undefined/skills/agents/file_analysis_agent/README.md @@ -1,13 +1,13 @@ # file_analysis_agent 智能体 -用于文件解析与分析(PDF/Word/Excel/PPT 等),并支持代码分析、多模态解析、arXiv 论文 PDF 获取分析、Bilibili 视频获取分析和抖音视频获取分析。 +用于文件解析与分析(PDF/Word/Excel/PPT 等),并支持代码分析、多模态解析、arXiv 论文 PDF 获取分析、Bilibili 视频与图文获取分析和抖音视频获取分析。 目录结构: - `config.json`:智能体定义 - `intro.md`:能力说明 - `prompt.md`:系统提示词 - `tools/`:文件解析与分析工具 -- 共享主工具:通过 callable 仅可调用 `arxiv_paper(output_mode=uid)`、`bilibili_video(output_mode=uid)` 与 `douyin_video(output_mode=uid)`,用于把 arXiv / Bilibili / 抖音标识转换为当前会话附件 UID 后再分析 +- 共享主工具:通过 callable 仅可调用 `arxiv_paper(output_mode=uid)`、`bilibili_video(output_mode=uid)`、`bilibili_opus(output_mode=uid)` 与 `douyin_video(output_mode=uid)`,用于把 arXiv / Bilibili 视频与图文 / 抖音标识转换为当前会话附件 UID 后再分析(图文会把正文图片逐张登记为 `pic_xxx`) 运行机制: - 由 `AgentRegistry` 自动发现并注册 diff --git a/src/Undefined/skills/agents/file_analysis_agent/prompt.md b/src/Undefined/skills/agents/file_analysis_agent/prompt.md index 07a0a7ab..03183f1d 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/prompt.md +++ b/src/Undefined/skills/agents/file_analysis_agent/prompt.md @@ -14,6 +14,7 @@ - 如果文件源是 arXiv ID、`arXiv:...`、`arxiv.org/abs/...` 或 `arxiv.org/pdf/...`,先调用共享工具 `arxiv_paper`,设置 `output_mode="uid"`,拿到 `` 后再按普通文件 UID 下载和分析。 - 如果文件源是 Bilibili BV 号、AV 号、B 站视频链接或 b23.tv 短链,先调用共享工具 `bilibili_video`,设置 `output_mode="uid"`,拿到 `` 后再按普通视频 UID 下载和分析。 - 如果文件源是抖音 `v.douyin.com` 短链、`douyin.com/video/` 长链或裸 aweme_id,先调用共享工具 `douyin_video`,设置 `output_mode="uid"`,拿到 `` 后再按普通视频 UID 下载和分析。 +- 如果文件源是 Bilibili 图文(`bilibili.com/opus/`、`t.bilibili.com/`、b23.tv 短链或裸动态 ID),先调用共享工具 `bilibili_opus`,设置 `output_mode="uid"`,拿到 `` 后再用 `analyze_multimodal` 逐张分析其中的图片;正文文字可从同一返回结果里读取,不需要再抓页面。 - 已经给出内部附件 UID 时,不要再调用 arXiv/Bilibili/抖音获取工具。 - 根据用户目标选择合适工具:文本读取、文件类型检测、PDF/Office/表格/代码/压缩包/多模态分析都按内容类型处理。 - `analyze_multimodal` 支持直接传入内部附件 UID;其他需要本地路径的工具应先调用 `download_file` 将 UID 转成本地临时文件路径。 diff --git a/src/Undefined/skills/tools/bilibili_opus/README.md b/src/Undefined/skills/tools/bilibili_opus/README.md new file mode 100644 index 00000000..0fbde8d3 --- /dev/null +++ b/src/Undefined/skills/tools/bilibili_opus/README.md @@ -0,0 +1,39 @@ +# bilibili_opus 工具 + +处理 Bilibili 图文(opus / 动态)。默认发送合并转发到群聊或私聊;也支持只把正文图片登记为附件 UID 供文件分析使用,或只获取图文信息。支持 `bilibili.com/opus/`、`m.bilibili.com/opus/`、`t.bilibili.com/`、`b23.tv` 短链、裸动态 ID 与 QQ 小程序分享卡片里提取到的 ID。 + +常用参数: +- `opus_id`:图文标识(动态 ID、图文链接或 b23.tv 短链) +- `target_type`:可选,目标会话类型(`group`/`private`) +- `target_id`:可选,目标会话 ID +- `output_mode`:可选,`send`(默认,发送合并转发)、`uid`(把正文图片登记为 ``,不发送消息)或 `info`(只返回图文信息,不发送) +- `max_images`:可选,`uid` 模式下最多登记多少张图片,默认 9 + +`send` 模式流程: +1. 解析 `opus_id` 为裸动态 ID(必要时解析 b23.tv 短链) +2. 请求 `x/polymer/web-dynamic/v1/opus/detail`,失败时回退 `x/polymer/web-dynamic/v1/detail`(带 WBI 签名) +3. 把 `modules` / `module_content.paragraphs[]` 渲染成块序列(文本、图片、分割线、引用、列表、代码、链接卡片) +4. 发送合并转发:第一条是图文信息(封面、标题、UP主、时间、数据、原文链接),第二条起是正文(文本与图片按原始顺序混排,按单节点 4000 字切分) +5. 正文里的图文/视频卡片各自成为独立嵌套合并转发,按 `opus_nested_depth` / `opus_nested_max_cards` 递归展开,超界降级为一行文本 + 链接 + +`uid` 模式流程: +1. 拉取图文并收集正文图片 URL +2. 按 `max_images` 上限逐张 `register_remote_url`(超过附件大小上限时自动降级为 URL 引用) +3. 返回图文概要与 `` 列表,供 `file_analysis_agent` 继续做多模态分析 + +`info` 模式流程: +1. 拉取图文并解析元数据 +2. 返回标题、图文 ID、UP主、发布时间、互动数据、封面、图片数量与原文链接 +3. 不发送消息、不注册附件 + +配置依赖: +- `config.toml` 中的 `[bilibili]` 段控制 Cookie、清晰度、时长与体积限制(嵌套视频卡片复用这些限制) +- `[bilibili].opus_enabled` 只控制自动提取管线;本工具不受该开关影响 + +自动提取行为: +- 自动处理管线命中图文链接、短链或分享卡片后,同样发送一次合并转发(元数据 + 内容 + 嵌套节点),每个图文最多处理 `opus_max_items` 条。 + +目录结构: +- `config.json`:工具定义 +- `callable.json`:允许 `file_analysis_agent` 调用 +- `handler.py`:执行逻辑 diff --git a/src/Undefined/skills/tools/bilibili_opus/callable.json b/src/Undefined/skills/tools/bilibili_opus/callable.json new file mode 100644 index 00000000..8781b6c6 --- /dev/null +++ b/src/Undefined/skills/tools/bilibili_opus/callable.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "allowed_callers": ["file_analysis_agent"] +} diff --git a/src/Undefined/skills/tools/bilibili_opus/config.json b/src/Undefined/skills/tools/bilibili_opus/config.json new file mode 100644 index 00000000..79641e2e --- /dev/null +++ b/src/Undefined/skills/tools/bilibili_opus/config.json @@ -0,0 +1,35 @@ +{ + "type": "function", + "function": { + "name": "bilibili_opus", + "description": "处理 Bilibili 图文(opus / 动态)。默认发送合并转发到群聊或私聊;可设置 output_mode=uid 只登记图片附件 UID,或 output_mode=info 只获取图文信息。支持 bilibili.com/opus/、t.bilibili.com/、b23.tv 短链或裸动态 ID。", + "parameters": { + "type": "object", + "properties": { + "opus_id": { + "type": "string", + "description": "Bilibili 图文标识:动态 ID(如 933099353259638816)、bilibili.com/opus/、t.bilibili.com/ 或 b23.tv 短链" + }, + "target_type": { + "type": "string", + "enum": ["group", "private"], + "description": "可选。目标会话类型" + }, + "target_id": { + "type": "integer", + "description": "可选。目标会话 ID" + }, + "output_mode": { + "type": "string", + "enum": ["send", "uid", "info"], + "description": "输出模式:send(默认,发送图文合并转发)、uid(仅把正文图片登记为附件 UID,不发送消息)或 info(仅返回图文信息,不发送)" + }, + "max_images": { + "type": "integer", + "description": "可选。output_mode=uid 时最多登记多少张图片,默认 9" + } + }, + "required": ["opus_id"] + } + } +} diff --git a/src/Undefined/skills/tools/bilibili_opus/handler.py b/src/Undefined/skills/tools/bilibili_opus/handler.py new file mode 100644 index 00000000..47361526 --- /dev/null +++ b/src/Undefined/skills/tools/bilibili_opus/handler.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, Literal + +from Undefined.attachments import scope_from_context +from Undefined.bilibili.opus_parser import DYNAMIC_ID_URL_PATTERN, OPUS_URL_PATTERN +from Undefined.bilibili.opus_render import format_opus_info +from Undefined.bilibili.opus_sender import ( + fetch_bilibili_opus_attachment, + fetch_opus_info, + send_opus, +) +from Undefined.bilibili.parser import SHORT_URL_PATTERN, resolve_short_url + +logger = logging.getLogger(__name__) + + +def _resolve_target( + args: Dict[str, Any], context: Dict[str, Any] +) -> tuple[tuple[Literal["group", "private"], int] | None, str | None]: + """解析目标会话,复用 send_message 的逻辑模式。""" + target_type_raw = args.get("target_type") + target_id_raw = args.get("target_id") + + if target_type_raw is not None and target_id_raw is not None: + target_type = str(target_type_raw).strip().lower() + if target_type not in ("group", "private"): + return None, "target_type 只能是 group 或 private" + try: + target_id = int(target_id_raw) + except (TypeError, ValueError): + return None, "target_id 必须是整数" + return (target_type, target_id), None # type: ignore[return-value] + + request_type = context.get("request_type") + if request_type == "group": + group_id = context.get("group_id") + if group_id: + return ("group", int(group_id)), None + elif request_type == "private": + user_id = context.get("user_id") + if user_id: + return ("private", int(user_id)), None + + group_id = context.get("group_id") + if group_id: + return ("group", int(group_id)), None + user_id = context.get("user_id") + if user_id: + return ("private", int(user_id)), None + + return None, "无法确定目标会话,请提供 target_type 与 target_id" + + +async def _normalize_opus_id(raw: str) -> str | None: + """把动态 ID / 图文链接 / 短链统一成裸动态 ID。""" + text = raw.strip() + if not text: + return None + if text.isdigit(): + return text + + for pattern in (OPUS_URL_PATTERN, DYNAMIC_ID_URL_PATTERN): + match = pattern.search(text) + if match: + return match.group(1) + + if SHORT_URL_PATTERN.search(text): + real_url = await resolve_short_url(text) + if real_url: + return await _normalize_opus_id(real_url) + return None + + +async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: + """获取并发送 Bilibili 图文(opus)。""" + raw_id = str(args.get("opus_id", "") or "").strip() + if not raw_id: + return "opus_id 不能为空" + + output_mode = str(args.get("output_mode", "send") or "send").strip().lower() + if output_mode not in {"send", "uid", "info"}: + return "output_mode 只能是 send、uid 或 info" + + runtime_config = context.get("runtime_config") + sender = context.get("sender") + onebot = context.get("onebot_client") or context.get("onebot") + if not onebot and sender is not None and hasattr(sender, "onebot"): + onebot = getattr(sender, "onebot") + + cookie = "" + if runtime_config: + cookie = getattr(runtime_config, "bilibili_cookie", "") + + try: + opus_id = await _normalize_opus_id(raw_id) + if not opus_id: + return f"无法解析图文标识: {raw_id}" + + if output_mode == "info": + return format_opus_info(await fetch_opus_info(opus_id, cookie=cookie)) + + if output_mode == "uid": + attachment_registry = context.get("attachment_registry") + scope_key = str(context.get("scope_key") or "").strip() + if not scope_key: + scope_key = scope_from_context(context) or "" + max_images_raw = args.get("max_images") + try: + max_images = int(max_images_raw) if max_images_raw is not None else 9 + except (TypeError, ValueError): + return "max_images 必须是整数" + if max_images <= 0: + return "max_images 必须大于 0" + return await fetch_bilibili_opus_attachment( + opus_id, + attachment_registry=attachment_registry, + scope_key=scope_key, + cookie=cookie, + config=runtime_config, + max_images=max_images, + ) + + target, error = _resolve_target(args, context) + if error or target is None: + return f"目标解析失败: {error or '参数错误'}" + target_type, target_id = target + + if not sender or not onebot: + return "缺少必要的运行时组件(sender/onebot)" + + return await send_opus( + opus_id, + sender=sender, + target_type=target_type, + target_id=target_id, + cookie=cookie, + config=runtime_config, + ) + except Exception as exc: + logger.exception("[bilibili_opus] 执行失败: %s", exc) + return f"图文处理失败: {exc}" diff --git a/tests/test_bilibili_opus_tool.py b/tests/test_bilibili_opus_tool.py new file mode 100644 index 00000000..71da2997 --- /dev/null +++ b/tests/test_bilibili_opus_tool.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +import Undefined.bilibili.opus_parser as opus_parser +import Undefined.bilibili.opus_sender as opus_sender +import Undefined.skills.tools.bilibili_opus.handler as handler_module +from Undefined.bilibili.models import ( + ImageBlock, + OpusAuthor, + OpusCardBlock, + OpusInfo, + OpusStats, + TextBlock, +) +from Undefined.bilibili.opus_sender import ( + fetch_bilibili_opus_attachment, + format_opus_uid_message, +) +from Undefined.skills.tools.bilibili_opus.handler import execute + + +def _info(*blocks: Any, **overrides: Any) -> OpusInfo: + payload: dict[str, Any] = { + "opus_id": "933099353259638816", + "title": "测试图文", + "blocks": tuple(blocks) or (TextBlock("正文"),), + "author": OpusAuthor(mid=1, name="测试UP"), + "stats": OpusStats(view=12345, like=88, comment=42, repost=3), + "pub_ts": 1700000000, + "cover_url": "https://i0.hdslb.com/cover.jpg", + } + payload.update(overrides) + return OpusInfo(**payload) + + +def _context(**overrides: Any) -> dict[str, Any]: + config = SimpleNamespace(bilibili_cookie="SESSDATA=xxx") + context: dict[str, Any] = { + "runtime_config": config, + "sender": SimpleNamespace( + send_group_message=AsyncMock(), + send_private_message=AsyncMock(), + ), + "onebot": SimpleNamespace(), + "group_id": 10001, + "request_type": "group", + "attachment_registry": SimpleNamespace(register_remote_url=AsyncMock()), + "scope_key": "group:10001", + } + context.update(overrides) + return context + + +# ---------- output_mode=info ---------- + + +@pytest.mark.asyncio +async def test_info_mode_returns_summary_without_sending( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_info()) + ) + context = _context() + + result = await execute( + {"opus_id": "933099353259638816", "output_mode": "info"}, context + ) + + assert "「测试图文」" in result + assert "图文 ID: 933099353259638816" in result + assert "数据: 阅读 1.2万" in result + assert result.endswith("https://www.bilibili.com/opus/933099353259638816") + context["sender"].send_group_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_opus_id_accepts_url_and_dynamic_url() -> None: + for raw in ( + "https://www.bilibili.com/opus/933099353259638816?suffix=1", + "https://t.bilibili.com/933099353259638816", + "m.bilibili.com/opus/933099353259638816", + "933099353259638816", + ): + assert await _normalized(raw) == "933099353259638816" + + +async def _normalized(raw: str) -> str | None: + from Undefined.skills.tools.bilibili_opus.handler import _normalize_opus_id + + return await _normalize_opus_id(raw) + + +@pytest.mark.asyncio +async def test_opus_id_resolves_short_link(monkeypatch: pytest.MonkeyPatch) -> None: + import Undefined.skills.tools.bilibili_opus.handler as handler + + monkeypatch.setattr( + handler, + "resolve_short_url", + AsyncMock(return_value="https://www.bilibili.com/opus/555555555555555"), + ) + assert await _normalized("https://b23.tv/abcd123") == "555555555555555" + + +@pytest.mark.asyncio +async def test_unparsable_opus_id_reports_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fetch = AsyncMock(return_value=_info()) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", fetch) + + result = await execute({"opus_id": "https://example.com/not-opus"}, _context()) + + assert result.startswith("无法解析图文标识") + fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_empty_opus_id_and_bad_mode() -> None: + assert await execute({"opus_id": " "}, _context()) == "opus_id 不能为空" + assert ( + await execute({"opus_id": "1", "output_mode": "bogus"}, _context()) + == "output_mode 只能是 send、uid 或 info" + ) + + +# ---------- output_mode=uid ---------- + + +@pytest.mark.asyncio +async def test_uid_mode_registers_images(monkeypatch: pytest.MonkeyPatch) -> None: + info = _info( + TextBlock("正文"), + ImageBlock(("https://i0.hdslb.com/1.jpg", "https://i0.hdslb.com/2.jpg")), + ) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", AsyncMock(return_value=info)) + registry = SimpleNamespace( + register_remote_url=AsyncMock( + side_effect=[ + SimpleNamespace(uid="pic_aaa"), + SimpleNamespace(uid="pic_bbb"), + ] + ) + ) + context = _context(attachment_registry=registry) + + result = await execute( + {"opus_id": "933099353259638816", "output_mode": "uid"}, context + ) + + assert '图片 1: ' in result + assert '图片 2: ' in result + assert "图片: 已登记 2/2 张" in result + assert registry.register_remote_url.await_count == 2 + first = registry.register_remote_url.await_args_list[0] + assert first.args == ("group:10001", "https://i0.hdslb.com/1.jpg") + assert first.kwargs["kind"] == "image" + assert first.kwargs["source_kind"] == "bilibili_opus" + + +@pytest.mark.asyncio +async def test_uid_mode_respects_max_images_and_reports_truncation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + info = _info( + ImageBlock(tuple(f"https://i0.hdslb.com/{index}.jpg" for index in range(1, 5))) + ) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", AsyncMock(return_value=info)) + registry = SimpleNamespace( + register_remote_url=AsyncMock( + side_effect=[SimpleNamespace(uid=f"pic_{index}") for index in range(1, 5)] + ) + ) + + result = await execute( + {"opus_id": "1", "output_mode": "uid", "max_images": 2}, + _context(attachment_registry=registry), + ) + + assert registry.register_remote_url.await_count == 2 + assert "图片: 已登记 2/4 张" in result + assert "仅登记前 2 张图片" in result + + +@pytest.mark.asyncio +async def test_uid_mode_keeps_going_after_one_image_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + info = _info( + ImageBlock(("https://i0.hdslb.com/1.jpg", "https://i0.hdslb.com/2.jpg")) + ) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", AsyncMock(return_value=info)) + registry = SimpleNamespace( + register_remote_url=AsyncMock( + side_effect=[RuntimeError("下载失败"), SimpleNamespace(uid="pic_ok")] + ) + ) + + result = await execute( + {"opus_id": "1", "output_mode": "uid"}, + _context(attachment_registry=registry), + ) + + assert '图片 1: ' in result + assert "图片登记失败" in result + assert "下载失败" in result + + +@pytest.mark.asyncio +async def test_uid_mode_reports_missing_components( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fetch = AsyncMock(return_value=_info(ImageBlock(("https://i0.hdslb.com/1.jpg",)))) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", fetch) + + no_registry = _context(attachment_registry=None) + assert "缺少必要的运行时组件" in await execute( + {"opus_id": "1", "output_mode": "uid"}, no_registry + ) + + # scope_key 为空时先尝试 scope_from_context 兜底,兜底也失败才报错 + no_scope = _context(scope_key="", group_id=None, request_type=None, user_id=None) + assert "无法确定附件作用域" in await execute( + {"opus_id": "1", "output_mode": "uid"}, no_scope + ) + fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_uid_mode_reports_no_images(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(return_value=_info(TextBlock("只有文字"))), + ) + registry = SimpleNamespace(register_remote_url=AsyncMock()) + + result = await execute( + {"opus_id": "1", "output_mode": "uid"}, + _context(attachment_registry=registry), + ) + + assert "没有可登记的图片" in result + registry.register_remote_url.assert_not_awaited() + + +# ---------- output_mode=send ---------- + + +@pytest.mark.asyncio +async def test_send_mode_uses_resolved_target( + monkeypatch: pytest.MonkeyPatch, +) -> None: + send = AsyncMock(return_value="已发送 Bilibili 图文合并转发「测试图文」") + # 工具从 opus_sender 导入函数本体,需 patch 工具模块里的绑定 + monkeypatch.setattr(handler_module, "send_opus", send) + + result = await execute( + {"opus_id": "1", "target_type": "private", "target_id": 7}, _context() + ) + + assert result.startswith("已发送 Bilibili 图文合并转发") + send.assert_awaited_once() + call = send.await_args + assert call is not None + assert call.args[0] == "1" + assert call.kwargs["target_type"] == "private" + assert call.kwargs["target_id"] == 7 + assert call.kwargs["cookie"] == "SESSDATA=xxx" + + +@pytest.mark.asyncio +async def test_send_mode_requires_target() -> None: + context = _context() + context.pop("group_id") + context.pop("request_type") + + result = await execute({"opus_id": "1"}, context) + + assert result.startswith("目标解析失败") + + +@pytest.mark.asyncio +async def test_send_mode_requires_sender_and_onebot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(opus_sender, "send_opus", AsyncMock()) + context = _context(sender=None) + + result = await execute({"opus_id": "1"}, context) + + assert "缺少必要的运行时组件" in result + + +# ---------- 附件模式辅助 ---------- + + +def test_format_opus_uid_message_lists_uids_and_failures() -> None: + message = format_opus_uid_message( + _info(), + uids=["pic_1", "pic_2"], + image_total=3, + failures=["#3 boom"], + ) + + assert '图片 1: ' in message + assert '图片 2: ' in message + assert "图片: 已登记 2/3 张" in message + assert "图片登记失败: #3 boom" in message + + +@pytest.mark.asyncio +async def test_fetch_attachment_clamps_max_images( + monkeypatch: pytest.MonkeyPatch, +) -> None: + info = _info( + ImageBlock(("https://i0.hdslb.com/1.jpg", "https://i0.hdslb.com/2.jpg")) + ) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", AsyncMock(return_value=info)) + registry = SimpleNamespace( + register_remote_url=AsyncMock( + side_effect=[SimpleNamespace(uid="pic_1"), SimpleNamespace(uid="pic_2")] + ) + ) + + result = await fetch_bilibili_opus_attachment( + "1", + attachment_registry=registry, + scope_key="group:1", + max_images=0, + ) + + # max_images<=0 收敛为 1 张,避免返回空结果 + assert registry.register_remote_url.await_count == 1 + assert "已登记 1/2 张" in result + + +@pytest.mark.asyncio +async def test_fetch_attachment_validates_inputs() -> None: + registry = SimpleNamespace(register_remote_url=AsyncMock()) + assert ( + await fetch_bilibili_opus_attachment( + " ", attachment_registry=registry, scope_key="group:1" + ) + == "图文 ID 不能为空" + ) + assert "缺少必要的运行时组件" in await fetch_bilibili_opus_attachment( + "1", attachment_registry=None, scope_key="group:1" + ) + assert "无法确定附件作用域" in await fetch_bilibili_opus_attachment( + "1", attachment_registry=registry, scope_key=" " + ) + + +@pytest.mark.asyncio +async def test_normalize_opus_id_ignores_unrelated_text() -> None: + from Undefined.skills.tools.bilibili_opus.handler import _normalize_opus_id + + assert await _normalize_opus_id("BV1xx411c7mD") is None + assert ( + await _normalize_opus_id("https://www.bilibili.com/video/BV1xx411c7mD") is None + ) + + +@pytest.mark.asyncio +async def test_opus_card_block_does_not_break_attachment_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """uid 模式只关心图片;正文里的嵌套卡片不应触发额外请求。""" + info = _info( + TextBlock("正文"), + OpusCardBlock(opus_id="999", title="嵌套"), + ImageBlock(("https://i0.hdslb.com/1.jpg",)), + ) + fetch = AsyncMock(return_value=info) + monkeypatch.setattr(opus_sender, "_fetch_opus_info", fetch) + registry = SimpleNamespace( + register_remote_url=AsyncMock(return_value=SimpleNamespace(uid="pic_1")) + ) + + result = await fetch_bilibili_opus_attachment( + "1", attachment_registry=registry, scope_key="group:1" + ) + + assert fetch.await_count == 1 + assert "已登记 1/1 张" in result + + +def test_opus_parser_patterns_are_reused_by_tool() -> None: + """工具复用管线同一套正则,避免两处漂移。""" + assert opus_parser.OPUS_URL_PATTERN.search("bilibili.com/opus/123456") is not None + assert ( + opus_parser.DYNAMIC_ID_URL_PATTERN.search("t.bilibili.com/123456") is not None + ) diff --git a/tests/test_skills_import_boundary.py b/tests/test_skills_import_boundary.py index c66897ab..7941024a 100644 --- a/tests/test_skills_import_boundary.py +++ b/tests/test_skills_import_boundary.py @@ -81,6 +81,11 @@ "tools/bilibili_video/handler.py::Undefined.bilibili.downloader", "tools/bilibili_video/handler.py::Undefined.bilibili.parser", "tools/bilibili_video/handler.py::Undefined.bilibili.sender", + "tools/bilibili_opus/handler.py::Undefined.attachments", + "tools/bilibili_opus/handler.py::Undefined.bilibili.opus_parser", + "tools/bilibili_opus/handler.py::Undefined.bilibili.opus_render", + "tools/bilibili_opus/handler.py::Undefined.bilibili.opus_sender", + "tools/bilibili_opus/handler.py::Undefined.bilibili.parser", "tools/changelog_query/handler.py::Undefined.changelog", "tools/douyin_video/handler.py::Undefined.attachments", "tools/douyin_video/handler.py::Undefined.douyin.client", From 7f1124aa551e6c6b5238d990a8e05ea9e166934a Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 14:16:45 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(skills):=20opus=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E5=AD=97=E6=95=B0=E5=8C=BA=E9=97=B4?= =?UTF-8?q?=E4=B8=8E=E5=85=B3=E9=94=AE=E8=AF=8D=E8=AF=BB=E5=8F=96=E6=AD=A3?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bilibili): opus_render 新增正文片段提取与渲染 - 新增 OpusSegment / extract_opus_text:把正文块渲染成纯文本(图片 [图片 xN]、卡片 [图文]/[视频] 标题 链接 占位)后按字符区间截取, offset 给出片段在全文中的起点,便于 caller 继续翻页; - 新增 keyword 查询:返回命中区间(最多 5 处)与前后各 60 字上下文, 重叠/相邻片段自动合并,命中过多时提示只展示前 5 处; - 新增 format_opus_segment:返回头给出正文总字数与本次范围,未读完时 提示下一段可用 start=N;关键词未命中、正文为空、start 超出范围都给出 明确文案而不是空内容; - 参数校验抛 ValueError(limit<=0 / >20000、负数 start/end、关键词短于 2 字或长于 200 字),由工具层转成可读提示。 * feat(skills): bilibili_opus 工具新增 output_mode=text - 新增 start / end / limit / keyword 四个可选参数,默认返回前 1000 字, 上限 20000(超限报错而非静默截断); - 修掉 limit=0 被“or 默认值”吞掉的缺陷:现在显式 0 会走校验报错; - 工具描述与 README 补充 text 模式流程,usage 文档同步。 * test(bilibili): 覆盖 text 模式的分页、关键词与边界 - 渲染层 20 个用例:默认 1000 字、显式区间、start+limit 尾段、start 超出 正文、end str: else: parts.append(f"[卡片] {block.title} {block.jump_url}".strip()) return "\n\n".join(part for part in parts if part) + + +@dataclass(slots=True, frozen=True) +class OpusSegment: + """按字符区间或在关键词上下文里截取的正文片段。""" + + text: str + total_chars: int + offset: int = 0 + has_more: bool = False + ranges: tuple[tuple[int, int], ...] = () + query: str = "range" + keyword: str = "" + + +def _merge_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]: + """合并重叠或相邻的区间,避免上下文重复。""" + if not ranges: + return [] + ordered = sorted(ranges) + merged: list[list[int]] = [[ordered[0][0], ordered[0][1]]] + for start, end in ordered[1:]: + last = merged[-1] + if start <= last[1]: + last[1] = max(last[1], end) + else: + merged.append([start, end]) + return [(start, end) for start, end in merged] + + +def extract_opus_text( + info: OpusInfo, + *, + start: int | None = None, + end: int | None = None, + limit: int | None = OPUS_TEXT_DEFAULT_LIMIT, + keyword: str = "", +) -> OpusSegment: + """返回正文纯文本的可见片段。 + + - 指定 ``keyword`` 时按关键词返回上下文片段,忽略 ``start`` / ``end``; + - 否则按 ``[start, end)`` 返回字符区间,``end`` 缺省时取 ``start + limit``。 + + ``offset`` 为该片段在完整正文中的起始字符位置,供调用方继续翻页。 + 参数非法(负数、limit<=0、关键词过长)时抛 :class:`ValueError`, + 由工具层转成可读提示。 + """ + text = format_blocks_text(info.blocks) + total = len(text) + + query = str(keyword or "").strip() + if query: + if len(query) > OPUS_TEXT_MAX_KEYWORD_LENGTH: + raise ValueError( + f"keyword 过长({len(query)} 字,上限 {OPUS_TEXT_MAX_KEYWORD_LENGTH})" + ) + if len(query) < OPUS_TEXT_MIN_KEYWORD_LENGTH: + raise ValueError( + f"keyword 至少 {OPUS_TEXT_MIN_KEYWORD_LENGTH} 个字,过短会命中大量无关位置" + ) + return _keyword_segment(text, query) if text else OpusSegment("", 0) + + if not text: + return OpusSegment(text="", total_chars=0) + + if limit is None: + size = OPUS_TEXT_DEFAULT_LIMIT + else: + size = int(limit) + if size <= 0: + raise ValueError("limit 必须大于 0") + if size > OPUS_TEXT_MAX_LIMIT: + raise ValueError(f"limit 过大({size},上限 {OPUS_TEXT_MAX_LIMIT})") + + if start is not None and int(start) < 0: + raise ValueError("start 不能为负数") + if end is not None and int(end) < 0: + raise ValueError("end 不能为负数") + + begin = max(0, int(start or 0)) + if begin >= total: + return OpusSegment( + text="", + total_chars=total, + offset=begin, + has_more=False, + ranges=(), + ) + finish = total if end is None else int(end) + finish = min(finish, begin + size, total) + if finish <= begin: + finish = min(begin + size, total) + return OpusSegment( + text=text[begin:finish], + total_chars=total, + offset=begin, + has_more=finish < total, + ranges=((begin, finish),), + ) + + +def _keyword_segment(text: str, keyword: str) -> OpusSegment: + total = len(text) + matches: list[int] = [] + cursor = text.find(keyword) + while cursor != -1: + matches.append(cursor) + cursor = text.find(keyword, cursor + len(keyword)) + + if not matches: + return OpusSegment( + text="", + total_chars=total, + has_more=False, + ranges=(), + query="keyword", + keyword=keyword, + ) + + ranges = _merge_ranges( + [ + ( + max(0, index - OPUS_TEXT_KEYWORD_CONTEXT), + min(total, index + len(keyword) + OPUS_TEXT_KEYWORD_CONTEXT), + ) + for index in matches[:OPUS_TEXT_KEYWORD_MAX_MATCHES] + ] + ) + parts: list[str] = [] + offset = ranges[0][0] + for index, (range_start, range_end) in enumerate(ranges, start=1): + if index > 1: + parts.append(f"\n…\n(片段 {index})") + parts.append(text[range_start:range_end]) + return OpusSegment( + text="".join(parts), + total_chars=total, + offset=offset, + has_more=len(matches) > OPUS_TEXT_KEYWORD_MAX_MATCHES, + ranges=tuple(ranges), + query="keyword", + keyword=keyword, + ) + + +def format_opus_segment(info: OpusInfo, segment: OpusSegment) -> str: + """把正文片段渲染成 ``output_mode=text`` 的返回文案。""" + lines = [ + f"「{info.title or '无标题'}」", + f"图文 ID: {info.opus_id}", + f"UP主: {info.author.name or '未知'}", + f"发布时间: {format_timestamp(info.pub_ts) or '未知'}", + ] + + if segment.query == "keyword": + if not segment.text: + lines.append( + f"关键词「{segment.keyword}」在正文中没有命中(正文共 {segment.total_chars} 字)" + ) + lines.append(info.url) + return "\n".join(lines) + occurrence_text = "、".join(f"{start}-{end}" for start, end in segment.ranges) + lines.append( + f"关键词「{segment.keyword}」命中位置: {occurrence_text}" + f"(正文共 {segment.total_chars} 字,本次给出 {len(segment.ranges)} 个片段)" + ) + elif segment.total_chars == 0: + lines.append("(该图文没有正文文字)") + lines.append(info.url) + return "\n".join(lines) + elif not segment.text: + lines.append( + f"start={segment.offset} 已超出正文范围(正文共 {segment.total_chars} 字)" + ) + lines.append(info.url) + return "\n".join(lines) + else: + lines.append( + f"正文共 {segment.total_chars} 字,本次返回 {segment.offset}-" + f"{segment.offset + len(segment.text)} 字" + ) + + lines.extend(["---", segment.text, "---"]) + if segment.query == "keyword": + if segment.has_more: + lines.append(f"(命中较多,仅展示前 {OPUS_TEXT_KEYWORD_MAX_MATCHES} 处)") + elif segment.has_more: + lines.append( + f"(还有后续内容,可用 start={segment.offset + len(segment.text)} 继续读取)" + ) + else: + lines.append("(已到正文结尾)") + lines.append(info.url) + return "\n".join(lines) diff --git a/src/Undefined/skills/tools/bilibili_opus/README.md b/src/Undefined/skills/tools/bilibili_opus/README.md index 0fbde8d3..3d0b5ee2 100644 --- a/src/Undefined/skills/tools/bilibili_opus/README.md +++ b/src/Undefined/skills/tools/bilibili_opus/README.md @@ -1,13 +1,16 @@ # bilibili_opus 工具 -处理 Bilibili 图文(opus / 动态)。默认发送合并转发到群聊或私聊;也支持只把正文图片登记为附件 UID 供文件分析使用,或只获取图文信息。支持 `bilibili.com/opus/`、`m.bilibili.com/opus/`、`t.bilibili.com/`、`b23.tv` 短链、裸动态 ID 与 QQ 小程序分享卡片里提取到的 ID。 +处理 Bilibili 图文(opus / 动态)。默认发送合并转发到群聊或私聊;也支持只把正文图片登记为附件 UID 供文件分析使用、只获取图文信息,或按字数区间 / 关键词读取正文文字。支持 `bilibili.com/opus/`、`m.bilibili.com/opus/`、`t.bilibili.com/`、`b23.tv` 短链、裸动态 ID 与 QQ 小程序分享卡片里提取到的 ID。 常用参数: - `opus_id`:图文标识(动态 ID、图文链接或 b23.tv 短链) - `target_type`:可选,目标会话类型(`group`/`private`) - `target_id`:可选,目标会话 ID -- `output_mode`:可选,`send`(默认,发送合并转发)、`uid`(把正文图片登记为 ``,不发送消息)或 `info`(只返回图文信息,不发送) +- `output_mode`:可选,`send`(默认,发送合并转发)、`uid`(把正文图片登记为 ``,不发送消息)、`info`(只返回图文元信息)或 `text`(只返回正文文字) - `max_images`:可选,`uid` 模式下最多登记多少张图片,默认 9 +- `start` / `end`:可选,`text` 模式的起始字与结束字(0 基,`end` 不含),不填即从开头读取 +- `limit`:可选,`text` 模式未显式给 `end` 时的字数为上限,默认 1000,上限 20000 +- `keyword`:可选,`text` 模式的关键词查询,返回命中位置与前后文片段(最多 5 处),此时 `start` / `end` 被忽略 `send` 模式流程: 1. 解析 `opus_id` 为裸动态 ID(必要时解析 b23.tv 短链) @@ -26,6 +29,13 @@ 2. 返回标题、图文 ID、UP主、发布时间、互动数据、封面、图片数量与原文链接 3. 不发送消息、不注册附件 +`text` 模式流程: +1. 把正文块渲染成纯文本(图片以 `[图片 xN]` 占位,卡片以 `[图文]/[视频] 标题 链接` 占位) +2. 无 `keyword` 时按 `[start, end)` 返回字符区间;`end` 缺省取 `start + limit`(默认 1000 字) +3. 有 `keyword` 时返回该词在正文中的命中区间(最多 5 处)与前后各 60 字的上下文,重叠片段自动合并 +4. 返回头给出正文总字数与本次范围,未读完时提示下一段可用 `start=N` 继续;关键词未命中时明确说明 +5. 不发送消息、不注册附件;`limit<=0`、`limit>20000`、负数 `start`/`end`、关键词短于 2 字或长于 200 字都返回可读错误 + 配置依赖: - `config.toml` 中的 `[bilibili]` 段控制 Cookie、清晰度、时长与体积限制(嵌套视频卡片复用这些限制) - `[bilibili].opus_enabled` 只控制自动提取管线;本工具不受该开关影响 diff --git a/src/Undefined/skills/tools/bilibili_opus/config.json b/src/Undefined/skills/tools/bilibili_opus/config.json index 79641e2e..b0877544 100644 --- a/src/Undefined/skills/tools/bilibili_opus/config.json +++ b/src/Undefined/skills/tools/bilibili_opus/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "bilibili_opus", - "description": "处理 Bilibili 图文(opus / 动态)。默认发送合并转发到群聊或私聊;可设置 output_mode=uid 只登记图片附件 UID,或 output_mode=info 只获取图文信息。支持 bilibili.com/opus/、t.bilibili.com/、b23.tv 短链或裸动态 ID。", + "description": "处理 Bilibili 图文(opus / 动态)。默认发送合并转发到群聊或私聊;可设置 output_mode=uid 只登记图片附件 UID,output_mode=info 只获取图文信息,或 output_mode=text 按字数区间 / 关键词读取正文文字(默认返回前 1000 字,可用 start / end 定位、keyword 查询)。支持 bilibili.com/opus/、t.bilibili.com/、b23.tv 短链或裸动态 ID。", "parameters": { "type": "object", "properties": { @@ -21,12 +21,28 @@ }, "output_mode": { "type": "string", - "enum": ["send", "uid", "info"], - "description": "输出模式:send(默认,发送图文合并转发)、uid(仅把正文图片登记为附件 UID,不发送消息)或 info(仅返回图文信息,不发送)" + "enum": ["send", "uid", "info", "text"], + "description": "输出模式:send(默认,发送图文合并转发)、uid(仅把正文图片登记为附件 UID,不发送)、info(仅返回图文元信息)、text(只返回正文文字,受 start / end / limit / keyword 控制)" }, "max_images": { "type": "integer", "description": "可选。output_mode=uid 时最多登记多少张图片,默认 9" + }, + "start": { + "type": "integer", + "description": "可选,output_mode=text 用。起始字位置,从 0 开始;不填即从正文开头读取" + }, + "end": { + "type": "integer", + "description": "可选,output_mode=text 用。结束字位置(不含该字);不填则为 start + limit" + }, + "limit": { + "type": "integer", + "description": "可选,output_mode=text 用。未显式给 end 时返回的字数上限,默认 1000,最大 20000" + }, + "keyword": { + "type": "string", + "description": "可选,output_mode=text 用。关键词查询:返回该词在正文中的命中位置与前后文片段(最多 5 处),此时 start / end 被忽略" } }, "required": ["opus_id"] diff --git a/src/Undefined/skills/tools/bilibili_opus/handler.py b/src/Undefined/skills/tools/bilibili_opus/handler.py index 47361526..74f1472f 100644 --- a/src/Undefined/skills/tools/bilibili_opus/handler.py +++ b/src/Undefined/skills/tools/bilibili_opus/handler.py @@ -5,7 +5,12 @@ from Undefined.attachments import scope_from_context from Undefined.bilibili.opus_parser import DYNAMIC_ID_URL_PATTERN, OPUS_URL_PATTERN -from Undefined.bilibili.opus_render import format_opus_info +from Undefined.bilibili.opus_render import ( + OPUS_TEXT_DEFAULT_LIMIT, + extract_opus_text, + format_opus_info, + format_opus_segment, +) from Undefined.bilibili.opus_sender import ( fetch_bilibili_opus_attachment, fetch_opus_info, @@ -53,6 +58,17 @@ def _resolve_target( return None, "无法确定目标会话,请提供 target_type 与 target_id" +def _optional_int(args: Dict[str, Any], name: str) -> int | None: + """把可选整型参数解析成 int;非法值抛 ``ValueError``(由调用方转成提示)。""" + raw = args.get(name) + if raw is None or (isinstance(raw, str) and not raw.strip()): + return None + try: + return int(raw) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} 必须是整数") from exc + + async def _normalize_opus_id(raw: str) -> str | None: """把动态 ID / 图文链接 / 短链统一成裸动态 ID。""" text = raw.strip() @@ -80,8 +96,8 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: return "opus_id 不能为空" output_mode = str(args.get("output_mode", "send") or "send").strip().lower() - if output_mode not in {"send", "uid", "info"}: - return "output_mode 只能是 send、uid 或 info" + if output_mode not in {"send", "uid", "info", "text"}: + return "output_mode 只能是 send、uid、info 或 text" runtime_config = context.get("runtime_config") sender = context.get("sender") @@ -101,6 +117,22 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if output_mode == "info": return format_opus_info(await fetch_opus_info(opus_id, cookie=cookie)) + if output_mode == "text": + info = await fetch_opus_info(opus_id, cookie=cookie) + try: + limit = _optional_int(args, "limit") + segment = extract_opus_text( + info, + start=_optional_int(args, "start"), + end=_optional_int(args, "end"), + # 未显式传 limit 时用默认 1000;显式传 0 交给校验报错 + limit=OPUS_TEXT_DEFAULT_LIMIT if limit is None else limit, + keyword=str(args.get("keyword") or ""), + ) + except ValueError as exc: + return str(exc) + return format_opus_segment(info, segment) + if output_mode == "uid": attachment_registry = context.get("attachment_registry") scope_key = str(context.get("scope_key") or "").strip() diff --git a/tests/test_bilibili_opus_render.py b/tests/test_bilibili_opus_render.py index 74b87275..ebead35b 100644 --- a/tests/test_bilibili_opus_render.py +++ b/tests/test_bilibili_opus_render.py @@ -7,13 +7,17 @@ from Undefined.bilibili.models import ( ImageBlock, LinkCardBlock, + OpusAuthor, OpusCardBlock, + OpusInfo, TextBlock, VideoCardBlock, ) from Undefined.bilibili.opus_render import ( + extract_opus_text, format_opus_history_message, format_opus_info, + format_opus_segment, format_opus_stats, parse_opus_item, ) @@ -478,3 +482,241 @@ def test_format_opus_history_message_includes_body_text() -> None: assert "视频: 长度超限" in message assert "第一段" in message assert "[图片 x1]" in message + + +# ---------- output_mode=text ---------- + + +def _text_info(body: str, *, parts: int = 1) -> OpusInfo: + """构造一篇正文为 ``body`` 的图文(parts>1 时按段切开)。 + + 段落之间会插入 ``\\n\\n``,因此 ``total_chars`` 会略大于 ``len(body)``; + 调用方需要精确字数时用 ``parts=1``。 + """ + if parts == 1: + blocks: tuple[Any, ...] = (TextBlock(body),) + else: + size = max(1, len(body) // parts) + blocks = tuple( + TextBlock(body[index : index + size]) for index in range(0, len(body), size) + ) + return OpusInfo( + opus_id="933099353259638816", + title="长文图文", + blocks=blocks, + author=OpusAuthor(name="测试UP"), + pub_ts=1700000000, + ) + + +def test_text_default_returns_first_1000_chars() -> None: + info = _text_info("甲" * 2500) + + segment = extract_opus_text(info) + + assert segment.total_chars == 2500 + assert segment.offset == 0 + assert len(segment.text) == 1000 + assert segment.has_more is True + assert segment.ranges == ((0, 1000),) + + +def test_text_start_and_end_select_exact_range() -> None: + info = _text_info("".join(str(index % 10) for index in range(500))) + + segment = extract_opus_text(info, start=120, end=130) + + assert segment.text == "0123456789" + assert segment.offset == 120 + assert segment.has_more is True + + +def test_text_start_without_end_uses_limit() -> None: + info = _text_info("乙" * 5000) + + segment = extract_opus_text(info, start=1000, limit=500) + + assert len(segment.text) == 500 + assert segment.offset == 1000 + assert segment.has_more is True + + tail = extract_opus_text(info, start=4800, limit=500) + assert len(tail.text) == 200 + assert tail.has_more is False + + +def test_text_start_beyond_total_returns_empty() -> None: + info = _text_info("短正文") + + segment = extract_opus_text(info, start=100) + + assert segment.text == "" + assert segment.total_chars == 3 + assert segment.has_more is False + + +def test_text_end_before_start_falls_back_to_limit() -> None: + info = _text_info("丙" * 2000) + + segment = extract_opus_text(info, start=500, end=100, limit=200) + + assert len(segment.text) == 200 + assert segment.offset == 500 + + +def test_text_empty_body() -> None: + info = OpusInfo( + opus_id="1", + title="空", + blocks=(), + author=OpusAuthor(), + ) + + segment = extract_opus_text(info) + + assert segment.total_chars == 0 + assert segment.text == "" + + # 空文本块同样被跳过 + blank = OpusInfo( + opus_id="1", + title="空", + blocks=(TextBlock(""),), + author=OpusAuthor(), + ) + assert extract_opus_text(blank).total_chars == 0 + + +def test_text_invalid_arguments_raise() -> None: + info = _text_info("丁" * 100) + + with pytest.raises(ValueError, match="limit 必须大于 0"): + extract_opus_text(info, limit=0) + with pytest.raises(ValueError, match="limit 过大"): + extract_opus_text(info, limit=99999) + with pytest.raises(ValueError, match="start 不能为负数"): + extract_opus_text(info, start=-1) + with pytest.raises(ValueError, match="end 不能为负数"): + extract_opus_text(info, end=-1) + with pytest.raises(ValueError, match="keyword 至少"): + extract_opus_text(info, keyword="丁") + with pytest.raises(ValueError, match="keyword 过长"): + extract_opus_text(info, keyword="丁" * 300) + + +def test_text_keyword_returns_context_windows() -> None: + info = _text_info("甲" * 500 + "目标词" + "乙" * 500) + + segment = extract_opus_text(info, keyword="目标词") + + assert segment.query == "keyword" + assert segment.keyword == "目标词" + assert segment.ranges == ((440, 563),) + assert segment.text == "甲" * 60 + "目标词" + "乙" * 60 + + +def test_text_keyword_multiple_matches_and_truncation() -> None: + # 关键词之间距离足够远,上下文不重叠 + body = "".join(f"{'甲' * 200}目标词" for _ in range(7)) + info = _text_info(body) + + segment = extract_opus_text(info, keyword="目标词") + + assert len(segment.ranges) == 5 + assert segment.has_more is True + assert "片段 2" in segment.text + assert segment.has_more is True + + +def test_text_keyword_merges_overlapping_windows() -> None: + info = _text_info("目标词" + "短" * 20 + "目标词") + + segment = extract_opus_text(info, keyword="目标词") + + assert len(segment.ranges) == 1 + assert segment.has_more is False + + +def test_text_keyword_not_found() -> None: + info = _text_info("甲" * 100) + + segment = extract_opus_text(info, keyword="找不到") + + assert segment.text == "" + assert segment.ranges == () + assert segment.total_chars == 100 + + +def test_text_keyword_scans_across_blocks() -> None: + # 关键词在第二个文本块里也要能找到(块之间会插入 \n\n 分隔) + info = OpusInfo( + opus_id="1", + title="多段", + blocks=(TextBlock("甲" * 100), TextBlock("乙" * 100 + "目标词")), + author=OpusAuthor(), + ) + + segment = extract_opus_text(info, keyword="目标词") + + assert len(segment.ranges) == 1 + assert segment.text.endswith("目标词") + # 片段起点落在第二个块内(第一块 100 字 + 分隔符 \n\n) + assert segment.offset == 100 + 2 + 100 - 60 + + +def test_format_opus_segment_range_output() -> None: + info = _text_info("戊" * 3000, parts=3) + segment = extract_opus_text(info, limit=1000) + + message = format_opus_segment(info, segment) + + assert f"正文共 {segment.total_chars} 字,本次返回 0-1000 字" in message + assert "可用 start=1000 继续读取" in message + assert "https://www.bilibili.com/opus/933099353259638816" in message + + +def test_format_opus_segment_tail_output() -> None: + info = _text_info("己" * 100) + + message = format_opus_segment(info, extract_opus_text(info)) + + assert "本次返回 0-100 字" in message + assert "(已到正文结尾)" in message + + +def test_format_opus_segment_keyword_output() -> None: + info = _text_info("甲" * 100 + "目标词" + "乙" * 100) + + message = format_opus_segment(info, extract_opus_text(info, keyword="目标词")) + + assert "关键词「目标词」命中位置: 40-163" in message + assert "本次给出 1 个片段" in message + assert "目标词" in message + + +def test_format_opus_segment_keyword_miss_output() -> None: + info = _text_info("庚" * 500) + + message = format_opus_segment(info, extract_opus_text(info, keyword="没有")) + + assert "在正文中没有命中" in message + assert "正文共 500 字" in message + + +def test_format_opus_segment_empty_body_output() -> None: + info = OpusInfo( + opus_id="1", title="空", blocks=(TextBlock(""),), author=OpusAuthor() + ) + + message = format_opus_segment(info, extract_opus_text(info)) + + assert "(该图文没有正文文字)" in message + + +def test_format_opus_segment_start_beyond_total() -> None: + info = _text_info("辛" * 100) + + message = format_opus_segment(info, extract_opus_text(info, start=500)) + + assert "start=500 已超出正文范围(正文共 100 字)" in message + assert "---" not in message diff --git a/tests/test_bilibili_opus_tool.py b/tests/test_bilibili_opus_tool.py index 71da2997..212ec659 100644 --- a/tests/test_bilibili_opus_tool.py +++ b/tests/test_bilibili_opus_tool.py @@ -126,7 +126,7 @@ async def test_empty_opus_id_and_bad_mode() -> None: assert await execute({"opus_id": " "}, _context()) == "opus_id 不能为空" assert ( await execute({"opus_id": "1", "output_mode": "bogus"}, _context()) - == "output_mode 只能是 send、uid 或 info" + == "output_mode 只能是 send、uid、info 或 text" ) @@ -398,3 +398,119 @@ def test_opus_parser_patterns_are_reused_by_tool() -> None: assert ( opus_parser.DYNAMIC_ID_URL_PATTERN.search("t.bilibili.com/123456") is not None ) + + +# ---------- output_mode=text ---------- + + +def _long_info(body: str) -> OpusInfo: + return _info(TextBlock(body), title="长文图文") + + +@pytest.mark.asyncio +async def test_text_mode_defaults_to_first_1000_chars( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_long_info("甲" * 2500)) + ) + + result = await execute({"opus_id": "1", "output_mode": "text"}, _context()) + + assert "正文共 2500 字,本次返回 0-1000 字" in result + assert "可用 start=1000 继续读取" in result + assert "甲" * 1000 in result + assert "甲" * 1001 not in result + + +@pytest.mark.asyncio +async def test_text_mode_start_and_end(monkeypatch: pytest.MonkeyPatch) -> None: + body = "".join(str(index % 10) for index in range(500)) + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_long_info(body)) + ) + + result = await execute( + {"opus_id": "1", "output_mode": "text", "start": 120, "end": 130}, _context() + ) + + assert "本次返回 120-130 字" in result + assert "0123456789" in result + + +@pytest.mark.asyncio +async def test_text_mode_keyword_query(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(return_value=_long_info("甲" * 200 + "目标词" + "乙" * 200)), + ) + + result = await execute( + {"opus_id": "1", "output_mode": "text", "keyword": "目标词"}, _context() + ) + + assert "关键词「目标词」命中位置: 140-263" in result + assert "目标词" in result + + +@pytest.mark.asyncio +async def test_text_mode_keyword_miss(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_long_info("甲" * 100)) + ) + + result = await execute( + {"opus_id": "1", "output_mode": "text", "keyword": "没有这个词"}, _context() + ) + + assert "在正文中没有命中" in result + + +@pytest.mark.asyncio +async def test_text_mode_invalid_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_long_info("甲" * 100)) + ) + context = _context() + + assert await execute( + {"opus_id": "1", "output_mode": "text", "start": "abc"}, context + ) == ("start 必须是整数") + assert await execute( + {"opus_id": "1", "output_mode": "text", "limit": 0}, context + ) == ("limit 必须大于 0") + assert await execute( + {"opus_id": "1", "output_mode": "text", "limit": 99999}, context + ) == ("limit 过大(99999,上限 20000)") + assert await execute( + {"opus_id": "1", "output_mode": "text", "keyword": "短"}, context + ) == ("keyword 至少 2 个字,过短会命中大量无关位置") + + +@pytest.mark.asyncio +async def test_text_mode_does_not_send_or_register( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_long_info("甲" * 50)) + ) + context = _context() + + await execute({"opus_id": "1", "output_mode": "text"}, context) + + context["sender"].send_group_message.assert_not_awaited() + context["attachment_registry"].register_remote_url.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_text_mode_reports_empty_body(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock(return_value=_info(TextBlock(""), title="空")), + ) + + result = await execute({"opus_id": "1", "output_mode": "text"}, _context()) + + assert "(该图文没有正文文字)" in result From ff55859180b0900a0bab7fa481c7183bbf976d7d Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 15:28:55 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(chat):=20=E6=B5=8B=E8=AF=95=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E6=81=A2=E5=A4=8D=E5=8F=AF=E7=94=A8=E7=9A=84=20localS?= =?UTF-8?q?torage=EF=BC=8C=E4=BF=AE=E5=A4=8D=20170=20=E4=B8=AA=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vitest 4 把 jsdom 的 window 复制到测试全局时会丢掉 Storage 的方法:复制出来的 window.localStorage 没有 setItem/getItem,原型退化成 Object.prototype,导致 src/test-utils.tsx 与用例里的 localStorage 读写抛 TypeError,chat 单元测试 170 个用例失败(e2e 6 个文件全部无法启动)。jsdom 自身实例 (globalThis.jsdom.window.localStorage) 仍是完整的 Storage,问题出在全局复制。 - test-setup.ts 启动时把 jsdom 的真实 Storage 挂回 window,App 代码与测试 读写同一个可用对象;仅在真身上 setItem 可用时替换,缺失时保持原状; - 去掉 beforeCatch 里静默吞异常的 try/catch:localStorage 不可用属于环境 故障,不应再被隐藏,改为直接暴露; - 实测恢复:单元测试 43 文件 / 413 用例全通过(此前 26 文件 / 170 用例失败), 6 个 e2e 文件恢复运行,`npm run check` 在 chat 应用下退出码 0。 --- apps/undefined-chat/src/test-setup.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/undefined-chat/src/test-setup.ts b/apps/undefined-chat/src/test-setup.ts index 796ec620..16210ef2 100644 --- a/apps/undefined-chat/src/test-setup.ts +++ b/apps/undefined-chat/src/test-setup.ts @@ -2,15 +2,27 @@ import "@testing-library/jest-dom/vitest"; import { beforeEach, vi } from "vitest"; import { LOCALE_STORAGE_KEY } from "./i18n"; +// vitest 4 把 jsdom 的 window 复制到测试全局时会丢掉 Storage 的方法: +// 复制出来的 window.localStorage 没有 setItem/getItem(原型退化成 Object.prototype), +// 而 globalThis.jsdom.window.localStorage 仍是完整的 Storage 实例。 +// 这里把真身挂回 window,保证 App 代码与测试用例读写的是同一个可用的 localStorage。 +// 参考 vitest populateGlobal 对 Storage/accessor 属性的处理限制。 +const jsdomWindow = (globalThis as unknown as { jsdom?: { window?: Window } }) + .jsdom?.window; +const jsdomStorage = jsdomWindow?.localStorage as Storage | undefined; +if (jsdomStorage && typeof jsdomStorage.setItem === "function") { + Object.defineProperty(window, "localStorage", { + writable: true, + configurable: true, + value: jsdomStorage, + }); +} + // 全局锁定 zh-CN:jsdom 默认 navigator.language=en-US 会让 LanguageProvider 回退到 en, // 破坏现有中文文案断言。每个测试前显式写入 localStorage,确保默认中文; // 需要测试英文的用例可在测试内自行覆盖(setLocale 或写入 localStorage)。 beforeEach(() => { - try { - window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); - } catch { - // localStorage 不可用时忽略 - } + window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); }); // Mock Tauri API From cfee74845a37d177d32503cb6910e18477c08b45 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 15:29:12 +0800 Subject: [PATCH 05/10] chore(version): bump version to 3.16.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(changelog): 记录 v3.16.0 Bilibili 图文(opus)提取 条目按仓库既有格式书写:一句摘要段说明本版本变化,随后以行为与用户可见 结果为主的要点列表,覆盖图文自动提取管线、合并转发节点结构与正文分段、 卡片嵌套展开的边界与降级、双接口与两种响应结构兼容、bilibili_opus 工具 的四种输出模式、text 模式的字数区间与关键词查询、新增 [bilibili] 配置项, 以及视频与图文共用的发送与格式化实现。 * chore(version): 用 bump_version.py 同步清单版本 pyproject.toml、src/Undefined/__init__.py、两个 App 的 package.json / package-lock.json / Cargo.toml / Cargo.lock / tauri.conf.json 与 uv.lock 统一从 3.15.0 升到 3.16.0;release_notes.py validate --tag v3.16.0 通过。 --- CHANGELOG.md | 15 +++++++++++++++ apps/undefined-chat/package-lock.json | 4 ++-- apps/undefined-chat/package.json | 2 +- apps/undefined-chat/src-tauri/Cargo.lock | 2 +- apps/undefined-chat/src-tauri/Cargo.toml | 2 +- apps/undefined-chat/src-tauri/tauri.conf.json | 2 +- apps/undefined-console/package-lock.json | 4 ++-- apps/undefined-console/package.json | 2 +- apps/undefined-console/src-tauri/Cargo.lock | 2 +- apps/undefined-console/src-tauri/Cargo.toml | 2 +- apps/undefined-console/src-tauri/tauri.conf.json | 2 +- pyproject.toml | 2 +- src/Undefined/__init__.py | 2 +- uv.lock | 2 +- 14 files changed, 30 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a021ef8f..e57c822d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v3.16.0 Bilibili 图文(opus)提取 + +本版本为 B 站内容自动提取补齐图文(opus / 动态)类型,命中图文链接后发送一次合并转发:第一条是图文元数据、第二条起是正文,文字与图片按原顺序混排,正文超长自动分段;正文里的图文与视频卡片各自成为嵌套转发节点;新增的 `bilibili_opus` 工具还支持按字数区间或关键词读取正文。 + +- 新增 Bilibili 图文自动提取管线。命中 `bilibili.com/opus/`、`m.bilibili.com/opus/`、`t.bilibili.com/`、`b23.tv` 短链,以及 QQ 小程序与 news 分享卡片中的跳转链接后自动发送合并转发;与视频管线互不干扰,同一条消息同时含 BV 号与图文链接时两条管线各自发送。 +- 转发结构与视频一致:第一条节点是图文信息(封面、标题、UP主、时间、阅读 / 点赞 / 评论 / 转发、原文链接),第二条起是正文。正文按单节点 4000 字切分(节点名形如 `正文 1/3`),不截断、不丢内容,图片作为消息段保留在原文位置并可与文本共处同一节点;正文为空时以占位文案说明。 +- 正文里的卡片各自展开为独立节点:图文卡片再拉取一次递归展开,视频卡片真实下载视频文件并复用清晰度、时长、体积与超限策略限制且附带弹幕,链接、商品、直播、投票等其它卡片渲染为单个链接卡片节点。展开受 `opus_nested_depth`(默认 5 层)与 `opus_nested_max_cards`(默认 8 张)约束,超出边界降级为一行「标题 — 链接」;嵌套失败只降级该节点,不影响整条转发。 +- 图文详情兼容两个接口与两种响应结构:优先 `x/polymer/web-dynamic/v1/opus/detail`,风控(`-352`)、旧版专栏或动态不可见时回退 `x/polymer/web-dynamic/v1/detail`(带 WBI 签名)。`modules` 为列表或字典(含 `major.opus.summary` 摘要)都能解析,段落覆盖文本、图片、分割线、块引用、列表、代码与链接卡片,富文本节点覆盖 @、表情、公式、话题与 BV / CV / 网页链接;指向自身的图文卡片不再被当作嵌套卡片,避免自引用递归。 +- 新增 `bilibili_opus` 工具,`output_mode` 与 `bilibili_video` 对齐并额外提供 text 模式:`send` 发送合并转发,`uid` 把正文图片逐张登记为 ``(默认最多 9 张,单张失败不中断并逐条报告原因),`info` 只返回元信息,`text` 按字数读取正文。该工具已暴露给 `file_analysis_agent`,图文图片可直接做多模态分析。 +- `output_mode=text` 支持起始字、结束字与关键词:默认返回前 1000 字(`limit` 上限 20000),`keyword` 返回命中区间(最多 5 处)与前后各 60 字上下文并自动合并重叠片段。返回头给出正文总字数与本次范围,未读完时提示下一段用 `start=N` 继续;关键词未命中、正文为空、`start` 超出范围都有明确文案,非法参数返回可读错误而不是静默截断。 +- 新增 `[bilibili].opus_enabled`(默认开启,仍需 `auto_extract_enabled`)、`opus_max_items`(单条消息最多处理几篇图文)、`opus_nested_depth`、`opus_nested_max_cards`;群聊与私聊白名单复用视频侧的 `auto_extract_group_ids` / `auto_extract_private_ids`,旧 `config.toml` 不做改动也可直接运行。 +- 图文转发与视频转发同样写入历史:实时 AI 上下文只看到 `forward_...` UID,需要内容时按层调用 `messages.get_forward_msg` 读取,不会因为一张长图文把上下文撑爆。视频与图文的数字缩写、时长、时间戳与 4000 字切分逻辑共用同一份实现,视频原有的提取行为不变。 + +--- + ## v3.15.0 按功能拆分 Embedding 配置与稳定性修复 本版本将嵌入模型配置按功能拆分,知识库、认知记忆与表情包可以共用默认模型或各自覆写连接、维度与指令;同时修复侧写并发合并丢失更新、Agent 技能无法加载、热更新静默失效、SIGTERM 停机缺失等多处影响长期运行的问题。 diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json index 4ee6acd1..f8efe131 100644 --- a/apps/undefined-chat/package-lock.json +++ b/apps/undefined-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-chat", - "version": "3.15.0", + "version": "3.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-chat", - "version": "3.15.0", + "version": "3.16.0", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json index e9c8e0ed..82c94d71 100644 --- a/apps/undefined-chat/package.json +++ b/apps/undefined-chat/package.json @@ -1,7 +1,7 @@ { "name": "undefined-chat", "private": true, - "version": "3.15.0", + "version": "3.16.0", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock index 572d809e..c49846aa 100644 --- a/apps/undefined-chat/src-tauri/Cargo.lock +++ b/apps/undefined-chat/src-tauri/Cargo.lock @@ -5431,7 +5431,7 @@ dependencies = [ [[package]] name = "undefined_chat" -version = "3.15.0" +version = "3.16.0" dependencies = [ "futures-util", "keyring", diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml index a3f3bc01..24f3fd78 100644 --- a/apps/undefined-chat/src-tauri/Cargo.toml +++ b/apps/undefined-chat/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_chat" -version = "3.15.0" +version = "3.16.0" description = "Undefined native chat client" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json index 78684b91..b170a8f2 100644 --- a/apps/undefined-chat/src-tauri/tauri.conf.json +++ b/apps/undefined-chat/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Chat", - "version": "3.15.0", + "version": "3.16.0", "identifier": "com.undefined.chat", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index 1bfc09e7..98a320f9 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.15.0", + "version": "3.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.15.0", + "version": "3.16.0", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index 8dff5d9f..ad020b54 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.15.0", + "version": "3.16.0", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index 7926eb47..898dc234 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.15.0" +version = "3.16.0" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index 05988bb8..cb7f44b5 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.15.0" +version = "3.16.0" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index 1c7e6e8a..ede3c2a4 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.15.0", + "version": "3.16.0", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/pyproject.toml b/pyproject.toml index 753d3387..8d256ecb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.15.0" +version = "3.16.0" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index 984379c5..eaa5cde3 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -24,7 +24,7 @@ from .skills.registry import BaseRegistry as BaseRegistry from .skills.tools import ToolRegistry as ToolRegistry -__version__: str = "3.15.0" +__version__: str = "3.16.0" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/uv.lock b/uv.lock index fe4028c3..6b68f248 100644 --- a/uv.lock +++ b/uv.lock @@ -4680,7 +4680,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.15.0" +version = "3.16.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From f526ee37336ce3c8d692c8ab01842e06a41847d4 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 16:08:14 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(bilibili):=20=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=96=87=E4=BB=B6=E4=BF=9D=E7=95=99=E5=88=B0?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E5=8F=91=E5=87=BA=E4=B9=8B=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _nested_video_node 原先在 finally 里立刻删除下载好的视频,但节点里只写了 file:// 路径,发送方拿到的可能已是不存在的文件(视频发不出去,甚至因为 fatal 文件传输错误让整条转发失败)。 - 新增 pending_cleanup 列表,沿 build_opus_nodes → _nested_opus_node → _nested_video_node 逐层传递,嵌套视频下载成功后把路径记进去; - 顶层 send_opus 在 finally 中统一清理,覆盖发送成功、降级发送与抛错路径; - 补回归用例:断言「构建节点时」与「实际发送时」文件都存在、发送后只清理 一次;以及构建失败时路径仍留在列表里交给调用方收尾。 --- src/Undefined/bilibili/opus_sender.py | 99 +++++++++++++++++---------- tests/test_bilibili_opus_sender.py | 96 ++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 38 deletions(-) diff --git a/src/Undefined/bilibili/opus_sender.py b/src/Undefined/bilibili/opus_sender.py index 98e6fb32..1ceebede 100644 --- a/src/Undefined/bilibili/opus_sender.py +++ b/src/Undefined/bilibili/opus_sender.py @@ -210,6 +210,7 @@ async def _nested_opus_node( config: Any, budget: _ExpansionBudget, depth: int, + pending_cleanup: list[Path], ) -> dict[str, Any]: label = f"嵌套图文: {block.title}" if block.title else "嵌套图文" try: @@ -223,6 +224,7 @@ async def _nested_opus_node( config=config, budget=budget, depth=depth, + pending_cleanup=pending_cleanup, ) return _node(nested_nodes, name=label) except Exception as exc: @@ -317,49 +319,49 @@ async def _nested_video_node( *, cookie: str, config: Any, + pending_cleanup: list[Path], ) -> dict[str, Any]: label = f"嵌套视频: {block.title}" if block.title else "嵌套视频" video_path, video_info, video_status, info_prefix = await _resolve_nested_video( block, cookie=cookie, config=config ) - try: - if video_info is None: - # 拿不到视频信息时退化为卡片信息节点 - lines = [f"「{block.title or '视频'}」", f"BV: {block.bvid or '未知'}"] - if block.jump_url: - lines.extend(["---", block.jump_url]) - segments: list[dict[str, Any]] = [] - if block.cover_url: - segments.append({"type": "image", "data": {"file": block.cover_url}}) - segments.append( - { - "type": "text", - "data": {"text": f"{video_status}\n" + "\n".join(lines)}, - } - ) - return _node(segments, name=label) - - danmaku_enabled = bool(_config_value(config, "bilibili_danmaku_enabled", True)) - nodes, _danmaku, _error = await build_bilibili_video_nodes( - video_info, - video_path=video_path, - video_status=video_status, - info_prefix=info_prefix, - cookie=cookie, - danmaku_enabled=danmaku_enabled, - danmaku_batch_size=int( - _config_value(config, "bilibili_danmaku_batch_size", 100) - ), - danmaku_max_count=int( - _config_value(config, "bilibili_danmaku_max_count", 0) - ), - info_node_name=label, + # 视频文件要等到外层转发真正发出去之后才能删:节点里只留 file:// 路径, + # 提前清理会让发送方拿到已经不存在的文件。路径交给调用方统一收尾。 + if video_path is not None: + pending_cleanup.append(video_path) + + if video_info is None: + # 拿不到视频信息时退化为卡片信息节点 + lines = [f"「{block.title or '视频'}」", f"BV: {block.bvid or '未知'}"] + if block.jump_url: + lines.extend(["---", block.jump_url]) + segments: list[dict[str, Any]] = [] + if block.cover_url: + segments.append({"type": "image", "data": {"file": block.cover_url}}) + segments.append( + { + "type": "text", + "data": {"text": f"{video_status}\n" + "\n".join(lines)}, + } ) - return _node(nodes, name=label) - finally: - if video_path is not None: - cleanup_file(video_path) + return _node(segments, name=label) + + danmaku_enabled = bool(_config_value(config, "bilibili_danmaku_enabled", True)) + nodes, _danmaku, _error = await build_bilibili_video_nodes( + video_info, + video_path=video_path, + video_status=video_status, + info_prefix=info_prefix, + cookie=cookie, + danmaku_enabled=danmaku_enabled, + danmaku_batch_size=int( + _config_value(config, "bilibili_danmaku_batch_size", 100) + ), + danmaku_max_count=int(_config_value(config, "bilibili_danmaku_max_count", 0)), + info_node_name=label, + ) + return _node(nodes, name=label) def _config_value(config: Any, name: str, default: Any) -> Any: @@ -380,12 +382,19 @@ async def build_opus_nodes( config: Any = None, budget: _ExpansionBudget | None = None, depth: int = 0, + pending_cleanup: list[Path] | None = None, ) -> list[dict[str, Any]]: - """按「元数据 → 内容 → 嵌套」顺序构建合并转发节点。""" + """按「元数据 → 内容 → 嵌套」顺序构建合并转发节点。 + + ``pending_cleanup`` 收集嵌套视频下载产生的临时文件,由调用方在转发 + 真正发出之后统一清理(节点里只有 ``file://`` 路径,提前删会发不出去)。 + """ max_depth = int(_config_value(config, "bilibili_opus_nested_depth", 5)) max_cards = int(_config_value(config, "bilibili_opus_nested_max_cards", 8)) if budget is None: budget = _ExpansionBudget(max_cards) + if pending_cleanup is None: + pending_cleanup = [] nodes: list[dict[str, Any]] = [_build_meta_node(info)] nodes.extend(render_blocks_to_nodes(info.blocks)) @@ -416,10 +425,18 @@ async def build_opus_nodes( config=config, budget=budget, depth=depth + 1, + pending_cleanup=pending_cleanup, ) ) else: - nodes.append(await _nested_video_node(block, cookie=cookie, config=config)) + nodes.append( + await _nested_video_node( + block, + cookie=cookie, + config=config, + pending_cleanup=pending_cleanup, + ) + ) return nodes @@ -574,6 +591,8 @@ async def send_opus( ) -> str: """获取图文并发送合并转发,返回可读结果文案。""" info: OpusInfo | None = None + # 嵌套视频下载的临时文件:转发真正发出(或彻底失败)之后才清理 + pending_cleanup: list[Path] = [] try: info = await _fetch_opus_info(opus_id, cookie=cookie, config=config) nodes = await build_opus_nodes( @@ -585,6 +604,7 @@ async def send_opus( config=config, budget=budget, depth=depth, + pending_cleanup=pending_cleanup, ) await _send_forward( sender, @@ -618,6 +638,9 @@ async def send_opus( raise raise OpusUnavailableError(f"图文处理失败: {exc}") from fallback_exc return f"处理失败,已发送 Bilibili 图文信息合并转发: {exc}" + finally: + for path in pending_cleanup: + cleanup_file(path) __all__ = [ diff --git a/tests/test_bilibili_opus_sender.py b/tests/test_bilibili_opus_sender.py index 4875e5c3..a729385b 100644 --- a/tests/test_bilibili_opus_sender.py +++ b/tests/test_bilibili_opus_sender.py @@ -504,3 +504,99 @@ async def test_send_opus_degrades_when_build_fails( assert nodes[0]["data"]["name"] == "图文信息" assert nodes[1]["data"]["name"] == "正文" assert "构建失败" in nodes[1]["data"]["content"] + + +# ---------- 嵌套视频文件生命周期(回归:不能提前删除) ---------- + + +@pytest.mark.asyncio +async def test_nested_video_file_survives_until_forward_is_sent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """嵌套视频的临时文件必须活到转发真正发出去之后。 + + 回归:此前 _nested_video_node 在 finally 里立刻 cleanup,而节点里只有 + file:// 路径,发送方会拿到已被删除的文件。 + """ + video_path = tmp_path / "v.mp4" + video_path.write_bytes(b"video") + seen: list[bool] = [] + + async def _fake_build(info: Any, **kwargs: Any) -> tuple[Any, Any, Any]: + # 节点构建时文件必须还在 + seen.append(video_path.exists()) + return [{"type": "video", "data": {"file": f"file://{video_path}"}}], [], None + + monkeypatch.setattr(opus_sender, "download_video", _download_video_stub(video_path)) + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock( + return_value=_info( + TextBlock("正文"), VideoCardBlock(bvid="BV1xx411c7mD", title="投稿视频") + ) + ), + ) + monkeypatch.setattr(opus_sender, "build_bilibili_video_nodes", _fake_build) + removed: list[Path] = [] + monkeypatch.setattr(opus_sender, "cleanup_file", lambda p: removed.append(Path(p))) + + sender = _sender() + + async def _send(*args: Any, **kwargs: Any) -> None: + # 发送时文件同样必须还在 + seen.append(video_path.exists()) + + sender.send_group_forward_message = AsyncMock(side_effect=_send) + + await opus_sender.send_opus( + "933099353259638816", + sender=sender, + target_type="group", + target_id=10001, + config=_config(), + ) + + assert seen == [True, True], "嵌套视频文件在构建或发送时已被删除" + assert removed == [video_path], "发送结束后应清理一次临时文件" + + +@pytest.mark.asyncio +async def test_nested_video_file_is_cleaned_when_build_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + video_path = tmp_path / "v.mp4" + video_path.write_bytes(b"video") + monkeypatch.setattr(opus_sender, "download_video", _download_video_stub(video_path)) + monkeypatch.setattr( + opus_sender, + "_fetch_opus_info", + AsyncMock( + return_value=_info( + TextBlock("正文"), VideoCardBlock(bvid="BV1xx411c7mD", title="视频") + ) + ), + ) + monkeypatch.setattr( + opus_sender, + "build_bilibili_video_nodes", + AsyncMock(side_effect=RuntimeError("构建失败")), + ) + pending_cleanup: list[Path] = [] + + with pytest.raises(RuntimeError): + await opus_sender.build_opus_nodes( + _info(TextBlock("正文"), VideoCardBlock(bvid="BV1xx411c7mD", title="视频")), + sender=_sender(), + target_type="group", + target_id=1, + config=_config(), + pending_cleanup=pending_cleanup, + ) + + # 构建失败时路径仍留在待清理列表里,交给调用方收尾,不会泄漏临时文件 + assert pending_cleanup == [video_path] + + +def _download_video_stub(video_path: Path) -> Any: + return AsyncMock(return_value=(video_path, _video_info(), 80)) From d4025cfb263fc9a86951bfa636b82cf8a6fc451c Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 16:08:18 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(bilibili):=20=E5=9B=BE=E6=96=87?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E6=94=B9=E4=B8=BA=E5=90=88=E5=B9=B6=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E4=B8=8E=E5=88=86=E4=BA=AB=E5=8D=A1=E7=89=87=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E9=A2=84=E7=AE=97=E6=8F=90=E5=89=8D=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E7=9F=AD=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分享卡片只在正文没有命中时才解析,导致「正文一个链接 + 卡片另一篇图文」 时卡片那篇被整条丢掉;改为两处都提取并按首次出现顺序去重; - extract_opus_ids_with_shortlinks 新增 limit:命中数量达到发送预算后不再 解析剩余 b23.tv 短链(每个短链一次 HEAD 请求,超时配置最长 480 秒), 管线 detect 阶段按 bilibili_opus_max_items 传入预算; - 补用例:预算内提前停止解析、limit=0 不解析、正文占满预算时跳过短链、 无预算时全部解析,以及正文与卡片同时命中时的合并结果。 --- src/Undefined/bilibili/opus_parser.py | 46 ++++++---- src/Undefined/handlers/auto_extract.py | 27 ++++-- .../skills/pipelines/bilibili_opus/handler.py | 8 +- tests/test_bilibili_opus_parser.py | 58 +++++++++++++ tests/test_bilibili_opus_pipeline.py | 83 +++++++++++++++++++ 5 files changed, 199 insertions(+), 23 deletions(-) diff --git a/src/Undefined/bilibili/opus_parser.py b/src/Undefined/bilibili/opus_parser.py index 770c9dee..a93e3993 100644 --- a/src/Undefined/bilibili/opus_parser.py +++ b/src/Undefined/bilibili/opus_parser.py @@ -52,26 +52,41 @@ def _extract_opus_ids_from_text(text: str) -> list[str]: return opus_ids -async def extract_opus_ids_with_shortlinks(text: str) -> list[str]: - """从纯文本中提取图文 ID,并解析 b23.tv 短链后二次提取(去重、保序)。""" +def _extend_unique(target: list[str], seen: set[str], candidates: list[str]) -> None: + for opus_id in candidates: + if opus_id not in seen: + seen.add(opus_id) + target.append(opus_id) + + +async def extract_opus_ids_with_shortlinks( + text: str, *, limit: int | None = None +) -> list[str]: + """从纯文本中提取图文 ID,并解析 b23.tv 短链后二次提取(去重、保序)。 + + ``limit`` 给出发送预算时,解析短链的数量会按剩余名额收敛,避免一条消息 + 里塞了多个短链时把用不到的短链都请求一遍。 + """ + max_items = None if limit is None else max(0, int(limit)) + if max_items == 0: + return [] + opus_ids: list[str] = [] seen: set[str] = set() - for opus_id in _extract_opus_ids_from_text(text): - if opus_id not in seen: - seen.add(opus_id) - opus_ids.append(opus_id) + _extend_unique(opus_ids, seen, _extract_opus_ids_from_text(text)) + if max_items is not None and len(opus_ids) >= max_items: + return opus_ids for match in SHORT_URL_PATTERN.finditer(text): real_url = await resolve_short_url(match.group(0)) if not real_url: continue - for opus_id in _extract_opus_ids_from_text(real_url): - if opus_id not in seen: - seen.add(opus_id) - opus_ids.append(opus_id) + _extend_unique(opus_ids, seen, _extract_opus_ids_from_text(real_url)) + if max_items is not None and len(opus_ids) >= max_items: + break - return opus_ids + return opus_ids[:max_items] if max_items is not None else opus_ids async def extract_opus_from_json_message( @@ -118,9 +133,10 @@ async def extract_opus_from_json_message( urls_to_check.append(str(jump_url)) for url in urls_to_check: - for opus_id in await extract_opus_ids_with_shortlinks(url): - if opus_id not in seen: - seen.add(opus_id) - opus_ids.append(opus_id) + _extend_unique( + opus_ids, + seen, + await extract_opus_ids_with_shortlinks(url), + ) return opus_ids diff --git a/src/Undefined/handlers/auto_extract.py b/src/Undefined/handlers/auto_extract.py index cb03edf2..155fa9b0 100644 --- a/src/Undefined/handlers/auto_extract.py +++ b/src/Undefined/handlers/auto_extract.py @@ -39,18 +39,33 @@ async def _extract_bilibili_ids( return list(bvids) async def _extract_bilibili_opus_ids( - self, text: str, message_content: list[dict[str, Any]] + self, + text: str, + message_content: list[dict[str, Any]], + *, + limit: int | None = None, ) -> list[str]: - """从文本和消息段中提取 B 站图文(opus / 动态)ID。""" + """从文本和消息段中提取 B 站图文(opus / 动态)ID。 + + ``limit`` 为发送预算:命中数量达到预算后不再解析剩余 b23.tv 短链。 + 正文与分享卡片两处都会提取并去重,只有卡片时也不会漏。 + """ from Undefined.bilibili.opus_parser import ( extract_opus_from_json_message, extract_opus_ids_with_shortlinks, ) - opus_ids = await extract_opus_ids_with_shortlinks(text) - if not opus_ids: - opus_ids = await extract_opus_from_json_message(message_content) - return list(opus_ids) + opus_ids = await extract_opus_ids_with_shortlinks(text, limit=limit) + if limit is not None and len(opus_ids) >= limit: + return opus_ids + + seen = set(opus_ids) + for opus_id in await extract_opus_from_json_message(message_content): + if opus_id in seen: + continue + seen.add(opus_id) + opus_ids.append(opus_id) + return opus_ids if limit is None else opus_ids[:limit] def _extract_douyin_ids( self, text: str, message_content: list[dict[str, Any]] diff --git a/src/Undefined/skills/pipelines/bilibili_opus/handler.py b/src/Undefined/skills/pipelines/bilibili_opus/handler.py index 60f0d4b5..dc138331 100644 --- a/src/Undefined/skills/pipelines/bilibili_opus/handler.py +++ b/src/Undefined/skills/pipelines/bilibili_opus/handler.py @@ -26,12 +26,16 @@ async def detect(context: PipelineContext) -> PipelineDetection | None: if not _is_allowed(config, target_type, target_id): return None + # 检测阶段就按发送预算截断:超出的短链不必再解析 + max_items = max(1, int(getattr(config, "bilibili_opus_max_items", 3))) extractor = context["extract_bilibili_opus_ids"] - opus_ids = await extractor(context["text"], context["message_content"]) + opus_ids = await extractor( + context["text"], context["message_content"], limit=max_items + ) if not opus_ids: return None return PipelineDetection( - name="bilibili_opus", items=tuple(str(item) for item in opus_ids) + name="bilibili_opus", items=tuple(str(item) for item in opus_ids[:max_items]) ) diff --git a/tests/test_bilibili_opus_parser.py b/tests/test_bilibili_opus_parser.py index a2ce006e..7ccfad36 100644 --- a/tests/test_bilibili_opus_parser.py +++ b/tests/test_bilibili_opus_parser.py @@ -127,3 +127,61 @@ async def test_extract_opus_from_json_message_skips_invalid_payloads() -> None: _json_segment({"meta": {"detail_1": {"qqdocurl": "https://example.com/x"}}}), ] assert await extract_opus_from_json_message(segments) == [] + + +@pytest.mark.asyncio +async def test_extract_stops_resolving_short_links_at_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """给了发送预算后,命中数量够了就不再解析剩余短链。""" + resolver = AsyncMock(return_value="https://www.bilibili.com/opus/555555555555555") + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + + text = " ".join(f"https://b23.tv/link{index}" for index in range(6)) + assert await extract_opus_ids_with_shortlinks(text, limit=1) == ["555555555555555"] + assert resolver.await_count == 1 + + +@pytest.mark.asyncio +async def test_extract_limit_zero_resolves_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolver = AsyncMock(return_value="https://www.bilibili.com/opus/1") + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + + result = await extract_opus_ids_with_shortlinks( + "https://b23.tv/x https://www.bilibili.com/opus/2", limit=0 + ) + + assert result == [] + resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_extract_limit_skips_links_when_text_already_fills_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolver = AsyncMock(return_value="https://www.bilibili.com/opus/9") + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + + text = "https://www.bilibili.com/opus/1 https://www.bilibili.com/opus/2 https://b23.tv/x" + assert await extract_opus_ids_with_shortlinks(text, limit=2) == ["1", "2"] + resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_extract_without_limit_resolves_all_short_links( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolver = AsyncMock( + side_effect=[ + "https://www.bilibili.com/opus/11", + "https://www.bilibili.com/opus/22", + ] + ) + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + + result = await extract_opus_ids_with_shortlinks("https://b23.tv/a https://b23.tv/b") + + assert result == ["11", "22"] + assert resolver.await_count == 2 diff --git a/tests/test_bilibili_opus_pipeline.py b/tests/test_bilibili_opus_pipeline.py index e324180e..84575366 100644 --- a/tests/test_bilibili_opus_pipeline.py +++ b/tests/test_bilibili_opus_pipeline.py @@ -188,3 +188,86 @@ async def test_bilibili_opus_handler_reports_failure( sender_mock = cast(Any, dummy.sender) sender_mock.send_private_message.assert_awaited_once() assert "图文提取失败" in sender_mock.send_private_message.await_args.args[1] + + +@pytest.mark.asyncio +async def test_detect_passes_item_budget_to_extractor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """检测阶段就把发送预算交给提取器,超出的短链不再解析。""" + # 管线 handler 按目录动态加载,不是可 import 的包模块 + registry = PipelineRegistry() + registry.load_items() + detect = registry._items["bilibili_opus"].detect + + extractor = AsyncMock(return_value=["1", "2", "3", "4", "5"]) + detection = await detect( + { + "config": _config(bilibili_opus_max_items=2), + "target_id": 20001, + "target_type": "private", + "text": "https://www.bilibili.com/opus/1", + "message_content": [], + "extract_bilibili_opus_ids": extractor, + } + ) + + assert detection is not None + assert detection.items == ("1", "2") + extractor.assert_awaited_once() + call = extractor.await_args + assert call is not None + assert call.kwargs["limit"] == 2 + + +@pytest.mark.asyncio +async def test_mixin_extracts_text_and_share_card_together( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """正文里有图文链接、卡片里是另一篇图文时,两篇都要处理。""" + import Undefined.bilibili.opus_parser as opus_parser + from Undefined.handlers.auto_extract import AutoExtractMixin + + monkeypatch.setattr( + opus_parser, + "extract_opus_ids_with_shortlinks", + AsyncMock(return_value=["111"]), + ) + monkeypatch.setattr( + opus_parser, + "extract_opus_from_json_message", + AsyncMock(return_value=["222", "111"]), + ) + + dummy = cast(Any, SimpleNamespace()) + opus_ids = await AutoExtractMixin._extract_bilibili_opus_ids( + dummy, + "https://www.bilibili.com/opus/111", + [{"type": "json", "data": {"data": "{}"}}], + ) + + assert opus_ids == ["111", "222"] + + +@pytest.mark.asyncio +async def test_mixin_skips_card_lookup_when_budget_full( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import Undefined.bilibili.opus_parser as opus_parser + from Undefined.handlers.auto_extract import AutoExtractMixin + + monkeypatch.setattr( + opus_parser, + "extract_opus_ids_with_shortlinks", + AsyncMock(return_value=["111", "222"]), + ) + card_lookup = AsyncMock(return_value=["333"]) + monkeypatch.setattr(opus_parser, "extract_opus_from_json_message", card_lookup) + + dummy = cast(Any, SimpleNamespace()) + opus_ids = await AutoExtractMixin._extract_bilibili_opus_ids( + dummy, "text", [], limit=2 + ) + + assert opus_ids == ["111", "222"] + card_lookup.assert_not_awaited() From c171b789313be590302f621c3033cd721b57c403 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 16:08:23 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(skills):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=9B=BE=E6=96=87=E5=B7=A5=E5=85=B7=20text=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=20end/limit=20=E8=AF=AD=E4=B9=89=E4=B8=8E?= =?UTF-8?q?=E7=9F=AD=E9=93=BE=E8=A7=A3=E6=9E=90=E5=85=A5=E5=8F=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 显式给 end 时不再套默认 1000 字上限:原先 start=0&end=5000 只会返回 1000 字,与 config.json / README 的约定(limit 只在未给 end 时生效)不符; 现在「只给 end」由 end 决定范围,「end 与 limit 同时给」取两者较紧的一个; - extract_opus_text 的 limit 默认值改为 None(纯函数层不再隐式截断), 默认 1000 字由工具层补齐,语义只保留一处; - 短链归一化只把正则匹配到的短链交给 resolve_short_url:原先传整段文本, 分享文案会解析失败,形如 http://other-host/b23.tv/x 的伪域名还会把请求 打到其它主机; - 工具描述与 README 同步 end/limit 的优先级。 --- src/Undefined/bilibili/opus_render.py | 21 ++++--- .../skills/tools/bilibili_opus/README.md | 2 +- .../skills/tools/bilibili_opus/config.json | 20 ++++-- .../skills/tools/bilibili_opus/handler.py | 21 +++++-- tests/test_bilibili_opus_render.py | 52 +++++++++++++++- tests/test_bilibili_opus_tool.py | 61 +++++++++++++++++++ 6 files changed, 157 insertions(+), 20 deletions(-) diff --git a/src/Undefined/bilibili/opus_render.py b/src/Undefined/bilibili/opus_render.py index ac1932ab..3125a084 100644 --- a/src/Undefined/bilibili/opus_render.py +++ b/src/Undefined/bilibili/opus_render.py @@ -655,13 +655,15 @@ def extract_opus_text( *, start: int | None = None, end: int | None = None, - limit: int | None = OPUS_TEXT_DEFAULT_LIMIT, + limit: int | None = None, keyword: str = "", ) -> OpusSegment: """返回正文纯文本的可见片段。 - 指定 ``keyword`` 时按关键词返回上下文片段,忽略 ``start`` / ``end``; - - 否则按 ``[start, end)`` 返回字符区间,``end`` 缺省时取 ``start + limit``。 + - 否则按 ``[start, end)`` 返回字符区间,``end`` 缺省时取 ``start + limit``; + - ``limit=None`` 表示不设窗口上限(只受 ``end`` 或正文长度约束), + ``end`` 与 ``limit`` 同时给出时取二者较紧的一个。 ``offset`` 为该片段在完整正文中的起始字符位置,供调用方继续翻页。 参数非法(负数、limit<=0、关键词过长)时抛 :class:`ValueError`, @@ -685,14 +687,16 @@ def extract_opus_text( if not text: return OpusSegment(text="", total_chars=0) - if limit is None: - size = OPUS_TEXT_DEFAULT_LIMIT - else: + # ``limit`` 只约束「未显式给 end」的窗口与「end < start」的兜底; + # 显式 end 时以 end 为准,不受上限影响 + if limit is not None: size = int(limit) if size <= 0: raise ValueError("limit 必须大于 0") if size > OPUS_TEXT_MAX_LIMIT: raise ValueError(f"limit 过大({size},上限 {OPUS_TEXT_MAX_LIMIT})") + else: + size = OPUS_TEXT_DEFAULT_LIMIT if start is not None and int(start) < 0: raise ValueError("start 不能为负数") @@ -708,8 +712,11 @@ def extract_opus_text( has_more=False, ranges=(), ) - finish = total if end is None else int(end) - finish = min(finish, begin + size, total) + # limit 的语义是「单次返回的字数上限」:未给 end 时决定窗口大小, + # 给了 end 时作为上界一起生效(调用方若想让 end 单独决定范围,传 limit=None) + finish = total if end is None else min(int(end), total) + if limit is not None: + finish = min(finish, begin + size) if finish <= begin: finish = min(begin + size, total) return OpusSegment( diff --git a/src/Undefined/skills/tools/bilibili_opus/README.md b/src/Undefined/skills/tools/bilibili_opus/README.md index 3d0b5ee2..faf94839 100644 --- a/src/Undefined/skills/tools/bilibili_opus/README.md +++ b/src/Undefined/skills/tools/bilibili_opus/README.md @@ -9,7 +9,7 @@ - `output_mode`:可选,`send`(默认,发送合并转发)、`uid`(把正文图片登记为 ``,不发送消息)、`info`(只返回图文元信息)或 `text`(只返回正文文字) - `max_images`:可选,`uid` 模式下最多登记多少张图片,默认 9 - `start` / `end`:可选,`text` 模式的起始字与结束字(0 基,`end` 不含),不填即从开头读取 -- `limit`:可选,`text` 模式未显式给 `end` 时的字数为上限,默认 1000,上限 20000 +- `limit`:可选,`text` 模式的单次返回字数上限,默认 1000、上限 20000;给了 `end` 时 `limit` 仍作为上界生效,只给 `end` 时不再套默认 1000 字 - `keyword`:可选,`text` 模式的关键词查询,返回命中位置与前后文片段(最多 5 处),此时 `start` / `end` 被忽略 `send` 模式流程: diff --git a/src/Undefined/skills/tools/bilibili_opus/config.json b/src/Undefined/skills/tools/bilibili_opus/config.json index b0877544..f407ae39 100644 --- a/src/Undefined/skills/tools/bilibili_opus/config.json +++ b/src/Undefined/skills/tools/bilibili_opus/config.json @@ -12,7 +12,10 @@ }, "target_type": { "type": "string", - "enum": ["group", "private"], + "enum": [ + "group", + "private" + ], "description": "可选。目标会话类型" }, "target_id": { @@ -21,7 +24,12 @@ }, "output_mode": { "type": "string", - "enum": ["send", "uid", "info", "text"], + "enum": [ + "send", + "uid", + "info", + "text" + ], "description": "输出模式:send(默认,发送图文合并转发)、uid(仅把正文图片登记为附件 UID,不发送)、info(仅返回图文元信息)、text(只返回正文文字,受 start / end / limit / keyword 控制)" }, "max_images": { @@ -34,18 +42,20 @@ }, "end": { "type": "integer", - "description": "可选,output_mode=text 用。结束字位置(不含该字);不填则为 start + limit" + "description": "可选,output_mode=text 用。结束字位置(不含该字);不填则为 start + limit。只给 end 时不再套默认 1000 字,limit 仍作为上界" }, "limit": { "type": "integer", - "description": "可选,output_mode=text 用。未显式给 end 时返回的字数上限,默认 1000,最大 20000" + "description": "可选,output_mode=text 用。单次返回的字数上限,默认 1000,最大 20000;给了 end 时仍作为上界生效" }, "keyword": { "type": "string", "description": "可选,output_mode=text 用。关键词查询:返回该词在正文中的命中位置与前后文片段(最多 5 处),此时 start / end 被忽略" } }, - "required": ["opus_id"] + "required": [ + "opus_id" + ] } } } diff --git a/src/Undefined/skills/tools/bilibili_opus/handler.py b/src/Undefined/skills/tools/bilibili_opus/handler.py index 74f1472f..783a5db0 100644 --- a/src/Undefined/skills/tools/bilibili_opus/handler.py +++ b/src/Undefined/skills/tools/bilibili_opus/handler.py @@ -82,8 +82,10 @@ async def _normalize_opus_id(raw: str) -> str | None: if match: return match.group(1) - if SHORT_URL_PATTERN.search(text): - real_url = await resolve_short_url(text) + match = SHORT_URL_PATTERN.search(text) + if match: + # 只把匹配到的短链交给解析器:整段文本或伪造的 other-host/b23.tv/x 都不该被请求 + real_url = await resolve_short_url(match.group(0)) if real_url: return await _normalize_opus_id(real_url) return None @@ -120,13 +122,20 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if output_mode == "text": info = await fetch_opus_info(opus_id, cookie=cookie) try: - limit = _optional_int(args, "limit") + requested_end = _optional_int(args, "end") + requested_limit = _optional_int(args, "limit") + if requested_limit is not None: + limit: int | None = requested_limit + elif requested_end is not None: + # 只给 end 时不套默认 1000 字上限,让 end 单独决定范围 + limit = None + else: + limit = OPUS_TEXT_DEFAULT_LIMIT segment = extract_opus_text( info, start=_optional_int(args, "start"), - end=_optional_int(args, "end"), - # 未显式传 limit 时用默认 1000;显式传 0 交给校验报错 - limit=OPUS_TEXT_DEFAULT_LIMIT if limit is None else limit, + end=requested_end, + limit=limit, keyword=str(args.get("keyword") or ""), ) except ValueError as exc: diff --git a/tests/test_bilibili_opus_render.py b/tests/test_bilibili_opus_render.py index ebead35b..160a6a8e 100644 --- a/tests/test_bilibili_opus_render.py +++ b/tests/test_bilibili_opus_render.py @@ -14,6 +14,7 @@ VideoCardBlock, ) from Undefined.bilibili.opus_render import ( + OPUS_TEXT_DEFAULT_LIMIT, extract_opus_text, format_opus_history_message, format_opus_info, @@ -512,7 +513,8 @@ def _text_info(body: str, *, parts: int = 1) -> OpusInfo: def test_text_default_returns_first_1000_chars() -> None: info = _text_info("甲" * 2500) - segment = extract_opus_text(info) + # 默认 1000 字由工具层补上(未显式传 limit 时传 OPUS_TEXT_DEFAULT_LIMIT) + segment = extract_opus_text(info, limit=OPUS_TEXT_DEFAULT_LIMIT) assert segment.total_chars == 2500 assert segment.offset == 0 @@ -521,6 +523,16 @@ def test_text_default_returns_first_1000_chars() -> None: assert segment.ranges == ((0, 1000),) +def test_text_without_limit_returns_whole_body() -> None: + """纯函数层不隐式设上限:limit=None 且无 end 时返回完整正文。""" + info = _text_info("甲" * 2500) + + segment = extract_opus_text(info) + + assert len(segment.text) == 2500 + assert segment.has_more is False + + def test_text_start_and_end_select_exact_range() -> None: info = _text_info("".join(str(index % 10) for index in range(500))) @@ -720,3 +732,41 @@ def test_format_opus_segment_start_beyond_total() -> None: assert "start=500 已超出正文范围(正文共 100 字)" in message assert "---" not in message + + +def test_text_explicit_end_is_not_capped_by_default_limit() -> None: + """显式 end 时不再套默认 1000 字上限(此前 end=5000 只会返回 1000 字)。""" + info = _text_info("子" * 8000) + + segment = extract_opus_text(info, start=0, end=5000) + + assert len(segment.text) == 5000 + assert segment.offset == 0 + assert segment.has_more is True + + +def test_text_explicit_end_still_capped_by_total() -> None: + info = _text_info("丑" * 300) + + segment = extract_opus_text(info, start=0, end=9999) + + assert len(segment.text) == 300 + assert segment.has_more is False + + +def test_text_explicit_limit_still_applies_with_end() -> None: + """同时给 end 与 limit 时,limit 仍然生效(显式上限优先)。""" + info = _text_info("寅" * 8000) + + segment = extract_opus_text(info, start=0, end=5000, limit=200) + + assert len(segment.text) == 200 + + +def test_text_end_before_start_falls_back_to_limit_value() -> None: + info = _text_info("卯" * 3000) + + segment = extract_opus_text(info, start=100, end=50, limit=50) + + assert len(segment.text) == 50 + assert segment.offset == 100 diff --git a/tests/test_bilibili_opus_tool.py b/tests/test_bilibili_opus_tool.py index 212ec659..9ac7fb27 100644 --- a/tests/test_bilibili_opus_tool.py +++ b/tests/test_bilibili_opus_tool.py @@ -514,3 +514,64 @@ async def test_text_mode_reports_empty_body(monkeypatch: pytest.MonkeyPatch) -> result = await execute({"opus_id": "1", "output_mode": "text"}, _context()) assert "(该图文没有正文文字)" in result + + +# ---------- 短链解析与输出模式回归 ---------- + + +@pytest.mark.asyncio +async def test_normalize_short_link_passes_only_matched_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """只把匹配到的短链交给解析器,避免整段分享文案或伪造域名被请求。""" + import Undefined.skills.tools.bilibili_opus.handler as handler_module + + resolver = AsyncMock(return_value="https://www.bilibili.com/opus/777777777777777") + monkeypatch.setattr(handler_module, "resolve_short_url", resolver) + + result = await _normalized("看看这个 https://b23.tv/abc123 挺好") + + assert result == "777777777777777" + resolver.assert_awaited_once() + call = resolver.await_args + assert call is not None + assert call.args[0] == "https://b23.tv/abc123" + + +@pytest.mark.asyncio +async def test_text_mode_explicit_end_beats_default_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """显式 end 时不再被默认 1000 字截断(回归)。""" + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_long_info("未" * 5000)) + ) + + result = await execute( + {"opus_id": "1", "output_mode": "text", "start": 0, "end": 3000}, _context() + ) + + assert "本次返回 0-3000 字" in result + assert "未" * 3000 in result + + +@pytest.mark.asyncio +async def test_text_mode_explicit_limit_with_end_still_caps( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + opus_sender, "_fetch_opus_info", AsyncMock(return_value=_long_info("申" * 5000)) + ) + + result = await execute( + { + "opus_id": "1", + "output_mode": "text", + "start": 0, + "end": 3000, + "limit": 200, + }, + _context(), + ) + + assert "本次返回 0-200 字" in result From 831fbe4f14a5544352aa8431b8b58ef5a49aada6 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 16:25:36 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(bilibili):=20=E5=9B=BE=E6=96=87=20ID?= =?UTF-8?q?=20=E6=8C=89=E5=87=BA=E7=8E=B0=E9=A1=BA=E5=BA=8F=E5=8D=A0?= =?UTF-8?q?=E9=A2=84=E7=AE=97=EF=BC=8C=E5=8D=A1=E7=89=87=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=90=8C=E6=A0=B7=E5=8F=97=E5=89=A9=E4=BD=99=E5=90=8D=E9=A2=9D?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审指出的三处顺序/预算问题(已按当前代码逐条复现): - 直链与短链分两趟收集,短链写在前面也会先返回后面的直链:改为把 OPUS_URL_PATTERN、DYNAMIC_ID_URL_PATTERN 的命中与 b23.tv 命中按位置合并 成一条序列,先出现的先占名额(「b23.tv/A 然后 /opus/222」预算 1 现在 返回短链解析出的 ID); - 同一问题在 _extract_opus_ids_from_text 内部也存在:两条正则分两趟扫, 「t.bilibili.com/9 然后 bilibili.com/opus/8」会返回 ['8','9'];新增 _collect_direct_ids 按 match.start() 排序后再去重; - extract_opus_from_json_message 新增 limit:多张分享卡片时名额用完即停止 解析后续卡片的短链(此前每张卡片都会解析一次),mixin 传入剩余预算; - _extend_unique 换成单条追加的 _append_unique,并补 _remaining 换算。 补 5 个用例:直链按位置排序、短链在前的预算归属、直链在前的预算归属、 卡片链路的剩余名额与 limit=0。 --- src/Undefined/bilibili/opus_parser.py | 106 ++++++++++++++++++------- src/Undefined/handlers/auto_extract.py | 5 +- tests/test_bilibili_opus_parser.py | 78 ++++++++++++++++++ 3 files changed, 158 insertions(+), 31 deletions(-) diff --git a/src/Undefined/bilibili/opus_parser.py b/src/Undefined/bilibili/opus_parser.py index a93e3993..8efb6f53 100644 --- a/src/Undefined/bilibili/opus_parser.py +++ b/src/Undefined/bilibili/opus_parser.py @@ -38,25 +38,46 @@ ) +_DIRECT_URL_PATTERNS = (OPUS_URL_PATTERN, DYNAMIC_ID_URL_PATTERN) + + +def _collect_direct_ids(text: str) -> list[tuple[int, str]]: + """收集直链图文 ID,返回 ``(位置, ID)`` 并按出现顺序排列。 + + 两条正则分两趟扫会打乱顺序(``t.bilibili.com/9`` 写在 + ``bilibili.com/opus/8`` 前面也会先返回 8),因此按位置统一排序。 + """ + matches: list[tuple[int, str]] = [] + for pattern in _DIRECT_URL_PATTERNS: + for match in pattern.finditer(text): + matches.append((match.start(), match.group(1))) + matches.sort(key=lambda item: item[0]) + return matches + + def _extract_opus_ids_from_text(text: str) -> list[str]: """从纯文本中提取图文 ID(不做短链解析,同步操作)。""" opus_ids: list[str] = [] seen: set[str] = set() - for pattern in (OPUS_URL_PATTERN, DYNAMIC_ID_URL_PATTERN): - for match in pattern.finditer(text): - opus_id = match.group(1) - if opus_id in seen: - continue - seen.add(opus_id) - opus_ids.append(opus_id) + for _position, opus_id in _collect_direct_ids(text): + if opus_id in seen: + continue + seen.add(opus_id) + opus_ids.append(opus_id) return opus_ids -def _extend_unique(target: list[str], seen: set[str], candidates: list[str]) -> None: - for opus_id in candidates: - if opus_id not in seen: - seen.add(opus_id) - target.append(opus_id) +def _append_unique(target: list[str], seen: set[str], opus_id: str) -> bool: + if not opus_id or opus_id in seen: + return False + seen.add(opus_id) + target.append(opus_id) + return True + + +def _remaining(max_items: int | None, collected: int) -> int | None: + """把总预算换算成剩余名额;``None`` 表示不限。""" + return None if max_items is None else max(0, max_items - collected) async def extract_opus_ids_with_shortlinks( @@ -64,25 +85,37 @@ async def extract_opus_ids_with_shortlinks( ) -> list[str]: """从纯文本中提取图文 ID,并解析 b23.tv 短链后二次提取(去重、保序)。 - ``limit`` 给出发送预算时,解析短链的数量会按剩余名额收敛,避免一条消息 - 里塞了多个短链时把用不到的短链都请求一遍。 + ``limit`` 给出发送预算时,直链与短链按在文本中的出现顺序统一排队, + 先出现的先占名额;名额用完后剩余短链不再请求(每个短链一次 HEAD 请求, + 超时配置最长 480 秒)。 """ max_items = None if limit is None else max(0, int(limit)) if max_items == 0: return [] + # 直链与短链按位置合并成一个序列,保证「先出现的先返回」 + entries: list[tuple[int, str, bool]] = [ + (position, opus_id, True) for position, opus_id in _collect_direct_ids(text) + ] + entries.extend( + (match.start(), match.group(0), False) + for match in SHORT_URL_PATTERN.finditer(text) + ) + entries.sort(key=lambda item: item[0]) + opus_ids: list[str] = [] seen: set[str] = set() - - _extend_unique(opus_ids, seen, _extract_opus_ids_from_text(text)) - if max_items is not None and len(opus_ids) >= max_items: - return opus_ids - - for match in SHORT_URL_PATTERN.finditer(text): - real_url = await resolve_short_url(match.group(0)) - if not real_url: - continue - _extend_unique(opus_ids, seen, _extract_opus_ids_from_text(real_url)) + for _position, payload, is_direct in entries: + if is_direct: + _append_unique(opus_ids, seen, payload) + else: + if max_items is not None and len(opus_ids) >= max_items: + # 预算已满:后面的短链不必再解析 + break + real_url = await resolve_short_url(payload) + if real_url: + for resolved_id in _extract_opus_ids_from_text(real_url): + _append_unique(opus_ids, seen, resolved_id) if max_items is not None and len(opus_ids) >= max_items: break @@ -91,8 +124,17 @@ async def extract_opus_ids_with_shortlinks( async def extract_opus_from_json_message( segments: list[dict[str, Any]], + *, + limit: int | None = None, ) -> list[str]: - """从 QQ 消息段中检测 JSON 小程序消息,提取 B 站图文 ID。""" + """从 QQ 消息段中检测 JSON 小程序消息,提取 B 站图文 ID。 + + ``limit`` 给出剩余发送预算:名额用完后不再解析后续卡片的短链。 + """ + max_items = None if limit is None else max(0, int(limit)) + if max_items == 0: + return [] + opus_ids: list[str] = [] seen: set[str] = set() @@ -133,10 +175,14 @@ async def extract_opus_from_json_message( urls_to_check.append(str(jump_url)) for url in urls_to_check: - _extend_unique( - opus_ids, - seen, - await extract_opus_ids_with_shortlinks(url), + if max_items is not None and len(opus_ids) >= max_items: + break + resolved = await extract_opus_ids_with_shortlinks( + url, limit=_remaining(max_items, len(opus_ids)) ) + for opus_id in resolved: + _append_unique(opus_ids, seen, opus_id) + if max_items is not None and len(opus_ids) >= max_items: + break - return opus_ids + return opus_ids[:max_items] if max_items is not None else opus_ids diff --git a/src/Undefined/handlers/auto_extract.py b/src/Undefined/handlers/auto_extract.py index 155fa9b0..abb75f98 100644 --- a/src/Undefined/handlers/auto_extract.py +++ b/src/Undefined/handlers/auto_extract.py @@ -60,7 +60,10 @@ async def _extract_bilibili_opus_ids( return opus_ids seen = set(opus_ids) - for opus_id in await extract_opus_from_json_message(message_content): + remaining = None if limit is None else max(0, limit - len(opus_ids)) + for opus_id in await extract_opus_from_json_message( + message_content, limit=remaining + ): if opus_id in seen: continue seen.add(opus_id) diff --git a/tests/test_bilibili_opus_parser.py b/tests/test_bilibili_opus_parser.py index 7ccfad36..d33d749a 100644 --- a/tests/test_bilibili_opus_parser.py +++ b/tests/test_bilibili_opus_parser.py @@ -185,3 +185,81 @@ async def test_extract_without_limit_resolves_all_short_links( assert result == ["11", "22"] assert resolver.await_count == 2 + + +@pytest.mark.asyncio +async def test_extract_orders_direct_ids_by_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """两条正则分两趟扫会打乱顺序:t.bilibili.com 写在前面就必须先返回。""" + monkeypatch.setattr(opus_parser, "resolve_short_url", AsyncMock(return_value=None)) + + text = "https://t.bilibili.com/9 然后 https://www.bilibili.com/opus/8" + assert await extract_opus_ids_with_shortlinks(text) == ["9", "8"] + + +@pytest.mark.asyncio +async def test_extract_short_link_before_direct_wins_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """短链出现在直链之前时,预算名额应该先给短链。""" + resolver = AsyncMock(return_value="https://www.bilibili.com/opus/111") + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + + text = "https://b23.tv/A https://www.bilibili.com/opus/222" + assert await extract_opus_ids_with_shortlinks(text, limit=1) == ["111"] + assert resolver.await_count == 1 + + +@pytest.mark.asyncio +async def test_extract_direct_before_short_link_wins_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolver = AsyncMock(return_value="https://www.bilibili.com/opus/111") + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + + text = "https://www.bilibili.com/opus/222 https://b23.tv/A" + assert await extract_opus_ids_with_shortlinks(text, limit=1) == ["222"] + resolver.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_extract_json_card_respects_remaining_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """卡片链路也要受剩余预算约束,名额用完不再解析后续卡片短链。""" + + async def _resolve(url: str) -> str: + return ( + "https://www.bilibili.com/opus/111" + if url.endswith("one") + else "https://www.bilibili.com/opus/222" + ) + + resolver = AsyncMock(side_effect=_resolve) + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + + segments = [ + _json_segment({"meta": {"detail_1": {"qqdocurl": "https://b23.tv/one"}}}), + _json_segment({"meta": {"news": {"jumpUrl": "https://b23.tv/two"}}}), + ] + + assert await extract_opus_from_json_message(segments, limit=1) == ["111"] + assert resolver.await_count == 1 + + assert await extract_opus_from_json_message(segments) == ["111", "222"] + assert resolver.await_count == 3 + + +@pytest.mark.asyncio +async def test_extract_json_card_limit_zero_skips_everything( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolver = AsyncMock(return_value="https://www.bilibili.com/opus/111") + monkeypatch.setattr(opus_parser, "resolve_short_url", resolver) + segments = [ + _json_segment({"meta": {"detail_1": {"qqdocurl": "https://b23.tv/one"}}}) + ] + + assert await extract_opus_from_json_message(segments, limit=0) == [] + resolver.assert_not_awaited() From 0c8d6fe45791e12035a985cc6d1fa7adb6378db1 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 25 Sep 2026 17:17:45 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(bilibili):=20=E5=88=86=E4=BA=AB?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=87=8C=E7=9A=84=E9=87=8D=E5=A4=8D=E5=9B=BE?= =?UTF-8?q?=E6=96=87=E4=B8=8D=E5=86=8D=E5=8D=A0=E7=94=A8=E5=8F=91=E9=80=81?= =?UTF-8?q?=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已按当前代码复现:正文命中 111、卡片先给出重复的 111 再给出新的 222 时, 预算 2 只返回 ['111']——重复项先被追加进卡片提取器的 opus_ids、吃掉了名额, 后续的新图文被 remaining 卡掉,最终结果反而比不限预算更少。 - extract_opus_from_json_message 新增 exclude 参数(已知 ID 一并放进 seen), 已命中的 ID 既不重复返回也不占预算; - mixin 把正文结果作为已知项传入,并保留「正文占满预算就不再解析卡片」的 短路,避免多一次无意义的卡片遍历; - 补回归用例:重复卡片 + 新卡片、预算 2 应拿到两个 ID,预算 1 时正文独占。 --- src/Undefined/bilibili/opus_parser.py | 6 ++++- src/Undefined/handlers/auto_extract.py | 11 +++++--- tests/test_bilibili_opus_pipeline.py | 37 ++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/Undefined/bilibili/opus_parser.py b/src/Undefined/bilibili/opus_parser.py index 8efb6f53..4ef85339 100644 --- a/src/Undefined/bilibili/opus_parser.py +++ b/src/Undefined/bilibili/opus_parser.py @@ -12,6 +12,7 @@ from __future__ import annotations +from collections.abc import Collection import html import json import logging @@ -126,17 +127,20 @@ async def extract_opus_from_json_message( segments: list[dict[str, Any]], *, limit: int | None = None, + exclude: Collection[str] = (), ) -> list[str]: """从 QQ 消息段中检测 JSON 小程序消息,提取 B 站图文 ID。 ``limit`` 给出剩余发送预算:名额用完后不再解析后续卡片的短链。 + ``exclude`` 是已知 ID(例如正文里已经命中的),它们不占用预算, + 避免重复卡片把名额吃光后漏掉真正的新图文。 """ max_items = None if limit is None else max(0, int(limit)) if max_items == 0: return [] opus_ids: list[str] = [] - seen: set[str] = set() + seen: set[str] = set(exclude) for seg in segments: if seg.get("type") != "json": diff --git a/src/Undefined/handlers/auto_extract.py b/src/Undefined/handlers/auto_extract.py index abb75f98..a5ad0dd4 100644 --- a/src/Undefined/handlers/auto_extract.py +++ b/src/Undefined/handlers/auto_extract.py @@ -57,16 +57,19 @@ async def _extract_bilibili_opus_ids( opus_ids = await extract_opus_ids_with_shortlinks(text, limit=limit) if limit is not None and len(opus_ids) >= limit: + # 正文已占满预算:不必再解析分享卡片 return opus_ids - seen = set(opus_ids) + # 卡片里可能重复正文已命中的图文:把这些 ID 作为已知项传下去, + # 它们不占预算,否则重复项会把名额吃光、漏掉卡片里的新图文 + known = set(opus_ids) remaining = None if limit is None else max(0, limit - len(opus_ids)) for opus_id in await extract_opus_from_json_message( - message_content, limit=remaining + message_content, limit=remaining, exclude=known ): - if opus_id in seen: + if opus_id in known: continue - seen.add(opus_id) + known.add(opus_id) opus_ids.append(opus_id) return opus_ids if limit is None else opus_ids[:limit] diff --git a/tests/test_bilibili_opus_pipeline.py b/tests/test_bilibili_opus_pipeline.py index 84575366..d5472d23 100644 --- a/tests/test_bilibili_opus_pipeline.py +++ b/tests/test_bilibili_opus_pipeline.py @@ -271,3 +271,40 @@ async def test_mixin_skips_card_lookup_when_budget_full( assert opus_ids == ["111", "222"] card_lookup.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_duplicate_card_does_not_consume_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """卡片里的重复图文不能吃掉名额,否则卡片中的新图文会被漏掉。""" + import Undefined.bilibili.opus_parser as opus_parser + from Undefined.handlers.auto_extract import AutoExtractMixin + + async def _resolve(url: str) -> str: + suffix = "111" if url.endswith("dup") else "222" + return f"https://www.bilibili.com/opus/{suffix}" + + monkeypatch.setattr( + opus_parser, "resolve_short_url", AsyncMock(side_effect=_resolve) + ) + + def _card(url: str) -> dict[str, Any]: + import html + import json + + payload = {"meta": {"detail_1": {"qqdocurl": url}}} + return {"type": "json", "data": {"data": html.escape(json.dumps(payload))}} + + dummy = cast(Any, SimpleNamespace()) + text = "https://www.bilibili.com/opus/111 https://b23.tv/dup" + segments = [_card("https://b23.tv/dup"), _card("https://b23.tv/new")] + + # 正文已命中 111,卡片给出重复的 111 + 新的 222;预算 2 应拿到两个 ID + assert await AutoExtractMixin._extract_bilibili_opus_ids( + dummy, text, segments, limit=2 + ) == ["111", "222"] + # 预算 1 时正文已占满,卡片不再贡献 + assert await AutoExtractMixin._extract_bilibili_opus_ids( + dummy, text, segments, limit=1 + ) == ["111"]