diff --git a/README.md b/README.md index 1cf1b3aa..e62b291e 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,15 @@ parser_emoji_cdn="https://emojicdn.elk.sh" # [可选] emoji 渲染样式 "apple", "google", "twitter", "facebook"(默认) parser_emoji_style="facebook" + +# [可选] 是否延迟发送视频/音频,需要用户发送特定表情或点赞特定表情后才发送 +parser_delay_send_media=False + +# [可选] 触发延迟发送视频的表情 +parser_delay_send_emoji="🎬" + +# [可选] 触发延迟发送视频的表情ID列表,用于监听group_msg_emoji_like事件 +parser_delay_send_emoji_ids='["128077"]' ``` diff --git a/src/nonebot_plugin_parser/config.py b/src/nonebot_plugin_parser/config.py index 76b47d5f..f0e5c184 100644 --- a/src/nonebot_plugin_parser/config.py +++ b/src/nonebot_plugin_parser/config.py @@ -52,6 +52,12 @@ class Config(BaseModel): """Pilmoji 表情 CDN""" parser_emoji_style: EmojiStyle = EmojiStyle.FACEBOOK """Pilmoji 表情样式""" + parser_delay_send_media: bool = False + """是否延迟发送视频/音频,需要用户发送特定表情或点赞特定表情后才发送""" + parser_delay_send_emoji: str = "🎬" + """触发延迟发送视频的表情""" + parser_delay_send_emoji_ids: list[str] = ["128077"] + """触发延迟发送视频的表情ID列表,用于监听group_msg_emoji_like事件""" @property def nickname(self) -> str: @@ -153,6 +159,21 @@ def emoji_style(self) -> EmojiStyle: """Pilmoji 表情样式""" return self.parser_emoji_style + @property + def delay_send_media(self) -> bool: + """是否延迟发送视频/音频""" + return self.parser_delay_send_media + + @property + def delay_send_emoji(self) -> str: + """触发延迟发送视频的表情""" + return self.parser_delay_send_emoji + + @property + def delay_send_emoji_ids(self) -> list[str]: + """触发延迟发送视频的表情ID列表""" + return self.parser_delay_send_emoji_ids + pconfig: Config = get_plugin_config(Config) """插件配置""" diff --git a/src/nonebot_plugin_parser/matchers/__init__.py b/src/nonebot_plugin_parser/matchers/__init__.py index 3add96f3..59c2789f 100644 --- a/src/nonebot_plugin_parser/matchers/__init__.py +++ b/src/nonebot_plugin_parser/matchers/__init__.py @@ -2,8 +2,13 @@ from typing import TypeVar from nonebot import logger, get_driver, on_command +from nonebot.rule import Rule from nonebot.params import CommandArg +from nonebot.typing import T_State +from nonebot.matcher import Matcher from nonebot.adapters import Message +from nonebot.plugin.on import get_matcher_source +from nonebot_plugin_alconna.uniseg import UniMsg from .rule import SUPER_PRIVATE, Searched, SearchResult, on_keyword_regex from ..utils import LimitedSizeDict @@ -12,6 +17,7 @@ from ..parsers import BaseParser, ParseResult, BilibiliParser from ..renders import get_renderer from ..download import DOWNLOADER +from ..parsers.data import AudioContent, VideoContent def _get_enabled_parser_classes() -> list[type[BaseParser]]: @@ -55,10 +61,13 @@ def register_parser_matcher(): # 缓存结果 _RESULT_CACHE = LimitedSizeDict[str, ParseResult](max_size=50) +# 消息ID与解析结果的关联缓存,用于多用户场景 +_MSG_ID_RESULT_MAP = LimitedSizeDict[str, ParseResult](max_size=100) def clear_result_cache(): _RESULT_CACHE.clear() + _MSG_ID_RESULT_MAP.clear() @UniHelper.with_reaction @@ -78,10 +87,74 @@ async def parser_handler( else: logger.debug(f"命中缓存: {cache_key}, 结果: {result}") - # 3. 渲染内容消息并发送 + # 3. 渲染内容消息并发送,保存消息ID renderer = get_renderer(result.platform.name) - async for message in renderer.render_messages(result): - await message.send() + try: + async for message in renderer.render_messages(result): + msg_sent = await message.send() + # 保存消息ID与解析结果的关联 + if msg_sent: + # 添加详细调试日志,查看msg_sent的类型和属性 + logger.debug(f"消息发送返回对象: type={type(msg_sent)}, repr={msg_sent!r}") + logger.debug(f"msg_sent属性: {dir(msg_sent)}") + try: + # 尝试获取消息ID + msg_id = None + # 检查是否为Event类型 + if hasattr(msg_sent, "get_event_name"): + from nonebot_plugin_alconna.uniseg import get_message_id + + try: + msg_id = get_message_id(msg_sent) # type: ignore + logger.debug(f"通过get_message_id获取到消息ID: {msg_id}") + except TypeError as e: + # 如果不是Event类型,跳过 + logger.debug(f"get_message_id类型错误: {e}") + # 尝试直接从对象获取id或message_id属性 + if hasattr(msg_sent, "id"): + msg_id = str(msg_sent.id) # type: ignore + logger.debug(f"通过id属性获取到消息ID: {msg_id}") + elif hasattr(msg_sent, "message_id"): + msg_id = str(msg_sent.message_id) # type: ignore + logger.debug(f"通过message_id属性获取到消息ID: {msg_id}") + elif hasattr(msg_sent, "message_ids"): + # 处理可能返回多个消息ID的情况 + msg_ids = getattr(msg_sent, "message_ids") + if msg_ids: + msg_id = str(msg_ids[0]) # type: ignore + logger.debug(f"通过message_ids属性获取到消息ID: {msg_id}") + elif hasattr(msg_sent, "msg_ids"): + # 处理Receipt对象的msg_ids属性 + receipt_msg_ids = getattr(msg_sent, "msg_ids") + logger.debug(f"Receipt.msg_ids: {receipt_msg_ids}") + if receipt_msg_ids: + # 处理msg_ids是列表的情况 + if isinstance(receipt_msg_ids, list): + for msg_id_info in receipt_msg_ids: + if isinstance(msg_id_info, dict) and "message_id" in msg_id_info: + msg_id = str(msg_id_info["message_id"]) + logger.debug(f"通过Receipt.msg_ids[0]['message_id']获取到消息ID: {msg_id}") + break + # 处理msg_ids是单个消息ID的情况 + else: + msg_id = str(receipt_msg_ids) # type: ignore + logger.debug(f"通过Receipt.msg_ids获取到消息ID: {msg_id}") + + if msg_id: + _MSG_ID_RESULT_MAP[msg_id] = result + logger.debug(f"保存消息ID与解析结果的关联: msg_id={msg_id}, url={cache_key}") + logger.debug(f"当前_MSG_ID_RESULT_MAP大小: {len(_MSG_ID_RESULT_MAP)}") + else: + logger.debug("未获取到消息ID") + except (NotImplementedError, TypeError, AttributeError) as e: + # 某些适配器可能不支持获取消息ID,忽略此错误 + logger.debug(f"获取消息ID失败: {e}") + except Exception as e: + # 渲染失败时,尝试直接发送解析结果 + logger.error(f"渲染失败: {e}") + from ..helper import UniMessage + + await UniMessage(f"解析成功,但渲染失败: {e!s}").send() # 4. 缓存解析结果 _RESULT_CACHE[cache_key] = result @@ -143,3 +216,156 @@ async def _(): await UniMessage(UniHelper.img_seg(raw=qrcode)).send() async for msg in parser.check_qr_state(): await UniMessage(msg).send() + + +# 监听特定表情,触发延迟发送的媒体内容 +class EmojiTriggerRule: + """表情触发规则类""" + + async def __call__(self, message: UniMsg, state: T_State) -> bool: + """检查消息是否是触发表情""" + text = message.extract_plain_text().strip() + return text == pconfig.delay_send_emoji + + +def emoji_trigger_rule() -> Rule: + """创建表情触发规则""" + return Rule(EmojiTriggerRule()) + + +# 创建表情触发的消息处理器 +delay_send_matcher = Matcher.new( + "message", + emoji_trigger_rule(), + priority=5, + block=True, + source=get_matcher_source(1), +) + + +@delay_send_matcher.handle() +async def delay_media_trigger_handler(): + from ..helper import UniHelper, UniMessage + + # 获取最新的解析结果 + if not _RESULT_CACHE: + return + + # 获取最近的解析结果 + latest_url = next(reversed(_RESULT_CACHE.keys())) + result = _RESULT_CACHE[latest_url] + + # 发送延迟的媒体内容 + for media_type, path in result.media_contents: + if media_type == VideoContent: + await UniMessage(UniHelper.video_seg(path)).send() + elif media_type == AudioContent: + await UniMessage(UniHelper.record_seg(path)).send() + + # 清空当前结果的媒体内容 + result.media_contents.clear() + + +# 监听group_msg_emoji_like事件,处理点赞触发 +from nonebot import on_notice +from nonebot_plugin_alconna.uniseg import message_reaction + +on_notice_ = on_notice(priority=1, block=False) + + +@on_notice_.handle() +async def handle_group_msg_emoji_like(event): + from ..helper import UniHelper, UniMessage + + # 检查是否是group_msg_emoji_like事件 + is_group_emoji_like = False + emoji_id = "" + liked_message_id = "" + + # 处理不同形式的事件对象(字典或对象) + if isinstance(event, dict): + # 字典形式的事件 + if event.get("notice_type") == "group_msg_emoji_like": + is_group_emoji_like = True + emoji_id = event["likes"][0]["emoji_id"] + liked_message_id = event["message_id"] + else: + # 对象形式的事件 + if hasattr(event, "notice_type") and event.notice_type == "group_msg_emoji_like": + is_group_emoji_like = True + if hasattr(event, "likes") and event.likes: + if isinstance(event.likes[0], dict): + emoji_id = event.likes[0].get("emoji_id", "") + else: + emoji_id = event.likes[0].emoji_id + if hasattr(event, "message_id"): + liked_message_id = event.message_id + + # 检查是否是group_msg_emoji_like事件且表情ID有效 + if not is_group_emoji_like or not emoji_id: + return + + # 检查表情ID是否在配置列表中 + if emoji_id not in pconfig.delay_send_emoji_ids: + return + + # 发送"听到需求"的表情(使用用户指定的表情ID 282) + try: + # 只有当liked_message_id有效时,才发送表情反馈 + if liked_message_id: + await message_reaction("282", message_id=str(liked_message_id)) + except Exception as e: + logger.warning(f"Failed to send resolving reaction: {e}") + + try: + logger.debug(f"收到表情点赞事件: emoji_id={emoji_id}, message_id={liked_message_id}, event={event}") + logger.debug(f"当前_MSG_ID_RESULT_MAP: {list(_MSG_ID_RESULT_MAP.keys())}") + + # 根据消息ID获取对应的解析结果 + result = _MSG_ID_RESULT_MAP.get(str(liked_message_id)) + if not result: + # 发送"失败"的表情(使用用户指定的表情ID 10060) + logger.debug(f"未找到消息ID {liked_message_id} 对应的解析结果") + try: + if liked_message_id: + await message_reaction("10060", message_id=str(liked_message_id)) + except Exception as e: + logger.warning(f"Failed to send fail reaction: {e}") + return + + # 发送延迟的媒体内容 + sent = False + for media_type, path in result.media_contents: + if media_type == VideoContent: + await UniMessage(UniHelper.video_seg(path)).send() + sent = True + elif media_type == AudioContent: + await UniMessage(UniHelper.record_seg(path)).send() + sent = True + + # 清空当前结果的媒体内容 + result.media_contents.clear() + + # 发送对应的表情 + if sent: + # 发送"完成"的表情(使用用户指定的表情ID 124) + try: + if liked_message_id: + await message_reaction("124", message_id=str(liked_message_id)) + except Exception as e: + logger.warning(f"Failed to send done reaction: {e}") + else: + # 没有可发送的媒体内容,发送"失败"的表情(使用用户指定的表情ID 10060) + try: + if liked_message_id: + await message_reaction("10060", message_id=str(liked_message_id)) + except Exception as e: + logger.warning(f"Failed to send fail reaction: {e}") + except Exception as e: + # 发送"失败"的表情(使用用户指定的表情ID 10060) + try: + if liked_message_id: + await message_reaction("10060", message_id=str(liked_message_id)) + except Exception as reaction_e: + logger.warning(f"Failed to send fail reaction: {reaction_e}") + logger.error(f"Failed to send media content: {e}") diff --git a/src/nonebot_plugin_parser/parsers/bilibili/__init__.py b/src/nonebot_plugin_parser/parsers/bilibili/__init__.py index b3457717..5285295c 100644 --- a/src/nonebot_plugin_parser/parsers/bilibili/__init__.py +++ b/src/nonebot_plugin_parser/parsers/bilibili/__init__.py @@ -40,6 +40,13 @@ def __init__(self): self._credential: Credential | None = None self._cookies_file = pconfig.config_dir / "bilibili_cookies.json" + def _format_stat(self, num: int | None) -> str: + """将数字格式化为 1.2万 的形式""" + if num is None: + return "0" + format_num = str(num) if num < 10000 else f"{num / 10000:.1f}万" + return format_num + @handle("b23.tv", r"b23\.tv/[A-Za-z\d\._?%&+\-=/#]+") @handle("bili2233", r"bili2233\.cn/[A-Za-z\d\._?%&+\-=/#]+") async def _parse_short_link(self, searched: Match[str]): @@ -157,6 +164,35 @@ async def download_video(): page_info.duration, ) + # 提取统计数据 + stats = {} + try: + if video_info.stat: + stats = { + "play": self._format_stat(video_info.stat.view), + "danmaku": self._format_stat(video_info.stat.danmaku), + "like": self._format_stat(video_info.stat.like), + "coin": self._format_stat(video_info.stat.coin), + "favorite": self._format_stat(video_info.stat.favorite), + "share": self._format_stat(video_info.stat.share), + "reply": self._format_stat(video_info.stat.reply), + } + logger.debug(f"[BiliParser] 视频统计数据: {stats}") + except Exception as e: + logger.warning(f"[BiliParser] 统计数据提取异常: {e}") + + # 构造 extra_data + extra_data = { + "info": ai_summary, + "stats": stats, + "type": "video", + "type_tag": "视频", + "type_icon": "fa-circle-play", + "author_id": str(video_info.owner.mid), + "content_id": video_info.bvid, + } + logger.debug(f"Video extra data: {extra_data}") + return self.result( url=url, title=page_info.title, @@ -164,7 +200,7 @@ async def download_video(): text=text, author=author, contents=[video_content], - extra={"info": ai_summary}, + extra=extra_data, ) async def parse_dynamic(self, dynamic_id: int): @@ -188,12 +224,82 @@ async def parse_dynamic(self, dynamic_id: int): img_task = DOWNLOADER.download_img(image_url, ext_headers=self.headers) contents.append(ImageContent(img_task)) + # 提取当前动态的统计数据 + stats = {} + try: + if dynamic_info.modules.module_stat: + m_stat = dynamic_info.modules.module_stat + stats = { + "like": self._format_stat(m_stat.get("like", {}).get("count", 0)), + "reply": self._format_stat(m_stat.get("comment", {}).get("count", 0)), + "share": self._format_stat(m_stat.get("forward", {}).get("count", 0)), + } + except Exception: + pass + + # --- 基础 extra 数据 --- + extra_data = { + "stats": stats, + "type": "dynamic", + "type_tag": "动态", + "type_icon": "fa-quote-left", + "author_id": str(dynamic_info.modules.module_author.mid), + "content_id": str(dynamic_id), + } + + # --- 新增:处理转发内容 (叠加到 extra) --- + if dynamic_info.type == "DYNAMIC_TYPE_FORWARD" and dynamic_info.orig: + orig_item = dynamic_info.orig + + if orig_item.visible: + orig_type_tag = "动态" + major_info = orig_item.modules.major_info + + # 尝试判断源动态类型 + if major_info: + major_type = major_info.get("type") + if major_type == "MAJOR_TYPE_ARCHIVE": + orig_type_tag = "视频" + elif major_type == "MAJOR_TYPE_OPUS": + orig_type_tag = "图文" + elif major_type == "MAJOR_TYPE_DRAW": + orig_type_tag = "图文" + + # 获取源动态封面 (优先取视频封面,否则取第一张图) + orig_cover = orig_item.cover_url + if not orig_cover and orig_item.image_urls: + orig_cover = orig_item.image_urls[0] + + # 获取源动态的所有图片列表 + orig_images = orig_item.image_urls + + # 构造 origin 字典 + extra_data["origin"] = { + "exists": True, + "author": orig_item.name, + "title": orig_item.title, + "text": orig_item.text, + "cover": orig_cover, + "images": orig_images, + "type_tag": orig_type_tag, + "mid": str(orig_item.modules.module_author.mid), + } + else: + # 源动态已失效 + extra_data["origin"] = { + "exists": False, + "text": "源动态已被删除或不可见", + "author": "未知", + "title": "资源失效", + } + return self.result( - title=dynamic_info.title, + title=dynamic_info.title or "B站动态", text=dynamic_info.text, timestamp=dynamic_info.timestamp, author=author, contents=contents, + extra=extra_data, ) async def parse_opus(self, opus_id: int): @@ -234,25 +340,73 @@ async def _parse_opus_obj(self, bili_opus: Opus): # 转换为结构体 opus_data = convert(opus_info, OpusItem) logger.debug(f"opus_data: {opus_data}") - author = self.create_author(*opus_data.name_avatar) + + # 提取作者信息 + author_name = "" + author_face = "" + author_mid = "" + + if hasattr(opus_data.item, "modules"): + for module in opus_data.item.modules: + if module.module_type == "MODULE_TYPE_AUTHOR" and module.module_author: + author_name = module.module_author.name + author_face = module.module_author.face + author_mid = str(module.module_author.mid) + break + + if not author_name and hasattr(opus_data, "name_avatar"): + author_name, author_face = opus_data.name_avatar + + author = self.create_author(author_name, author_face) # 按顺序处理图文内容(参考 parse_read 的逻辑) contents: list[MediaContent] = [] - current_text = "" + full_text_list = [] for node in opus_data.gen_text_img(): if isinstance(node, ImageNode): - contents.append(self.create_graphics_content(node.url, current_text.strip(), node.alt)) - current_text = "" + # 使用 DOWNLOADER 下载并封装为 ImageContent + img_task = DOWNLOADER.download_img(node.url, ext_headers=self.headers) + contents.append(ImageContent(img_task)) + elif isinstance(node, TextNode): - current_text += node.text + full_text_list.append(node.text) + + full_text = "\n".join(full_text_list).strip() + + # 提取统计数据 + stats = {} + try: + if hasattr(opus_data.item, "modules"): + for module in opus_data.item.modules: + if module.module_type == "MODULE_TYPE_STAT" and module.module_stat: + st = module.module_stat + stats = { + "like": self._format_stat(st.get("like", {}).get("count", 0)), + "reply": self._format_stat(st.get("comment", {}).get("count", 0)), + "share": self._format_stat(st.get("forward", {}).get("count", 0)), + } + break + except Exception: + pass + + # 构造 Extra 数据 + extra_data = { + "stats": stats, + "type": "opus", + "type_tag": "图文", + "type_icon": "fa-file-pen", + "author_id": author_mid, + "content_id": str(opus_data.item.id_str), + } return self.result( - title=opus_data.title, + title=opus_data.title or f"{author_name}的图文动态", author=author, timestamp=opus_data.timestamp, contents=contents, - text=current_text.strip(), + text=full_text, + extra=extra_data, ) async def parse_live(self, room_id: int): @@ -286,12 +440,22 @@ async def parse_live(self, room_id: int): author = self.create_author(room_data.name, room_data.avatar) url = f"https://www.bilibili.com/blackboard/live/live-activity-player.html?enterTheRoom=0&cid={room_id}" + + extra_data = { + "type": "live", + "type_tag": f"直播·{room_data.room_info.parent_area_name}", + "type_icon": "fa-tower-broadcast", + "content_id": f"ROOM{room_id}", + "tags": str(room_data.room_info.tags), + "live_info": { + "level": str(room_data.anchor_info.live_info.level), + "level_color": str(room_data.anchor_info.live_info.level_color), + "score": str(room_data.anchor_info.live_info.score), + }, + } + return self.result( - url=url, - title=room_data.title, - text=room_data.detail, - contents=contents, - author=author, + url=url, title=room_data.title, text=room_data.detail, contents=contents, author=author, extra=extra_data ) async def parse_favlist(self, fav_id: int): diff --git a/src/nonebot_plugin_parser/parsers/bilibili/dynamic.py b/src/nonebot_plugin_parser/parsers/bilibili/dynamic.py index 4a5c84df..a222cbba 100644 --- a/src/nonebot_plugin_parser/parsers/bilibili/dynamic.py +++ b/src/nonebot_plugin_parser/parsers/bilibili/dynamic.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Optional from msgspec import Struct, convert @@ -11,11 +11,6 @@ class AuthorInfo(Struct): mid: int pub_time: str pub_ts: int - # jump_url: str - # following: bool = False - # official_verify: dict[str, Any] | None = None - # vip: dict[str, Any] | None = None - # pendant: dict[str, Any] | None = None class VideoArchive(Struct): @@ -26,28 +21,18 @@ class VideoArchive(Struct): title: str desc: str cover: str - # duration_text: str - # jump_url: str - # stat: dict[str, str] - # badge: dict[str, Any] | None = None class OpusImage(Struct): """图文动态图片信息""" url: str - # width: int - # height: int - # size: float - # aigc: dict[str, Any] | None = None - # live_url: str | None = None class OpusSummary(Struct): """图文动态摘要""" text: str - # rich_text_nodes: list[dict[str, Any]] class OpusContent(Struct): @@ -57,11 +42,10 @@ class OpusContent(Struct): pics: list[OpusImage] summary: OpusSummary title: str | None = None - # fold_action: list[str] | None = None class DynamicMajor(Struct): - """动态主要内容""" + """动态主要内容 (Major)""" type: str archive: VideoArchive | None = None @@ -138,6 +122,8 @@ class DynamicInfo(Struct): visible: bool modules: DynamicModule basic: dict[str, Any] | None = None + # 【关键修改】添加 orig 字段以支持转发内容 (递归结构) + orig: Optional["DynamicInfo"] = None @property def name(self) -> str: @@ -161,15 +147,29 @@ def title(self) -> str | None: if major_info: major = convert(major_info, DynamicMajor) return major.title + # 如果是转发动态且没有 major title,可以返回默认值 + if self.type == "DYNAMIC_TYPE_FORWARD": + return "转发动态" return None @property def text(self) -> str | None: """获取文本内容""" + # 【关键修改】优先从 modules.module_dynamic.desc.text 获取 + # 这是用户发布的文字(包括转发时的评论) + if self.modules.module_dynamic: + desc = self.modules.module_dynamic.get("desc") + if desc and isinstance(desc, dict): + text_content = desc.get("text") + if text_content: + return text_content + + # 如果没有直接的 desc 文本,尝试从 major 中获取 (例如纯视频投稿的简介) major_info = self.modules.major_info if major_info: major = convert(major_info, DynamicMajor) return major.text + return None @property diff --git a/src/nonebot_plugin_parser/parsers/bilibili/opus.py b/src/nonebot_plugin_parser/parsers/bilibili/opus.py index 72564dc1..b40f710d 100644 --- a/src/nonebot_plugin_parser/parsers/bilibili/opus.py +++ b/src/nonebot_plugin_parser/parsers/bilibili/opus.py @@ -84,7 +84,7 @@ class Module(Struct): module_type: str module_author: Author | None = None module_content: Content | None = None - # module_stat: OpusStat | None = None + module_stat: dict[str, Any] | None = None class Basic(Struct): diff --git a/src/nonebot_plugin_parser/parsers/data.py b/src/nonebot_plugin_parser/parsers/data.py index 8ddcd965..f095ea01 100644 --- a/src/nonebot_plugin_parser/parsers/data.py +++ b/src/nonebot_plugin_parser/parsers/data.py @@ -158,6 +158,8 @@ class ParseResult: """转发的内容""" render_image: Path | None = None """渲染图片""" + media_contents: list[tuple[type, Path]] = field(default_factory=list) + """延迟发送的媒体内容""" @property def header(self) -> str | None: @@ -224,7 +226,7 @@ def __repr__(self) -> str: f"author: {self.author}, " f"contents: {self.contents}, " f"extra: {self.extra}, " - f"repost: <<<<<<<{self.repost}>>>>>>, " + f"repost: {self.repost}, " f"render_image: {self.render_image.name if self.render_image else 'None'}" ) diff --git a/src/nonebot_plugin_parser/renders/base.py b/src/nonebot_plugin_parser/renders/base.py index 50cd9854..9dcbf516 100644 --- a/src/nonebot_plugin_parser/renders/base.py +++ b/src/nonebot_plugin_parser/renders/base.py @@ -6,6 +6,8 @@ from collections.abc import AsyncGenerator from typing_extensions import override +from nonebot import logger + from ..config import pconfig from ..helper import UniHelper, UniMessage, ForwardNodeInner from ..parsers import ( @@ -51,6 +53,7 @@ async def render_contents(self, result: ParseResult) -> AsyncGenerator[UniMessag failed_count = 0 forwardable_segs: list[ForwardNodeInner] = [] dynamic_segs: list[ForwardNodeInner] = [] + media_contents: list[tuple[type, Path]] = [] for cont in chain(result.contents, result.repost.contents if result.repost else ()): try: @@ -66,9 +69,23 @@ async def render_contents(self, result: ParseResult) -> AsyncGenerator[UniMessag match cont: case VideoContent(): - yield UniMessage(UniHelper.video_seg(path)) + logger.debug(f"处理VideoContent,delay_send_media={pconfig.delay_send_media}") + if pconfig.delay_send_media: + # 延迟发送,先缓存 + logger.debug(f"延迟发送视频,缓存路径: {path}") + media_contents.append((VideoContent, path)) + else: + logger.debug(f"立即发送视频: {path}") + yield UniMessage(UniHelper.video_seg(path)) case AudioContent(): - yield UniMessage(UniHelper.record_seg(path)) + logger.debug(f"处理AudioContent,delay_send_media={pconfig.delay_send_media}") + if pconfig.delay_send_media: + # 延迟发送,先缓存 + logger.debug(f"延迟发送音频,缓存路径: {path}") + media_contents.append((AudioContent, path)) + else: + logger.debug(f"立即发送音频: {path}") + yield UniMessage(UniHelper.record_seg(path)) case ImageContent(): forwardable_segs.append(UniHelper.img_seg(path)) case DynamicContent(): @@ -81,6 +98,10 @@ async def render_contents(self, result: ParseResult) -> AsyncGenerator[UniMessag graphics_msg = graphics_msg + graphics.alt forwardable_segs.append(graphics_msg) + # 如果有延迟发送的媒体,存储到解析结果中 + if media_contents: + result.media_contents = media_contents + if forwardable_segs: if result.text: forwardable_segs.append(result.text) diff --git a/src/nonebot_plugin_parser/renders/htmlrender.py b/src/nonebot_plugin_parser/renders/htmlrender.py index bcd1519c..7a3bbffb 100644 --- a/src/nonebot_plugin_parser/renders/htmlrender.py +++ b/src/nonebot_plugin_parser/renders/htmlrender.py @@ -1,3 +1,4 @@ +import datetime from typing import Any from pathlib import Path from typing_extensions import override @@ -15,35 +16,41 @@ class HtmlRenderer(ImageRenderer): @override async def render_image(self, result: ParseResult) -> bytes: - """使用 HTML 绘制通用社交媒体帖子卡片 - - Args: - result: 解析结果 - - Returns: - PNG 图片的字节数据 - """ + """使用 HTML 绘制通用社交媒体帖子卡片""" # 准备模板数据 template_data = await self._resolve_parse_result(result) + # 处理模板针对 + template_name = "card.html.jinja" + if result.platform: + # 添加存在性验证 + file_name = f"{str(result.platform.name).lower()}.html.jinja" + if (self.templates_dir / file_name).exists(): + template_name = file_name + # 渲染图片 return await template_to_pic( template_path=str(self.templates_dir), - template_name="card.html.jinja", - templates={"result": template_data}, + template_name=template_name, + templates={ + "result": template_data, + "rendering_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }, pages={ - "viewport": {"width": 800, "height": 100}, # 高度会自动调整 + "viewport": {"width": 800, "height": 100}, "base_url": f"file://{self.templates_dir}", }, ) async def _resolve_parse_result(self, result: ParseResult) -> dict[str, Any]: - """解析 ParseResult 为模板可用的字典数据,并处理异步资源路径""" + """解析 ParseResult 为模板可用的字典数据""" + data: dict[str, Any] = { "title": result.title, "text": result.text, "formartted_datetime": result.formartted_datetime, "extra_info": result.extra_info, + "extra": result.extra, } if result.platform: @@ -56,27 +63,28 @@ async def _resolve_parse_result(self, result: ParseResult) -> dict[str, Any]: if logo_path.exists(): data["platform"]["logo_path"] = logo_path.as_uri() - # 处理作者信息 if result.author: avatar_path = await result.author.get_avatar_path() + author_id = getattr(result.author, "id", None) + if not author_id and result.extra: + author_id = result.extra.get("author_id") + data["author"] = { "name": result.author.name, + "id": author_id, # 传递 UID "avatar_path": avatar_path.as_uri() if avatar_path else None, } - # 处理封面 cover_path = await result.cover_path if cover_path: data["cover_path"] = cover_path.as_uri() - # 处理图片内容 img_contents = [] for img in result.img_contents: path = await img.get_path() img_contents.append({"path": path.as_uri()}) data["img_contents"] = img_contents - # 处理图文内容 graphics_contents = [] for graphics in result.graphics_contents: path = await graphics.get_path() @@ -89,7 +97,6 @@ async def _resolve_parse_result(self, result: ParseResult) -> dict[str, Any]: ) data["graphics_contents"] = graphics_contents - # 处理转发内容 if result.repost: data["repost"] = await self._resolve_parse_result(result.repost) diff --git a/src/nonebot_plugin_parser/renders/templates/bilibili.html.jinja b/src/nonebot_plugin_parser/renders/templates/bilibili.html.jinja new file mode 100644 index 00000000..0c9268f4 --- /dev/null +++ b/src/nonebot_plugin_parser/renders/templates/bilibili.html.jinja @@ -0,0 +1,645 @@ + + + + + + Bilibili Card + + + + + + {# === 宏定义:渲染卡片 === #} + {% macro render_card(result, is_repost=False) %} + + {# 变量定义 #} + {% set extra = result.extra if result.extra else {} %} + {% set platform = result.platform if result.platform else {} %} + {% set content_id = extra.content_id %} + {% set stats = extra.stats if extra.stats else {} %} + {% set type_tag = extra.type_tag if extra.type_tag else "B站" %} + {% set type_icon = extra.type_icon if extra.type_icon else "fa-brands fa-bilibili" %} + {% set type = extra.type if extra.type else "video" %} + +
+ {% if not is_repost %} +
+ {% endif %} + + {# --- 封面区域 --- #} + {% set cover_url = result.cover_path %} + {% if not cover_url and result.img_contents and result.img_contents|length > 0 %} + {% set cover_url = result.img_contents[0].path %} + {% endif %} + + {% if cover_url %} +
+ Cover +
+ + {% if platform.name == 'bilibili' and extra.type_tag %} +
+ + {{ extra.type_tag }} +
+ {% endif %} + + {% if platform %} +
+ {% if result.platform.logo_path %} + + {% else %} + + {% endif %} +
+ {% endif %} + + {% if type in ['video', 'live'] %} +
+ +
+ {% endif %} +
+ {% endif %} + +
+ {# --- 作者信息行 --- #} + {% if result.author %} +
+
+ +
+

{{ result.author.name }}

+
+ {% if extra.author_id %} + UID: {{ extra.author_id }} + {% endif %} + {% if content_id %} + {{ content_id }} + {% endif %} + {% if result.formartted_datetime %} + {{ result.formartted_datetime }} + {% endif %} +
+
+
+
+ +
+
+ {% endif %} + + {# --- 统计数据 --- #} + {% if stats and not is_repost %} +
+
{{ stats.play or '-' }}
播放
+
{{ stats.like or '-' }}
点赞
+
{{ stats.reply or '-' }}
评论
+
{{ stats.favorite or '-' }}
收藏
+
{{ stats.share or '-' }}
分享
+
+
{% if stats.coin %}{% else %}{% endif %}
+
{{ stats.coin or stats.danmaku or '-' }}
{{ '硬币' if stats.coin else '弹幕' }}
+
+
+ {% endif %} + + {# --- 主要标题 --- #} + {% if result.title %} +
{{ result.title }}
+ {% endif %} + + {# --- AI 总结 --- #} + {% if extra.info %} +
+

AI 内容总结

+

{{ extra.info }}

+
+ {% endif %} + + {# --- 正文描述 --- #} + {% if result.text %} +
{{ result.text }}
+ {% endif %} + + {# --- 【新增】Extra Origin 转发区域 --- #} + {% if result.extra.origin %} +
+ {% if result.extra.origin.exists %} +
+ {{ result.extra.origin.type_tag }} + @{{ result.extra.origin.author }} + {% if result.extra.origin.mid %} + (UID: {{ result.extra.origin.mid }}) + {% endif %} +
+ + {% if result.extra.origin.title %} +
{{ result.extra.origin.title }}
+ {% endif %} + + {% if result.extra.origin.text %} +
{{ result.extra.origin.text }}
+ {% endif %} + + {# --- 图片展示逻辑优化 --- #} + {% set orig_imgs = result.extra.origin.images %} + {% set is_video = result.extra.origin.type_tag == '视频' %} + + {# 情况1:视频内容,或者只有一张图 -> 显示大图(原有逻辑) #} + {% if is_video or (orig_imgs and orig_imgs|length == 1) %} +
+ 转发图片 + {% if is_video %} + +
+ +
+ {% endif %} +
+ + {# 情况2:多图 -> 显示九宫格 #} + {% elif orig_imgs and orig_imgs|length > 1 %} + {% set count = orig_imgs|length %} + {% set grid_class = 'double' %} + {% if count >= 3 %}{% set grid_class = 'nine' %} + {% elif count == 4 %}{% set grid_class = 'quad' %}{% endif %} + +
+
+ {% for img_url in orig_imgs[:9] %} +
+ + {% if loop.last and count > 9 %} +
+{{ count - 9 }}
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + {% else %} + +
+ + {{ result.extra.origin.text or '源动态已被删除或不可见' }} +
+ {% endif %} +
+ {% endif %} + + {# --- 图片网格 --- #} + {% if result.img_contents %} + {% set imgs = result.img_contents %} + {% set count = imgs|length %} + {% if count > 0 %} +
+
内容预览
+ + {% set grid_class = 'single' %} + {% if count == 2 %}{% set grid_class = 'double' %} + {% elif count == 4 %}{% set grid_class = 'quad' %} + {% elif count >= 3 %}{% set grid_class = 'nine' %} + {% endif %} + +
+ {% for img in imgs[:9] %} +
+ + {% if loop.last and count > 9 %} +
+{{ count - 9 }}
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + {% endif %} + + {# --- 旧版 Repost (保留兼容性) --- #} + {% if result.repost %} +
+
+ 转发内容 (Legacy) +
+ {{ render_card(result.repost, is_repost=True) }} +
+ {% endif %} +
+ + {# --- 底部 --- #} + {% if not is_repost %} + + {% endif %} +
+ {% endmacro %} + + {{ render_card(result) }} + + + diff --git a/tests/parsers/test_bilibili.py b/tests/parsers/test_bilibili.py index 4cc8a70d..6cb60e6b 100644 --- a/tests/parsers/test_bilibili.py +++ b/tests/parsers/test_bilibili.py @@ -89,7 +89,7 @@ async def test_parse_opus(opus_url: str) -> None: assert avatar_path.exists(), "头像不存在" graphics_contents = result.graphics_contents - assert graphics_contents, "图文内容为空" + assert result.text or result.contents, "解析结果为空(无文本也无图片)" for graphics_content in graphics_contents: path = await graphics_content.get_path()