diff --git a/nonebot_bison/__init__.py b/nonebot_bison/__init__.py index 5cd9365..90da7a1 100644 --- a/nonebot_bison/__init__.py +++ b/nonebot_bison/__init__.py @@ -7,7 +7,7 @@ require("nonebot_plugin_saa") import nonebot_plugin_saa from .plugin_config import PlugConfig, plugin_config -from . import post, send, types, utils, config, platform, bootstrap, scheduler, admin_page, sub_manager +from . import post, send, theme, types, utils, config, platform, bootstrap, scheduler, admin_page, sub_manager __help__version__ = "0.8.2" nonebot_plugin_saa.enable_auto_select_bot() @@ -43,4 +43,5 @@ __all__ = [ "platform", "types", "utils", + "theme", ] diff --git a/nonebot_bison/platform/arknights.py b/nonebot_bison/platform/arknights.py index 6876ee4..a3f788c 100644 --- a/nonebot_bison/platform/arknights.py +++ b/nonebot_bison/platform/arknights.py @@ -1,14 +1,56 @@ from typing import Any -from pathlib import Path +from functools import partial from httpx import AsyncClient -from nonebot.plugin import require from bs4 import BeautifulSoup as bs +from pydantic import Field, BaseModel from ..post import Post from ..types import Target, RawPost, Category +from .platform import NewMessage, StatusChange from ..utils.scheduler_config import SchedulerConfig -from .platform import NewMessage, StatusChange, CategoryNotRecognize + + +class ArkResponseBase(BaseModel): + code: int + msg: str + + +class BulletinListItem(BaseModel): + cid: str + title: str + category: int + display_time: str = Field(alias="displayTime") + updated_at: int = Field(alias="updatedAt") + sticky: bool + + +class BulletinList(BaseModel): + list: list[BulletinListItem] + + class Config: + extra = "ignore" + + +class BulletinData(BaseModel): + cid: str + display_type: int = Field(alias="displayType") + title: str + category: int + header: str + content: str + jump_link: str = Field(alias="jumpLink") + banner_image_url: str = Field(alias="bannerImageUrl") + display_time: str = Field(alias="displayTime") + updated_at: int = Field(alias="updatedAt") + + +class ArkBulletinListResponse(ArkResponseBase): + data: BulletinList + + +class ArkBulletinResponse(ArkResponseBase): + data: BulletinData class ArknightsSchedConf(SchedulerConfig): @@ -26,74 +68,52 @@ class Arknights(NewMessage): is_common = False scheduler = ArknightsSchedConf has_target = False + default_theme = "arknights" @classmethod async def get_target_name(cls, client: AsyncClient, target: Target) -> str | None: return "明日方舟游戏信息" - async def get_sub_list(self, _) -> list[RawPost]: + async def get_sub_list(self, _) -> list[BulletinListItem]: raw_data = await self.client.get("https://ak-webview.hypergryph.com/api/game/bulletinList?target=IOS") - return raw_data.json()["data"]["list"] + return ArkBulletinListResponse.parse_obj(raw_data.json()).data.list - def get_id(self, post: RawPost) -> Any: - return post["cid"] + def get_id(self, post: BulletinListItem) -> Any: + return post.cid - def get_date(self, _: RawPost) -> Any: - return None + def get_date(self, post: BulletinListItem) -> Any: + return post.updated_at def get_category(self, _) -> Category: return Category(1) - async def parse(self, raw_post: RawPost) -> Post: + async def parse(self, raw_post: BulletinListItem) -> Post: raw_data = await self.client.get( f"https://ak-webview.hypergryph.com/api/game/bulletin/{self.get_id(post=raw_post)}" ) - raw_data = raw_data.json()["data"] + data = ArkBulletinResponse.parse_obj(raw_data.json()).data - announce_title = raw_data.get("header") if raw_data.get("header") != "" else raw_data.get("title") - text = "" + def title_escape(text: str) -> str: + return text.replace("\n", " - ") - pics = [] - if raw_data["bannerImageUrl"]: - pics.append(raw_post["bannerImageUrl"]) - - elif raw_data["content"]: - require("nonebot_plugin_htmlrender") - from nonebot_plugin_htmlrender import template_to_pic - - template_path = str(Path(__file__).parent.parent / "post/templates/ark_announce") - pic_data = await template_to_pic( - template_path=template_path, - template_name="index.html", - templates={ - "bannerImageUrl": raw_data["bannerImageUrl"], - "announce_title": announce_title, - "content": raw_data["content"], - }, - pages={ - "viewport": {"width": 400, "height": 100}, - "base_url": f"file://{template_path}", - }, - ) - # render = Render() - # viewport = {"width": 320, "height": 6400, "deviceScaleFactor": 3} - # pic_data = await render.render( - # announce_url, viewport=viewport, target="div.main" - # ) - if pic_data: - pics.append(pic_data) - else: - text = "图片渲染失败" + # gen title, content + if data.header: + # header是title的更详细版本 + # header会和content一起出现 + title = data.header else: - raise CategoryNotRecognize("未找到可渲染部分") + # 只有一张图片 + title = title_escape(data.title) + return Post( - "arknights", - text=text, - url="", - target_name="明日方舟游戏内公告", - pics=pics, + self, + content=data.content, + title=title, + nickname="明日方舟游戏内公告", + images=[data.banner_image_url] if data.banner_image_url else None, + url=data.jump_link or None, + timestamp=data.updated_at, compress=True, - override_use_pic=False, ) @@ -106,6 +126,7 @@ class AkVersion(StatusChange): is_common = False scheduler = ArknightsSchedConf has_target = False + default_theme = "brief" @classmethod async def get_target_name(cls, client: AsyncClient, target: Target) -> str | None: @@ -122,18 +143,15 @@ class AkVersion(StatusChange): def compare_status(self, _, old_status, new_status): res = [] + ArkUpdatePost = partial(Post, self, "", nickname="明日方舟更新信息") if old_status.get("preAnnounceType") == 2 and new_status.get("preAnnounceType") == 0: - res.append( - Post("arknights", text="登录界面维护公告上线(大概是开始维护了)", target_name="明日方舟更新信息") - ) + res.append(ArkUpdatePost(title="登录界面维护公告上线(大概是开始维护了)")) elif old_status.get("preAnnounceType") == 0 and new_status.get("preAnnounceType") == 2: - res.append( - Post("arknights", text="登录界面维护公告下线(大概是开服了,冲!)", target_name="明日方舟更新信息") - ) + res.append(ArkUpdatePost(title="登录界面维护公告下线(大概是开服了,冲!)")) if old_status.get("clientVersion") != new_status.get("clientVersion"): - res.append(Post("arknights", text="游戏本体更新(大更新)", target_name="明日方舟更新信息")) + res.append(ArkUpdatePost(title="游戏本体更新(大更新)")) if old_status.get("resVersion") != new_status.get("resVersion"): - res.append(Post("arknights", text="游戏资源更新(小更新)", target_name="明日方舟更新信息")) + res.append(ArkUpdatePost(title="游戏资源更新(小更新)")) return res def get_category(self, _): @@ -180,13 +198,12 @@ class MonsterSiren(NewMessage): imgs = [x["src"] for x in soup("img")] text = f'{raw_post["title"]}\n{soup.text.strip()}' return Post( - "monster-siren", - text=text, - pics=imgs, + self, + text, + images=imgs, url=url, - target_name="塞壬唱片新闻", + nickname="塞壬唱片新闻", compress=True, - override_use_pic=False, ) @@ -199,6 +216,7 @@ class TerraHistoricusComic(NewMessage): is_common = False scheduler = ArknightsSchedConf has_target = False + default_theme = "brief" @classmethod async def get_target_name(cls, client: AsyncClient, target: Target) -> str | None: @@ -220,11 +238,11 @@ class TerraHistoricusComic(NewMessage): async def parse(self, raw_post: RawPost) -> Post: url = f'https://terra-historicus.hypergryph.com/comic/{raw_post["comicCid"]}/episode/{raw_post["episodeCid"]}' return Post( - "terra-historicus", - text=f'{raw_post["title"]} - {raw_post["episodeShortTitle"]}', - pics=[raw_post["coverUrl"]], + self, + raw_post["subtitle"], + title=f'{raw_post["title"]} - {raw_post["episodeShortTitle"]}', + images=[raw_post["coverUrl"]], url=url, - target_name="泰拉记事社漫画", + nickname="泰拉记事社漫画", compress=True, - override_use_pic=False, ) diff --git a/nonebot_bison/platform/bilibili.py b/nonebot_bison/platform/bilibili.py index 369cf11..17b724f 100644 --- a/nonebot_bison/platform/bilibili.py +++ b/nonebot_bison/platform/bilibili.py @@ -193,7 +193,7 @@ class Bilibili(NewMessage): text += orig_text else: raise CategoryNotSupport(post_type) - return Post("bilibili", text=text, url=url, pics=pic, target_name=target_name) + return Post(self, text, url=url, images=pic, nickname=target_name) class Bilibililive(StatusChange): @@ -206,6 +206,7 @@ class Bilibililive(StatusChange): name = "Bilibili直播" has_target = True use_batch = True + default_theme = "brief" @unique class LiveStatus(Enum): @@ -334,11 +335,12 @@ class Bilibililive(StatusChange): title = f"[{self.categories[raw_post.category].rstrip('提醒')}] {raw_post.title}" target_name = f"{raw_post.uname} {raw_post.area_name}" return Post( - self.name, - text=title, + self, + "", + title=title, url=url, - pics=list(pic), - target_name=target_name, + images=list(pic), + nickname=target_name, compress=True, ) @@ -353,6 +355,7 @@ class BilibiliBangumi(StatusChange): name = "Bilibili剧集" has_target = True parse_target_promot = "请输入剧集主页" + default_theme = "brief" _url = "https://api.bilibili.com/pgc/review/user" @@ -384,7 +387,7 @@ class BilibiliBangumi(StatusChange): if res_dict["code"] == 0: return { "index": res_dict["result"]["media"]["new_ep"]["index"], - "index_show": res_dict["result"]["media"]["new_ep"]["index"], + "index_show": res_dict["result"]["media"]["new_ep"]["index_show"], "season_id": res_dict["result"]["media"]["season_id"], } else: @@ -412,13 +415,15 @@ class BilibiliBangumi(StatusChange): url = lastest_episode["link"] pic: list[str] = [lastest_episode["cover"]] target_name = detail_dict["result"]["season_title"] - text = lastest_episode["share_copy"] + content = raw_post["index_show"] + title = lastest_episode["share_copy"] return Post( - self.name, - text=text, + self, + content, + title=title, url=url, - pics=list(pic), - target_name=target_name, + images=list(pic), + nickname=target_name, compress=True, ) diff --git a/nonebot_bison/platform/ff14.py b/nonebot_bison/platform/ff14.py index c7af6d4..e050aae 100644 --- a/nonebot_bison/platform/ff14.py +++ b/nonebot_bison/platform/ff14.py @@ -40,6 +40,7 @@ class FF14(NewMessage): return None async def parse(self, raw_post: RawPost) -> Post: - text = f'{raw_post["Title"]}\n{raw_post["Summary"]}' + title = raw_post["Title"] + text = raw_post["Summary"] url = raw_post["Author"] - return Post("ff14", text=text, url=url, target_name="最终幻想XIV官方公告") + return Post(self, text, title=title, url=url, nickname="最终幻想XIV官方公告") diff --git a/nonebot_bison/platform/mcbbsnews.py b/nonebot_bison/platform/mcbbsnews.py index 1784698..b39e6ae 100644 --- a/nonebot_bison/platform/mcbbsnews.py +++ b/nonebot_bison/platform/mcbbsnews.py @@ -155,11 +155,11 @@ class McbbsNews(NewMessage): pics = await self._news_render(post_url, f"#{post_id}") return Post( - self.name, - text="{}\n│\n└由 {} 发表".format(post["title"], post["author"]), + self, + "{}\n│\n└由 {} 发表".format(post["title"], post["author"]), url=post_url, - pics=list(pics), - target_name=post["category"], + images=list(pics), + nickname=post["category"], ) async def _news_render(self, url: str, selector: str) -> list[bytes]: diff --git a/nonebot_bison/platform/ncm.py b/nonebot_bison/platform/ncm.py index 34883f7..031dc93 100644 --- a/nonebot_bison/platform/ncm.py +++ b/nonebot_bison/platform/ncm.py @@ -68,7 +68,7 @@ class NcmArtist(NewMessage): target_name = raw_post["artist"]["name"] pics = [raw_post["picUrl"]] url = "https://music.163.com/#/album?id={}".format(raw_post["id"]) - return Post("ncm-artist", text=text, url=url, pics=pics, target_name=target_name) + return Post(self, text, url=url, images=pics, nickname=target_name) class NcmRadio(NewMessage): @@ -126,4 +126,4 @@ class NcmRadio(NewMessage): target_name = raw_post["radio"]["name"] pics = [raw_post["coverUrl"]] url = "https://music.163.com/#/program/{}".format(raw_post["id"]) - return Post("ncm-radio", text=text, url=url, pics=pics, target_name=target_name) + return Post(self, text, url=url, images=pics, nickname=target_name) diff --git a/nonebot_bison/platform/platform.py b/nonebot_bison/platform/platform.py index f6232ed..0c902c6 100644 --- a/nonebot_bison/platform/platform.py +++ b/nonebot_bison/platform/platform.py @@ -95,6 +95,8 @@ class Platform(metaclass=PlatformABCMeta, base=True): client: AsyncClient reverse_category: dict[str, Category] use_batch: bool = False + # TODO: 限定可使用的theme名称 + default_theme: str = "basic" @classmethod @abstractmethod diff --git a/nonebot_bison/platform/rss.py b/nonebot_bison/platform/rss.py index aa1a9fb..a7af592 100644 --- a/nonebot_bison/platform/rss.py +++ b/nonebot_bison/platform/rss.py @@ -53,28 +53,29 @@ class Rss(NewMessage): entry["_target_name"] = feed.feed.title return feed.entries - def _text_process(self, title: str, desc: str) -> str: + def _text_process(self, title: str, desc: str) -> tuple[str | None, str]: + """检查标题和描述是否相似,如果相似则标题为None, 否则返回标题和描述""" similarity = 1.0 if len(title) == 0 or len(desc) == 0 else text_similarity(title, desc) if similarity > 0.8: - text = title if len(title) > len(desc) else desc - else: - text = title + "\n\n" + desc - return text + return None, title if len(title) > len(desc) else desc + + return title, desc async def parse(self, raw_post: RawPost) -> Post: title = raw_post.get("title", "") soup = bs(raw_post.description, "html.parser") desc = soup.text.strip() - text = self._text_process(title, desc) + title, desc = self._text_process(title, desc) pics = [x.attrs["src"] for x in soup("img")] if raw_post.get("media_content"): for media in raw_post["media_content"]: if media.get("medium") == "image" and media.get("url"): pics.append(media.get("url")) return Post( - "rss", - text=text, + self, + desc, + title=title, url=raw_post.link, - pics=pics, - target_name=raw_post["_target_name"], + images=pics, + nickname=raw_post["_target_name"], ) diff --git a/nonebot_bison/platform/weibo.py b/nonebot_bison/platform/weibo.py index be09d95..3a31111 100644 --- a/nonebot_bison/platform/weibo.py +++ b/nonebot_bison/platform/weibo.py @@ -155,9 +155,9 @@ class Weibo(NewMessage): detail_url = f"https://weibo.com/{info['user']['id']}/{info['bid']}" # return parsed_text, detail_url, pic_urls return Post( - "weibo", - text=parsed_text, + self, + parsed_text, url=detail_url, - pics=pics, - target_name=info["user"]["screen_name"], + images=pics, + nickname=info["user"]["screen_name"], ) diff --git a/nonebot_bison/plugin_config.py b/nonebot_bison/plugin_config.py index 8d52d3b..429069f 100644 --- a/nonebot_bison/plugin_config.py +++ b/nonebot_bison/plugin_config.py @@ -1,12 +1,18 @@ import nonebot -from pydantic import BaseSettings +from pydantic import Field, BaseSettings global_config = nonebot.get_driver().config +PlatformName = str +ThemeName = str class PlugConfig(BaseSettings): bison_config_path: str = "" - bison_use_pic: bool = False + bison_use_pic: bool = Field( + default=False, + description="发送消息时将所有文本转换为图片,防止风控,仅需要推送文转图可以为 platform 指定 theme", + ) + bison_theme_use_browser: bool = Field(default=False, description="是否允许主题使用浏览器") bison_init_filter: bool = True bison_use_queue: bool = True bison_outer_url: str = "" @@ -17,10 +23,12 @@ class PlugConfig(BaseSettings): # 0:不启用;1:首条消息单独发送,剩余照片合并转发;2以及以上:所有消息全部合并转发 bison_resend_times: int = 0 bison_proxy: str | None - bison_ua: str = ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36" + bison_ua: str = Field( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + description="默认UA", ) bison_show_network_warning: bool = True + bison_platform_theme: dict[PlatformName, ThemeName] = {} @property def outer_url(self) -> str: diff --git a/nonebot_bison/post/__init__.py b/nonebot_bison/post/__init__.py index 3900f47..0cc0514 100644 --- a/nonebot_bison/post/__init__.py +++ b/nonebot_bison/post/__init__.py @@ -1,3 +1 @@ -from .post import Post - -__all__ = ["Post"] +from .post import Post as Post diff --git a/nonebot_bison/post/abstract_post.py b/nonebot_bison/post/abstract_post.py index a8d88de..2a76a44 100644 --- a/nonebot_bison/post/abstract_post.py +++ b/nonebot_bison/post/abstract_post.py @@ -1,52 +1,51 @@ -from functools import reduce -from abc import abstractmethod -from dataclasses import field, dataclass +from dataclasses import dataclass +from abc import ABC, abstractmethod -from nonebot_plugin_saa import MessageFactory, MessageSegmentFactory +from nonebot_plugin_saa import Text, MessageFactory, MessageSegmentFactory +from ..utils import text_to_image from ..plugin_config import plugin_config -@dataclass -class BasePost: - @abstractmethod - async def generate_text_messages(self) -> list[MessageSegmentFactory]: - "Generate MessageFactory list from this instance" - ... - - @abstractmethod - async def generate_pic_messages(self) -> list[MessageSegmentFactory]: - "Generate MessageFactory list from this instance with `use_pic`" - ... - - -@dataclass -class OptionalMixin: - # Because of https://stackoverflow.com/questions/51575931/class-inheritance-in-python-3-7-dataclasses - - override_use_pic: bool | None = None +@dataclass(kw_only=True) +class AbstractPost(ABC): compress: bool = False - extra_msg: list[MessageFactory] = field(default_factory=list) + extra_msg: list[MessageFactory] | None = None - def _use_pic(self): - if self.override_use_pic is not None: - return self.override_use_pic - return plugin_config.bison_use_pic + @abstractmethod + async def generate(self) -> list[MessageSegmentFactory]: + "Generate MessageSegmentFactory list from this instance" + ... - -@dataclass -class AbstractPost(OptionalMixin, BasePost): async def generate_messages(self) -> list[MessageFactory]: - if self._use_pic(): - msg_segments = await self.generate_pic_messages() - else: - msg_segments = await self.generate_text_messages() - if msg_segments: - if self.compress: - msgs = [reduce(lambda x, y: x.append(y), msg_segments, MessageFactory([]))] - else: - msgs = [MessageFactory([msg_segment]) for msg_segment in msg_segments] - else: - msgs = [] - msgs.extend(self.extra_msg) + "really call to generate messages" + msg_segments = await self.generate() + msg_segments = await self.message_segments_process(msg_segments) + msgs = await self.message_process(msg_segments) + return msgs + + async def message_segments_process(self, msg_segments: list[MessageSegmentFactory]) -> list[MessageSegmentFactory]: + "generate message segments and process them" + + async def convert(msg: MessageSegmentFactory) -> MessageSegmentFactory: + if isinstance(msg, Text): + return await text_to_image(msg) + else: + return msg + + if plugin_config.bison_use_pic: + return [await convert(msg) for msg in msg_segments] + + return msg_segments + + async def message_process(self, msg_segments: list[MessageSegmentFactory]) -> list[MessageFactory]: + "generate messages and process them" + if self.compress: + msgs = [MessageFactory(msg_segments)] + else: + msgs = [MessageFactory(msg_segment) for msg_segment in msg_segments] + + if self.extra_msg: + msgs.extend(self.extra_msg) + return msgs diff --git a/nonebot_bison/post/custom_post.py b/nonebot_bison/post/custom_post.py deleted file mode 100644 index 04d6163..0000000 --- a/nonebot_bison/post/custom_post.py +++ /dev/null @@ -1,68 +0,0 @@ -from dataclasses import field, dataclass - -from nonebot.log import logger -from nonebot.plugin import require -from nonebot.adapters.onebot.v11 import MessageSegment -from nonebot_plugin_saa import Text, Image, MessageSegmentFactory - -from .abstract_post import BasePost, AbstractPost - - -@dataclass -class _CustomPost(BasePost): - ms_factories: list[MessageSegmentFactory] = field(default_factory=list) - css_path: str = "" # 模板文件所用css路径 - - async def generate_text_messages(self) -> list[MessageSegmentFactory]: - return self.ms_factories - - async def generate_pic_messages(self) -> list[MessageSegmentFactory]: - require("nonebot_plugin_htmlrender") - from nonebot_plugin_htmlrender import md_to_pic - - pic_bytes = await md_to_pic(md=self._generate_md(), css_path=self.css_path) - return [Image(pic_bytes)] - - def _generate_md(self) -> str: - md = "" - - for message_segment in self.ms_factories: - match message_segment: - case Text(data={"text": text}): - md += f"{text}" - case Image(data={"image": image}): - # use onebot v11 to convert image into url - ob11_image = MessageSegment.image(image) - md += "\n".format(ob11_image.data["file"]) - case _: - logger.warning(f"custom_post不支持处理类型:{type(message_segment)}") - continue - - return md - - -@dataclass -class CustomPost(_CustomPost, AbstractPost): - """基于 markdown 语法的,自由度较高的推送内容格式 - - 简介: - 支持处理text/image两种MessageSegmentFactory, - 通过将text/image转换成对应的markdown语法以生成markdown文本。 - 理论上text类型中可以直接使用markdown语法,例如`##第一章`。 - 但会导致不启用`override_use_pic`时, 发送不会被渲染的纯文本消息。 - 图片渲染最终由htmlrender执行。 - - 注意: - 每一个MessageSegmentFactory元素都会被解释为单独的一行 - - 可选参数: - `override_use_pic`:是否覆盖`bison_use_pic`全局配置 - `compress`:将所有消息压缩为一条进行发送 - `extra_msg`:需要附带发送的额外消息 - - 成员函数: - `generate_text_messages()`:负责生成文本消息 - `generate_pic_messages()`:负责生成图片消息 - """ - - pass diff --git a/nonebot_bison/post/post.py b/nonebot_bison/post/post.py index 4c2a239..107c60e 100644 --- a/nonebot_bison/post/post.py +++ b/nonebot_bison/post/post.py @@ -1,151 +1,84 @@ from io import BytesIO -from dataclasses import field, dataclass +from pathlib import Path +from typing import TYPE_CHECKING +from dataclasses import dataclass -from PIL import Image from nonebot.log import logger -import nonebot_plugin_saa as saa from nonebot_plugin_saa import MessageSegmentFactory -from ..utils import parse_text, http_client -from .abstract_post import BasePost, AbstractPost +from ..theme import theme_manager +from .abstract_post import AbstractPost +from ..plugin_config import plugin_config +from ..theme.types import ThemeRenderError, ThemeRenderUnsupportError + +if TYPE_CHECKING: + from ..platform import Platform @dataclass -class _Post(BasePost): - target_type: str - text: str +class Post(AbstractPost): + """最通用的Post,理论上包含所有常用的数据 + + 对于更特殊的需要,可以考虑另外实现一个Post + """ + + platform: "Platform" + """来源平台""" + content: str + """文本内容""" + title: str | None = None + """标题""" + images: list[str | bytes | Path | BytesIO] | None = None + """图片列表""" + timestamp: int | None = None + """发布/获取时间戳""" url: str | None = None - target_name: str | None = None - pics: list[str | bytes] = field(default_factory=list) + """来源链接""" + avatar: str | bytes | Path | BytesIO | None = None + """发布者头像""" + nickname: str | None = None + """发布者昵称""" + description: str | None = None + """发布者个性签名等""" + repost: "Post | None" = None + """转发的Post""" - _message: list[MessageSegmentFactory] | None = None - _pic_message: list[MessageSegmentFactory] | None = None + def get_config_theme(self) -> str | None: + """获取用户指定的theme""" + return plugin_config.bison_platform_theme.get(self.platform.platform_name) - async def _pic_url_to_image(self, data: str | bytes) -> Image.Image: - pic_buffer = BytesIO() - if isinstance(data, str): - async with http_client() as client: - res = await client.get(data) - pic_buffer.write(res.content) + def get_priority_themes(self) -> list[str]: + """获取渲染所使用的theme名列表,按照优先级排序""" + themes_by_priority: list[str] = [] + # 最先使用用户指定的theme + if user_theme := self.get_config_theme(): + themes_by_priority.append(user_theme) + # 然后使用平台默认的theme + if self.platform.default_theme not in themes_by_priority: + themes_by_priority.append(self.platform.default_theme) + # 最后使用最基础的theme + if "basic" not in themes_by_priority: + themes_by_priority.append("basic") + return themes_by_priority + + async def generate(self) -> list[MessageSegmentFactory]: + """生成消息""" + themes = self.get_priority_themes() + for theme_name in themes: + if theme := theme_manager[theme_name]: + try: + logger.debug(f"Try to render Post with theme {theme_name}") + return await theme.do_render(self) + except ThemeRenderUnsupportError as e: + logger.warning( + f"Theme {theme_name} does not support Post of {self.platform.__class__.__name__}: {e}" + ) + continue + except ThemeRenderError as e: + logger.exception(f"Theme {theme_name} render error: {e}") + continue + else: + logger.error(f"Theme {theme_name} not found") + continue else: - pic_buffer.write(data) - return Image.open(pic_buffer) - - def _check_image_square(self, size: tuple[int, int]) -> bool: - return abs(size[0] - size[1]) / size[0] < 0.05 - - async def _pic_merge(self) -> None: - if len(self.pics) < 3: - return - first_image = await self._pic_url_to_image(self.pics[0]) - if not self._check_image_square(first_image.size): - return - images: list[Image.Image] = [first_image] - # first row - for i in range(1, 3): - cur_img = await self._pic_url_to_image(self.pics[i]) - if not self._check_image_square(cur_img.size): - return - if cur_img.size[1] != images[0].size[1]: # height not equal - return - images.append(cur_img) - _tmp = 0 - x_coord = [0] - for i in range(3): - _tmp += images[i].size[0] - x_coord.append(_tmp) - y_coord = [0, first_image.size[1]] - - async def process_row(row: int) -> bool: - if len(self.pics) < (row + 1) * 3: - return False - row_first_img = await self._pic_url_to_image(self.pics[row * 3]) - if not self._check_image_square(row_first_img.size): - return False - if row_first_img.size[0] != images[0].size[0]: - return False - image_row: list[Image.Image] = [row_first_img] - for i in range(row * 3 + 1, row * 3 + 3): - cur_img = await self._pic_url_to_image(self.pics[i]) - if not self._check_image_square(cur_img.size): - return False - if cur_img.size[1] != row_first_img.size[1]: - return False - if cur_img.size[0] != images[i % 3].size[0]: - return False - image_row.append(cur_img) - images.extend(image_row) - y_coord.append(y_coord[-1] + row_first_img.size[1]) - return True - - if await process_row(1): - matrix = (3, 2) - else: - matrix = (3, 1) - if await process_row(2): - matrix = (3, 3) - logger.info("trigger merge image") - target = Image.new("RGB", (x_coord[-1], y_coord[-1])) - for y in range(matrix[1]): - for x in range(matrix[0]): - target.paste( - images[y * matrix[0] + x], - (x_coord[x], y_coord[y], x_coord[x + 1], y_coord[y + 1]), - ) - target_io = BytesIO() - target.save(target_io, "JPEG") - self.pics = self.pics[matrix[0] * matrix[1] :] - self.pics.insert(0, target_io.getvalue()) - - async def generate_text_messages(self) -> list[MessageSegmentFactory]: - if self._message is None: - await self._pic_merge() - msg_segments: list[MessageSegmentFactory] = [] - text = "" - if self.text: - text += "{}".format(self.text if len(self.text) < 500 else self.text[:500] + "...") - if text: - text += "\n" - text += f"来源: {self.target_type}" - if self.target_name: - text += f" {self.target_name}" - if self.url: - text += f" \n详情: {self.url}" - msg_segments.append(saa.Text(text)) - for pic in self.pics: - msg_segments.append(saa.Image(pic)) - self._message = msg_segments - return self._message - - async def generate_pic_messages(self) -> list[MessageSegmentFactory]: - if self._pic_message is None: - await self._pic_merge() - msg_segments: list[MessageSegmentFactory] = [] - text = "" - if self.text: - text += f"{self.text}" - text += "\n" - text += f"来源: {self.target_type}" - if self.target_name: - text += f" {self.target_name}" - msg_segments.append(await parse_text(text)) - if not self.target_type == "rss" and self.url: - msg_segments.append(saa.Text(self.url)) - for pic in self.pics: - msg_segments.append(saa.Image(pic)) - self._pic_message = msg_segments - return self._pic_message - - def __str__(self): - return "type: {}\nfrom: {}\ntext: {}\nurl: {}\npic: {}".format( - self.target_type, - self.target_name, - self.text if len(self.text) < 500 else self.text[:500] + "...", - self.url, - ", ".join("b64img" if isinstance(x, bytes) or x.startswith("base64") else x for x in self.pics), - ) - - -@dataclass -class Post(AbstractPost, _Post): - pass + raise ThemeRenderError(f"No theme can render Post of {self.platform.__class__.__name__}") diff --git a/nonebot_bison/post/templates/custom_post.css b/nonebot_bison/post/templates/custom_post.css deleted file mode 100644 index cc761bc..0000000 --- a/nonebot_bison/post/templates/custom_post.css +++ /dev/null @@ -1,112 +0,0 @@ -@charset "utf-8"; - -/** - * markdown.css - * - * This program is free software: you can redistribute it and/or modify it under - * the terms of the GNU Lesser General Public License as published by the Free - * Software Foundation, either version 3 of the License, or (at your option) any - * later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more - * details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see http://gnu.org/licenses/lgpl.txt. - * - * @project Weblog and Open Source Projects of Florian Wolters - * @version GIT: $Id$ - * @package xhtml-css - * @author Florian Wolters - * @copyright 2012 Florian Wolters - * @cssdoc version 1.0-pre - * @license http://gnu.org/licenses/lgpl.txt GNU Lesser General Public License - * @link http://github.com/FlorianWolters/jekyll-bootstrap-theme - * @media all - * @valid true - */ - -body { - font-family: Helvetica, Arial, Freesans, clean, sans-serif; - padding: 1em; - margin: auto; - max-width: 42em; - background: #fefefe; -} - -h1, h2, h3, h4, h5, h6 { - font-weight: bold; -} - -h1 { - color: #000000; - font-size: 28px; -} - -h2 { - border-bottom: 1px solid #CCCCCC; - color: #000000; - font-size: 24px; -} - -h3 { - font-size: 18px; -} - -h4 { - font-size: 16px; -} - -h5 { - font-size: 14px; -} - -h6 { - color: #777777; - background-color: inherit; - font-size: 14px; -} - -hr { - height: 0.2em; - border: 0; - color: #CCCCCC; - background-color: #CCCCCC; -} - -p, blockquote, ul, ol, dl, li, table, pre { - margin: 15px 0; -} - -code, pre { - border-radius: 3px; - background-color: #F8F8F8; - color: inherit; -} - -code { - border: 1px solid #EAEAEA; - margin: 0 2px; - padding: 0 5px; -} - -pre { - border: 1px solid #CCCCCC; - line-height: 1.25em; - overflow: auto; - padding: 6px 10px; -} - -pre>code { - border: 0; - margin: 0; - padding: 0; -} - -a, a:visited { - color: #4183C4; - background-color: inherit; - text-decoration: none; -} \ No newline at end of file diff --git a/nonebot_bison/theme/__init__.py b/nonebot_bison/theme/__init__.py new file mode 100644 index 0000000..fef5cb1 --- /dev/null +++ b/nonebot_bison/theme/__init__.py @@ -0,0 +1,22 @@ +from pathlib import Path +from pkgutil import iter_modules +from importlib import import_module + +from .types import Theme +from .registry import theme_manager +from .types import ThemeRegistrationError +from .types import ThemeRenderError as ThemeRenderError +from .types import ThemeRenderUnsupportError as ThemeRenderUnsupportError + +_theme_dir = str((Path(__file__).parent / "themes").resolve()) + +for _, theme, _ in iter_modules([_theme_dir]): + theme_module = import_module(f"{__name__}.themes.{theme}") + + if not hasattr(theme_module, "__theme_meta__"): + raise ThemeRegistrationError(f"{theme} has no __theme_meta__") + + if not isinstance(theme_module.__theme_meta__, Theme): + raise ThemeRegistrationError(f"{theme}'s __theme_meta__ is not a AbstractTheme instance") + + theme_manager.register(theme_module.__theme_meta__) diff --git a/nonebot_bison/theme/registry.py b/nonebot_bison/theme/registry.py new file mode 100644 index 0000000..009d432 --- /dev/null +++ b/nonebot_bison/theme/registry.py @@ -0,0 +1,36 @@ +from nonebot import logger + +from ..plugin_config import plugin_config +from .types import Theme, ThemeRegistrationError + + +class ThemeManager: + __themes: dict[str, Theme] = {} + + def register(self, theme: Theme): + logger.trace(f"Registering theme: {theme}") + if theme.name in self.__themes: + raise ThemeRegistrationError(f"Theme {theme.name} duplicated registration") + if theme.need_browser and not plugin_config.bison_theme_use_browser: + logger.opt(colors=True).warning(f"Theme {theme.name} requires browser, but not allowed") + self.__themes[theme.name] = theme + logger.opt(colors=True).success(f"Theme {theme.name} registered") + + def unregister(self, theme_name: str): + logger.trace(f"Unregistering theme: {theme_name}") + if theme_name not in self.__themes: + raise ThemeRegistrationError(f"Theme {theme_name} was not registered") + self.__themes.pop(theme_name) + logger.opt(colors=True).success(f"Theme {theme_name} unregistered") + + def __getitem__(self, theme: str): + return self.__themes[theme] + + def __len__(self): + return len(self.__themes) + + def __contains__(self, theme: str): + return theme in self.__themes + + +theme_manager = ThemeManager() diff --git a/nonebot_bison/theme/themes/arknights/__init__.py b/nonebot_bison/theme/themes/arknights/__init__.py new file mode 100644 index 0000000..ebbd30b --- /dev/null +++ b/nonebot_bison/theme/themes/arknights/__init__.py @@ -0,0 +1,3 @@ +from .build import ArknightsTheme + +__theme_meta__ = ArknightsTheme() diff --git a/nonebot_bison/theme/themes/arknights/build.py b/nonebot_bison/theme/themes/arknights/build.py new file mode 100644 index 0000000..f126859 --- /dev/null +++ b/nonebot_bison/theme/themes/arknights/build.py @@ -0,0 +1,69 @@ +from pathlib import Path +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from nonebot_plugin_saa import Text, Image, MessageSegmentFactory + +from nonebot_bison.theme import Theme, ThemeRenderError, ThemeRenderUnsupportError + +if TYPE_CHECKING: + from nonebot_bison.post import Post + + +@dataclass +class ArkData: + announce_title: str + content: str + banner_image_url: str | Path | None + + +class ArknightsTheme(Theme): + """Arknights 公告风格主题 + + 需要安装`nonebot_plugin_htmlrender`插件 + """ + + name: Literal["arknights"] = "arknights" + need_browser: bool = True + + template_path: Path = Path(__file__).parent / "templates" + template_name: str = "announce.html.jinja" + + async def render(self, post: "Post"): + from nonebot_plugin_htmlrender import template_to_pic + + if not post.title: + raise ThemeRenderUnsupportError("标题为空") + if post.images and len(post.images) > 1: + raise ThemeRenderUnsupportError("图片数量大于1") + + banner = post.images[0] if post.images else None + + if banner is not None and not isinstance(banner, str | Path): + raise ThemeRenderUnsupportError(f"图片类型错误, 期望 str 或 Path, 实际为 {type(banner)}") + + ark_data = ArkData( + announce_title=post.title, + content=post.content, + banner_image_url=banner, + ) + + try: + announce_pic = await template_to_pic( + template_path=self.template_path.as_posix(), + template_name=self.template_name, + templates={ + "data": ark_data, + }, + pages={ + "viewport": {"width": 600, "height": 100}, + "base_url": self.template_path.as_uri(), + }, + ) + except Exception as e: + raise ThemeRenderError(f"渲染文本失败: {e}") + msgs: list[MessageSegmentFactory] = [] + msgs.append(Image(announce_pic)) + if post.url: + msgs.append(Text(f"前往:{post.url}")) + return [Image(announce_pic)] diff --git a/nonebot_bison/post/templates/ark_announce/index.html b/nonebot_bison/theme/themes/arknights/templates/announce.html.jinja similarity index 73% rename from nonebot_bison/post/templates/ark_announce/index.html rename to nonebot_bison/theme/themes/arknights/templates/announce.html.jinja index d85ebc2..0e71394 100644 --- a/nonebot_bison/post/templates/ark_announce/index.html +++ b/nonebot_bison/theme/themes/arknights/templates/announce.html.jinja @@ -15,15 +15,15 @@ - {% if bannerImageUrl %} + {% if data.banner_image_url %} - + {% endif %} - {{ announce_title }} + {{ data.announce_title }} - {{ content }} + {{ data.content }} diff --git a/nonebot_bison/post/templates/ark_announce/style.css b/nonebot_bison/theme/themes/arknights/templates/style.css similarity index 100% rename from nonebot_bison/post/templates/ark_announce/style.css rename to nonebot_bison/theme/themes/arknights/templates/style.css diff --git a/nonebot_bison/theme/themes/basic/__init__.py b/nonebot_bison/theme/themes/basic/__init__.py new file mode 100644 index 0000000..e54ff7d --- /dev/null +++ b/nonebot_bison/theme/themes/basic/__init__.py @@ -0,0 +1,3 @@ +from .build import BasicTheme + +__theme_meta__ = BasicTheme() diff --git a/nonebot_bison/theme/themes/basic/build.py b/nonebot_bison/theme/themes/basic/build.py new file mode 100644 index 0000000..6e045e7 --- /dev/null +++ b/nonebot_bison/theme/themes/basic/build.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING, Literal + +from nonebot_plugin_saa import Text, Image, MessageSegmentFactory + +from nonebot_bison.theme import Theme +from nonebot_bison.utils import pic_merge, is_pics_mergable + +if TYPE_CHECKING: + from nonebot_bison.post import Post + + +class BasicTheme(Theme): + """最基本的主题 + + 纯文本,应为每个Post必定支持的Theme + """ + + name: Literal["basic"] = "basic" + + async def render(self, post: "Post") -> list[MessageSegmentFactory]: + text = "" + + if post.title: + text += f"{post.title}\n\n" + + text += post.content if len(post.content) < 500 else f"{post.content[:500]}..." + + text += f"\n来源: {post.platform.name} {post.nickname or ''}\n" + + if post.url: + text += f"详情: {post.url}" + + msgs: list[MessageSegmentFactory] = [Text(text)] + if post.images: + pics = post.images + if is_pics_mergable(pics): + pics = await pic_merge(list(pics), post.platform.client) + msgs.extend(map(Image, pics)) + + return msgs diff --git a/nonebot_bison/theme/themes/brief/__init__.py b/nonebot_bison/theme/themes/brief/__init__.py new file mode 100644 index 0000000..01633e2 --- /dev/null +++ b/nonebot_bison/theme/themes/brief/__init__.py @@ -0,0 +1,3 @@ +from .build import BriefTheme + +__theme_meta__ = BriefTheme() diff --git a/nonebot_bison/theme/themes/brief/build.py b/nonebot_bison/theme/themes/brief/build.py new file mode 100644 index 0000000..8e876ab --- /dev/null +++ b/nonebot_bison/theme/themes/brief/build.py @@ -0,0 +1,32 @@ +from typing import TYPE_CHECKING, Literal + +from nonebot_plugin_saa import Text, Image, MessageSegmentFactory + +from nonebot_bison.utils import pic_merge, is_pics_mergable +from nonebot_bison.theme import Theme, ThemeRenderUnsupportError + +if TYPE_CHECKING: + from nonebot_bison.post import Post + + +class BriefTheme(Theme): + """简报主题,只发送标题、头图(如果有)、URL(如果有)""" + + name: Literal["brief"] = "brief" + + async def render(self, post: "Post") -> list[MessageSegmentFactory]: + if not post.title: + raise ThemeRenderUnsupportError("Post has no title") + text = f"{post.title}\n\n" + text += f"来源: {post.platform.name} {post.nickname or ''}\n" + if post.url: + text += f"详情: {post.url}" + + msgs: list[MessageSegmentFactory] = [Text(text)] + if post.images: + pics = post.images + if is_pics_mergable(pics): + pics = await pic_merge(list(pics), post.platform.client) + msgs.append(Image(pics[0])) + + return msgs diff --git a/nonebot_bison/theme/themes/ceobe_canteen/README.md b/nonebot_bison/theme/themes/ceobe_canteen/README.md new file mode 100644 index 0000000..d40fb1a --- /dev/null +++ b/nonebot_bison/theme/themes/ceobe_canteen/README.md @@ -0,0 +1,10 @@ +# Jinja模版与LOGO图片说明 + +## LOGO图片 + +- `templates/ceobecanteen_logo.png` + +### 版权声明 + +logo图片采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。 +本项目使用已经过 [Ceobe Canteen](https://github.com/Enraged-Dun-Cookie-Development-Team) 授权许可使用。 diff --git a/nonebot_bison/theme/themes/ceobe_canteen/__init__.py b/nonebot_bison/theme/themes/ceobe_canteen/__init__.py new file mode 100644 index 0000000..dfd5f63 --- /dev/null +++ b/nonebot_bison/theme/themes/ceobe_canteen/__init__.py @@ -0,0 +1,3 @@ +from .build import CeobeCanteenTheme + +__theme_meta__ = CeobeCanteenTheme() diff --git a/nonebot_bison/theme/themes/ceobe_canteen/build.py b/nonebot_bison/theme/themes/ceobe_canteen/build.py new file mode 100644 index 0000000..2dfdf59 --- /dev/null +++ b/nonebot_bison/theme/themes/ceobe_canteen/build.py @@ -0,0 +1,113 @@ +from pathlib import Path +from datetime import datetime +from typing import TYPE_CHECKING, Literal + +import jinja2 +from pydantic import BaseModel, root_validator +from nonebot_plugin_saa import Text, Image, MessageSegmentFactory + +from nonebot_bison.theme.utils import convert_to_qr +from nonebot_bison.theme import Theme, ThemeRenderError, ThemeRenderUnsupportError + +if TYPE_CHECKING: + from nonebot_bison.post import Post + + +class CeobeInfo(BaseModel): + """卡片的信息部分 + + datasource: 数据来源 + + time: 时间 + """ + + datasource: str + time: str + + +class CeoboContent(BaseModel): + """卡片的内容部分 + + image: 图片链接 + text: 文字内容 + """ + + image: str | None + text: str | None + + @root_validator + def check(cls, values): + if values["image"] is None and values["text"] is None: + raise ValueError("image and text cannot be both None") + return values + + +class CeobeCard(BaseModel): + info: CeobeInfo + content: CeoboContent + qr: str | None + + +class CeobeCanteenTheme(Theme): + """小刻食堂 分享卡片风格主题 + + 需要安装`nonebot_plugin_htmlrender`插件 + """ + + name: Literal["ceobecanteen"] = "ceobecanteen" + need_browser: bool = True + + template_path: Path = Path(__file__).parent / "templates" + template_name: str = "ceobe_canteen.html.jinja" + + def parse(self, post: "Post") -> CeobeCard: + """解析 Post 为 CeobeCard""" + if not post.nickname: + raise ThemeRenderUnsupportError("post.nickname is None") + if not post.timestamp: + raise ThemeRenderUnsupportError("post.timestamp is None") + info = CeobeInfo( + datasource=post.nickname, time=datetime.fromtimestamp(post.timestamp).strftime("%Y-%m-%d %H:%M:%S") + ) + + head_pic = post.images[0] if post.images else None + if head_pic is not None and not isinstance(head_pic, str): + raise ThemeRenderUnsupportError("post.images[0] is not str") + + content = CeoboContent(image=head_pic, text=post.content) + return CeobeCard(info=info, content=content, qr=convert_to_qr(post.url or "No URL")) + + async def render(self, post: "Post") -> list[MessageSegmentFactory]: + ceobe_card = self.parse(post) + from nonebot_plugin_htmlrender import get_new_page + + template_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(self.template_path), + enable_async=True, + ) + template = template_env.get_template(self.template_name) + html = await template.render_async(card=ceobe_card) + pages = { + "viewport": {"width": 1000, "height": 3000}, + "base_url": self.template_path.as_uri(), + } + try: + async with get_new_page(**pages) as page: + await page.goto(self.template_path.as_uri()) + await page.set_content(html) + await page.wait_for_timeout(1) + img_raw = await page.locator("#ceobecanteen-card").screenshot( + type="png", + ) + except Exception as e: + raise ThemeRenderError(f"Render error: {e}") from e + msgs: list[MessageSegmentFactory] = [Image(img_raw)] + + text = f"来源: {post.platform.name} {post.nickname or ''}\n" + if post.url: + text += f"详情: {post.url}" + msgs.append(Text(text)) + + if post.images: + msgs.extend(map(Image, post.images)) + return msgs diff --git a/nonebot_bison/theme/themes/ceobe_canteen/templates/bison_logo.jpg b/nonebot_bison/theme/themes/ceobe_canteen/templates/bison_logo.jpg new file mode 100644 index 0000000..9e95800 Binary files /dev/null and b/nonebot_bison/theme/themes/ceobe_canteen/templates/bison_logo.jpg differ diff --git a/nonebot_bison/theme/themes/ceobe_canteen/templates/ceobe_canteen.html.jinja b/nonebot_bison/theme/themes/ceobe_canteen/templates/ceobe_canteen.html.jinja new file mode 100644 index 0000000..baba226 --- /dev/null +++ b/nonebot_bison/theme/themes/ceobe_canteen/templates/ceobe_canteen.html.jinja @@ -0,0 +1,98 @@ + + + + +小刻食堂分享卡片 + + + + {% if card.content.image %} + + {% endif %} + {% if card.content.text %} + {{ card.content.text }} + {% endif %} + + + + + + diff --git a/nonebot_bison/theme/themes/ceobe_canteen/templates/ceobecanteen_logo.png b/nonebot_bison/theme/themes/ceobe_canteen/templates/ceobecanteen_logo.png new file mode 100644 index 0000000..57282a7 Binary files /dev/null and b/nonebot_bison/theme/themes/ceobe_canteen/templates/ceobecanteen_logo.png differ diff --git a/nonebot_bison/theme/themes/ht2i/__init__.py b/nonebot_bison/theme/themes/ht2i/__init__.py new file mode 100644 index 0000000..6fa3ed7 --- /dev/null +++ b/nonebot_bison/theme/themes/ht2i/__init__.py @@ -0,0 +1,3 @@ +from .build import Ht2iTheme + +__theme_meta__ = Ht2iTheme() diff --git a/nonebot_bison/theme/themes/ht2i/build.py b/nonebot_bison/theme/themes/ht2i/build.py new file mode 100644 index 0000000..3ceb946 --- /dev/null +++ b/nonebot_bison/theme/themes/ht2i/build.py @@ -0,0 +1,50 @@ +from typing import TYPE_CHECKING, Literal + +from nonebot_plugin_saa import Text, Image, MessageSegmentFactory + +from nonebot_bison.theme import Theme, ThemeRenderError +from nonebot_bison.utils import pic_merge, is_pics_mergable + +if TYPE_CHECKING: + from nonebot_bison.post import Post + + +class Ht2iTheme(Theme): + """使用浏览器将文本渲染为图片 + + HTML render Text To Image. + 需要安装`nonebot_plugin_htmlrender`插件 + """ + + name: Literal["ht2i"] = "ht2i" + need_browser: bool = True + + async def _text_render(self, text: str): + from nonebot_plugin_htmlrender import text_to_pic + + try: + return Image(await text_to_pic(text)) + except Exception as e: + raise ThemeRenderError(f"渲染文本失败: {e}") + + async def render(self, post: "Post"): + _text = "" + + if post.title: + _text += f"{post.title}\n\n" + + _text += post.content if len(post.content) < 500 else f"{post.content[:500]}..." + + _text += f"\n来源: {post.platform.name} {post.nickname or ''}\n" + + msgs: list[MessageSegmentFactory] = [await self._text_render(_text)] + + if post.url: + msgs.append(Text(f"详情: {post.url}")) + if post.images: + pics = post.images + if is_pics_mergable(pics): + pics = await pic_merge(list(pics), post.platform.client) + msgs.extend(map(Image, pics)) + + return msgs diff --git a/nonebot_bison/theme/types.py b/nonebot_bison/theme/types.py new file mode 100644 index 0000000..4b15727 --- /dev/null +++ b/nonebot_bison/theme/types.py @@ -0,0 +1,78 @@ +from typing import TYPE_CHECKING +from abc import ABC, abstractmethod + +from nonebot import logger, require +from pydantic import BaseModel, PrivateAttr +from nonebot_plugin_saa import MessageSegmentFactory + +from ..plugin_config import plugin_config + +if TYPE_CHECKING: + from ..post.abstract_post import AbstractPost + + +class Theme(ABC, BaseModel): + """theme基类""" + + name: str + """theme名称""" + need_browser: bool = False + """是否需要使用浏览器""" + + _browser_checked: bool = PrivateAttr(default=False) + + async def is_support_render(self, post: "AbstractPost") -> bool: + """是否支持渲染该类型的Post""" + if self.need_browser and not plugin_config.bison_theme_use_browser: + logger.warning(f"Theme {self.name} need browser, but `bison_theme_use_browser` is False") + return False + return True + + async def prepare(self): + if self.need_browser: + self.check_htmlrender_plugin_enable() + + async def do_render(self, post: "AbstractPost") -> list[MessageSegmentFactory]: + """真正调用的渲染函数,会对渲染过程进行一些处理""" + if not await self.is_support_render(post): + raise ThemeRenderUnsupportError(f"Theme [{self.name}] does not support render {post} by support check") + + await self.prepare() + + return await self.render(post) + + def check_htmlrender_plugin_enable(self): + """根据`need_browser`检测渲染插件""" + if self._browser_checked: + return + try: + require("nonebot_plugin_htmlrender") + self._browser_checked = True + except RuntimeError as e: + if "Cannot load plugin" in str(e): + raise ThemeRenderUnsupportError("需要安装`nonebot_plugin_htmlrender`插件") + else: + raise e + + @abstractmethod + async def render(self, post: "AbstractPost") -> list[MessageSegmentFactory]: + """对多种Post的实例可以考虑使用@overload""" + ... + + +class ThemeRegistrationError(Exception): + """Theme注册错误""" + + pass + + +class ThemeRenderUnsupportError(Exception): + """Theme不支持渲染该类型的Post""" + + pass + + +class ThemeRenderError(Exception): + """Theme渲染错误""" + + pass diff --git a/nonebot_bison/theme/utils.py b/nonebot_bison/theme/utils.py new file mode 100644 index 0000000..2e5d4fc --- /dev/null +++ b/nonebot_bison/theme/utils.py @@ -0,0 +1,22 @@ +from qrcode import constants +from qrcode.main import QRCode +from qrcode.image.svg import SvgFragmentImage + + +def convert_to_qr(data: str) -> str: + """Convert data to QR code + Args: + data (str): data to be converted + Returns: + bytes: QR code image + """ + qr = QRCode( + version=1, + error_correction=constants.ERROR_CORRECT_L, + box_size=10, + border=2, + image_factory=SvgFragmentImage, + ) + qr.add_data(data) + qr.make(fit=True) + return qr.make_image().to_string().decode("utf-8") diff --git a/nonebot_bison/utils/__init__.py b/nonebot_bison/utils/__init__.py index 4534bd4..29da939 100644 --- a/nonebot_bison/utils/__init__.py +++ b/nonebot_bison/utils/__init__.py @@ -12,6 +12,7 @@ from .http import http_client from .context import ProcessContext from ..plugin_config import plugin_config from .scheduler_config import SchedulerConfig, scheduler +from .image import pic_merge, text_to_image, is_pics_mergable, pic_url_to_image __all__ = [ "http_client", @@ -21,6 +22,10 @@ __all__ = [ "html_to_text", "SchedulerConfig", "scheduler", + "pic_merge", + "pic_url_to_image", + "is_pics_mergable", + "text_to_image", ] diff --git a/nonebot_bison/utils/image.py b/nonebot_bison/utils/image.py new file mode 100644 index 0000000..ec805fd --- /dev/null +++ b/nonebot_bison/utils/image.py @@ -0,0 +1,110 @@ +from io import BytesIO +from typing import TypeGuard +from functools import partial + +from PIL import Image +from httpx import AsyncClient +from nonebot import logger, require +from PIL.Image import Image as PILImage +from nonebot_plugin_saa import Text as SaaText +from nonebot_plugin_saa import Image as SaaImage + +from ..plugin_config import plugin_config + + +async def pic_url_to_image(data: str | bytes, http_client: AsyncClient) -> PILImage: + pic_buffer = BytesIO() + if isinstance(data, str): + res = await http_client.get(data) + pic_buffer.write(res.content) + else: + pic_buffer.write(data) + return Image.open(pic_buffer) + + +def _check_image_square(size: tuple[int, int]) -> bool: + return abs(size[0] - size[1]) / size[0] < 0.05 + + +async def pic_merge(pics: list[str | bytes], http_client: AsyncClient) -> list[str | bytes]: + if len(pics) < 3: + return pics + + _pic_url_to_image = partial(pic_url_to_image, http_client=http_client) + + first_image = await _pic_url_to_image(pics[0]) + if not _check_image_square(first_image.size): + return pics + images: list[PILImage] = [first_image] + # first row + for i in range(1, 3): + cur_img = await _pic_url_to_image(pics[i]) + if not _check_image_square(cur_img.size): + return pics + if cur_img.size[1] != images[0].size[1]: # height not equal + return pics + images.append(cur_img) + _tmp = 0 + x_coord = [0] + for i in range(3): + _tmp += images[i].size[0] + x_coord.append(_tmp) + y_coord = [0, first_image.size[1]] + + async def process_row(row: int) -> bool: + if len(pics) < (row + 1) * 3: + return False + row_first_img = await _pic_url_to_image(pics[row * 3]) + if not _check_image_square(row_first_img.size): + return False + if row_first_img.size[0] != images[0].size[0]: + return False + image_row: list[PILImage] = [row_first_img] + for i in range(row * 3 + 1, row * 3 + 3): + cur_img = await _pic_url_to_image(pics[i]) + if not _check_image_square(cur_img.size): + return False + if cur_img.size[1] != row_first_img.size[1]: + return False + if cur_img.size[0] != images[i % 3].size[0]: + return False + image_row.append(cur_img) + images.extend(image_row) + y_coord.append(y_coord[-1] + row_first_img.size[1]) + return True + + if await process_row(1): + matrix = (3, 2) + else: + matrix = (3, 1) + if await process_row(2): + matrix = (3, 3) + logger.info("trigger merge image") + target = Image.new("RGB", (x_coord[-1], y_coord[-1])) + for y in range(matrix[1]): + for x in range(matrix[0]): + target.paste( + images[y * matrix[0] + x], + (x_coord[x], y_coord[y], x_coord[x + 1], y_coord[y + 1]), + ) + target_io = BytesIO() + target.save(target_io, "JPEG") + pics = pics[matrix[0] * matrix[1] :] + pics.insert(0, target_io.getvalue()) + + return pics + + +def is_pics_mergable(imgs: list) -> TypeGuard[list[str | bytes]]: + return all(isinstance(img, str | bytes) for img in imgs) + + +async def text_to_image(saa_text: SaaText) -> SaaImage: + """使用 htmlrender 将 saa.Text 渲染为 saa.Image""" + if not plugin_config.bison_use_pic: + raise ValueError("请启用 bison_use_pic") + require("nonebot_plugin_htmlrender") + from nonebot_plugin_htmlrender import text_to_pic + + render_data = await text_to_pic(str(saa_text)) + return SaaImage(render_data) diff --git a/poetry.lock b/poetry.lock index 4bda795..9ae483b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aiodns" @@ -457,33 +457,33 @@ reference = "offical-source" [[package]] name = "black" -version = "24.1.1" +version = "24.2.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"}, - {file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"}, - {file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"}, - {file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"}, - {file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"}, - {file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"}, - {file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"}, - {file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"}, - {file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"}, - {file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"}, - {file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"}, - {file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"}, - {file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"}, - {file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"}, - {file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"}, - {file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"}, - {file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"}, - {file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"}, - {file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"}, - {file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"}, - {file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"}, - {file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"}, + {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"}, + {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"}, + {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"}, + {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"}, + {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"}, + {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"}, + {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"}, + {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"}, + {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"}, + {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"}, + {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"}, + {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"}, + {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"}, + {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"}, + {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"}, + {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"}, + {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"}, + {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"}, + {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"}, + {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"}, + {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"}, + {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"}, ] [package.dependencies] @@ -2202,18 +2202,19 @@ reference = "offical-source" [[package]] name = "nonebot-adapter-onebot" -version = "2.3.1" +version = "2.4.0" description = "OneBot(CQHTTP) adapter for nonebot2" optional = false python-versions = ">=3.8,<4.0" files = [ - {file = "nonebot_adapter_onebot-2.3.1-py3-none-any.whl", hash = "sha256:c4085f1fc1a62e46c737452b9ce3d6eb374812c78a419bb4fa378f48bd8e4088"}, - {file = "nonebot_adapter_onebot-2.3.1.tar.gz", hash = "sha256:10cec3aee454700e6d2144748bd898772db7bd95247d51d3ccd3b31919e24689"}, + {file = "nonebot_adapter_onebot-2.4.0-py3-none-any.whl", hash = "sha256:4a51da1913c8ab6008e8ef8a2877af7acc32eac82d6773ff443f743dda380e14"}, + {file = "nonebot_adapter_onebot-2.4.0.tar.gz", hash = "sha256:27a29de8137ce60f0ea328c6fac63571ba7efef7619d0cc74c0dff42c3034b12"}, ] [package.dependencies] msgpack = ">=1.0.3,<2.0.0" -nonebot2 = ">=2.1.0,<3.0.0" +nonebot2 = ">=2.2.0,<3.0.0" +pydantic = ">=1.10.0,<2.5.0 || >2.5.0,<2.5.1 || >2.5.1,<3.0.0" typing-extensions = ">=4.0.0,<5.0.0" [package.source] @@ -2362,17 +2363,18 @@ reference = "offical-source" [[package]] name = "nonebot-plugin-localstore" -version = "0.5.2" +version = "0.6.0" description = "Local Storage Support for NoneBot2" optional = false python-versions = ">=3.8,<4.0" files = [ - {file = "nonebot_plugin_localstore-0.5.2-py3-none-any.whl", hash = "sha256:6cd3ee2120918e9fd2af572730aa0612f29d69da22c67bf7297bbdc86b489b57"}, - {file = "nonebot_plugin_localstore-0.5.2.tar.gz", hash = "sha256:66ac53f954c7f9c4117c3bc472580c018bc83ec0c08bc12c4fd96ce875f8a1e3"}, + {file = "nonebot_plugin_localstore-0.6.0-py3-none-any.whl", hash = "sha256:59f0126d85680601166a9a62cca886a33e1b0a8fef7cd67fff52747bd47f42d3"}, + {file = "nonebot_plugin_localstore-0.6.0.tar.gz", hash = "sha256:7eb4039cb2e76c54b860b2b98f2b90cd25284919603e81dedec367f215662fcd"}, ] [package.dependencies] -nonebot2 = ">=2.0.0,<3.0.0" +nonebot2 = ">=2.2.0,<3.0.0" +pydantic = ">=1.10.0,<2.5.0 || >2.5.0,<2.5.1 || >2.5.1,<3.0.0" typing-extensions = ">=4.0.0" [package.source] @@ -3136,6 +3138,22 @@ type = "legacy" url = "https://pypi.org/simple" reference = "offical-source" +[[package]] +name = "pypng" +version = "0.20220715.0" +description = "Pure Python library for saving and loading PNG images" +optional = false +python-versions = "*" +files = [ + {file = "pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c"}, + {file = "pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1"}, +] + +[package.source] +type = "legacy" +url = "https://pypi.org/simple" +reference = "offical-source" + [[package]] name = "pytest" version = "7.4.4" @@ -3426,7 +3444,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3466,6 +3483,34 @@ type = "legacy" url = "https://pypi.org/simple" reference = "offical-source" +[[package]] +name = "qrcode" +version = "7.4.2" +description = "QR Code image generator" +optional = false +python-versions = ">=3.7" +files = [ + {file = "qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a"}, + {file = "qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +pypng = "*" +typing-extensions = "*" + +[package.extras] +all = ["pillow (>=9.1.0)", "pytest", "pytest-cov", "tox", "zest.releaser[recommended]"] +dev = ["pytest", "pytest-cov", "tox"] +maintainer = ["zest.releaser[recommended]"] +pil = ["pillow (>=9.1.0)"] +test = ["coverage", "pytest"] + +[package.source] +type = "legacy" +url = "https://pypi.org/simple" +reference = "offical-source" + [[package]] name = "requests" version = "2.31.0" @@ -3567,18 +3612,18 @@ reference = "offical-source" [[package]] name = "setuptools" -version = "69.0.3" +version = "69.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.0.3-py3-none-any.whl", hash = "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05"}, - {file = "setuptools-69.0.3.tar.gz", hash = "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78"}, + {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"}, + {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [package.source] @@ -3673,66 +3718,66 @@ reference = "offical-source" [[package]] name = "sqlalchemy" -version = "2.0.25" +version = "2.0.27" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" files = [ - {file = "SQLAlchemy-2.0.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4344d059265cc8b1b1be351bfb88749294b87a8b2bbe21dfbe066c4199541ebd"}, - {file = "SQLAlchemy-2.0.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6f9e2e59cbcc6ba1488404aad43de005d05ca56e069477b33ff74e91b6319735"}, - {file = "SQLAlchemy-2.0.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84daa0a2055df9ca0f148a64fdde12ac635e30edbca80e87df9b3aaf419e144a"}, - {file = "SQLAlchemy-2.0.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc8b7dabe8e67c4832891a5d322cec6d44ef02f432b4588390017f5cec186a84"}, - {file = "SQLAlchemy-2.0.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f5693145220517b5f42393e07a6898acdfe820e136c98663b971906120549da5"}, - {file = "SQLAlchemy-2.0.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:db854730a25db7c956423bb9fb4bdd1216c839a689bf9cc15fada0a7fb2f4570"}, - {file = "SQLAlchemy-2.0.25-cp310-cp310-win32.whl", hash = "sha256:14a6f68e8fc96e5e8f5647ef6cda6250c780612a573d99e4d881581432ef1669"}, - {file = "SQLAlchemy-2.0.25-cp310-cp310-win_amd64.whl", hash = "sha256:87f6e732bccd7dcf1741c00f1ecf33797383128bd1c90144ac8adc02cbb98643"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:342d365988ba88ada8af320d43df4e0b13a694dbd75951f537b2d5e4cb5cd002"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f37c0caf14b9e9b9e8f6dbc81bc56db06acb4363eba5a633167781a48ef036ed"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa9373708763ef46782d10e950b49d0235bfe58facebd76917d3f5cbf5971aed"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24f571990c05f6b36a396218f251f3e0dda916e0c687ef6fdca5072743208f5"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75432b5b14dc2fff43c50435e248b45c7cdadef73388e5610852b95280ffd0e9"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:884272dcd3ad97f47702965a0e902b540541890f468d24bd1d98bcfe41c3f018"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-win32.whl", hash = "sha256:e607cdd99cbf9bb80391f54446b86e16eea6ad309361942bf88318bcd452363c"}, - {file = "SQLAlchemy-2.0.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d505815ac340568fd03f719446a589162d55c52f08abd77ba8964fbb7eb5b5f"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0dacf67aee53b16f365c589ce72e766efaabd2b145f9de7c917777b575e3659d"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b801154027107461ee992ff4b5c09aa7cc6ec91ddfe50d02bca344918c3265c6"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59a21853f5daeb50412d459cfb13cb82c089ad4c04ec208cd14dddd99fc23b39"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29049e2c299b5ace92cbed0c1610a7a236f3baf4c6b66eb9547c01179f638ec5"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b64b183d610b424a160b0d4d880995e935208fc043d0302dd29fee32d1ee3f95"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4f7a7d7fcc675d3d85fbf3b3828ecd5990b8d61bd6de3f1b260080b3beccf215"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-win32.whl", hash = "sha256:cf18ff7fc9941b8fc23437cc3e68ed4ebeff3599eec6ef5eebf305f3d2e9a7c2"}, - {file = "SQLAlchemy-2.0.25-cp312-cp312-win_amd64.whl", hash = "sha256:91f7d9d1c4dd1f4f6e092874c128c11165eafcf7c963128f79e28f8445de82d5"}, - {file = "SQLAlchemy-2.0.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bb209a73b8307f8fe4fe46f6ad5979649be01607f11af1eb94aa9e8a3aaf77f0"}, - {file = "SQLAlchemy-2.0.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:798f717ae7c806d67145f6ae94dc7c342d3222d3b9a311a784f371a4333212c7"}, - {file = "SQLAlchemy-2.0.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fdd402169aa00df3142149940b3bf9ce7dde075928c1886d9a1df63d4b8de62"}, - {file = "SQLAlchemy-2.0.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0d3cab3076af2e4aa5693f89622bef7fa770c6fec967143e4da7508b3dceb9b9"}, - {file = "SQLAlchemy-2.0.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:74b080c897563f81062b74e44f5a72fa44c2b373741a9ade701d5f789a10ba23"}, - {file = "SQLAlchemy-2.0.25-cp37-cp37m-win32.whl", hash = "sha256:87d91043ea0dc65ee583026cb18e1b458d8ec5fc0a93637126b5fc0bc3ea68c4"}, - {file = "SQLAlchemy-2.0.25-cp37-cp37m-win_amd64.whl", hash = "sha256:75f99202324383d613ddd1f7455ac908dca9c2dd729ec8584c9541dd41822a2c"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:420362338681eec03f53467804541a854617faed7272fe71a1bfdb07336a381e"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c88f0c7dcc5f99bdb34b4fd9b69b93c89f893f454f40219fe923a3a2fd11625"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3be4987e3ee9d9a380b66393b77a4cd6d742480c951a1c56a23c335caca4ce3"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a159111a0f58fb034c93eeba211b4141137ec4b0a6e75789ab7a3ef3c7e7e3"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8b8cb63d3ea63b29074dcd29da4dc6a97ad1349151f2d2949495418fd6e48db9"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:736ea78cd06de6c21ecba7416499e7236a22374561493b456a1f7ffbe3f6cdb4"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-win32.whl", hash = "sha256:10331f129982a19df4284ceac6fe87353ca3ca6b4ca77ff7d697209ae0a5915e"}, - {file = "SQLAlchemy-2.0.25-cp38-cp38-win_amd64.whl", hash = "sha256:c55731c116806836a5d678a70c84cb13f2cedba920212ba7dcad53260997666d"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:605b6b059f4b57b277f75ace81cc5bc6335efcbcc4ccb9066695e515dbdb3900"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:665f0a3954635b5b777a55111ababf44b4fc12b1f3ba0a435b602b6387ffd7cf"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecf6d4cda1f9f6cb0b45803a01ea7f034e2f1aed9475e883410812d9f9e3cfcf"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c51db269513917394faec5e5c00d6f83829742ba62e2ac4fa5c98d58be91662f"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:790f533fa5c8901a62b6fef5811d48980adeb2f51f1290ade8b5e7ba990ba3de"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1b1180cda6df7af84fe72e4530f192231b1f29a7496951db4ff38dac1687202d"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-win32.whl", hash = "sha256:555651adbb503ac7f4cb35834c5e4ae0819aab2cd24857a123370764dc7d7e24"}, - {file = "SQLAlchemy-2.0.25-cp39-cp39-win_amd64.whl", hash = "sha256:dc55990143cbd853a5d038c05e79284baedf3e299661389654551bd02a6a68d7"}, - {file = "SQLAlchemy-2.0.25-py3-none-any.whl", hash = "sha256:a86b4240e67d4753dc3092d9511886795b3c2852abe599cffe108952f7af7ac3"}, - {file = "SQLAlchemy-2.0.25.tar.gz", hash = "sha256:a2c69a7664fb2d54b8682dd774c3b54f67f84fa123cf84dda2a5f40dcaa04e08"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d04e579e911562f1055d26dab1868d3e0bb905db3bccf664ee8ad109f035618a"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa67d821c1fd268a5a87922ef4940442513b4e6c377553506b9db3b83beebbd8"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c7a596d0be71b7baa037f4ac10d5e057d276f65a9a611c46970f012752ebf2d"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:954d9735ee9c3fa74874c830d089a815b7b48df6f6b6e357a74130e478dbd951"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5cd20f58c29bbf2680039ff9f569fa6d21453fbd2fa84dbdb4092f006424c2e6"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:03f448ffb731b48323bda68bcc93152f751436ad6037f18a42b7e16af9e91c07"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-win32.whl", hash = "sha256:d997c5938a08b5e172c30583ba6b8aad657ed9901fc24caf3a7152eeccb2f1b4"}, + {file = "SQLAlchemy-2.0.27-cp310-cp310-win_amd64.whl", hash = "sha256:eb15ef40b833f5b2f19eeae65d65e191f039e71790dd565c2af2a3783f72262f"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c5bad7c60a392850d2f0fee8f355953abaec878c483dd7c3836e0089f046bf6"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3012ab65ea42de1be81fff5fb28d6db893ef978950afc8130ba707179b4284a"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbcd77c4d94b23e0753c5ed8deba8c69f331d4fd83f68bfc9db58bc8983f49cd"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d177b7e82f6dd5e1aebd24d9c3297c70ce09cd1d5d37b43e53f39514379c029c"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:680b9a36029b30cf063698755d277885d4a0eab70a2c7c6e71aab601323cba45"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1306102f6d9e625cebaca3d4c9c8f10588735ef877f0360b5cdb4fdfd3fd7131"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-win32.whl", hash = "sha256:5b78aa9f4f68212248aaf8943d84c0ff0f74efc65a661c2fc68b82d498311fd5"}, + {file = "SQLAlchemy-2.0.27-cp311-cp311-win_amd64.whl", hash = "sha256:15e19a84b84528f52a68143439d0c7a3a69befcd4f50b8ef9b7b69d2628ae7c4"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0de1263aac858f288a80b2071990f02082c51d88335a1db0d589237a3435fe71"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce850db091bf7d2a1f2fdb615220b968aeff3849007b1204bf6e3e50a57b3d32"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dfc936870507da96aebb43e664ae3a71a7b96278382bcfe84d277b88e379b18"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4fbe6a766301f2e8a4519f4500fe74ef0a8509a59e07a4085458f26228cd7cc"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4535c49d961fe9a77392e3a630a626af5baa967172d42732b7a43496c8b28876"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0fb3bffc0ced37e5aa4ac2416f56d6d858f46d4da70c09bb731a246e70bff4d5"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-win32.whl", hash = "sha256:7f470327d06400a0aa7926b375b8e8c3c31d335e0884f509fe272b3c700a7254"}, + {file = "SQLAlchemy-2.0.27-cp312-cp312-win_amd64.whl", hash = "sha256:f9374e270e2553653d710ece397df67db9d19c60d2647bcd35bfc616f1622dcd"}, + {file = "SQLAlchemy-2.0.27-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e97cf143d74a7a5a0f143aa34039b4fecf11343eed66538610debc438685db4a"}, + {file = "SQLAlchemy-2.0.27-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7b5a3e2120982b8b6bd1d5d99e3025339f7fb8b8267551c679afb39e9c7c7f1"}, + {file = "SQLAlchemy-2.0.27-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e36aa62b765cf9f43a003233a8c2d7ffdeb55bc62eaa0a0380475b228663a38f"}, + {file = "SQLAlchemy-2.0.27-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5ada0438f5b74c3952d916c199367c29ee4d6858edff18eab783b3978d0db16d"}, + {file = "SQLAlchemy-2.0.27-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b1d9d1bfd96eef3c3faedb73f486c89e44e64e40e5bfec304ee163de01cf996f"}, + {file = "SQLAlchemy-2.0.27-cp37-cp37m-win32.whl", hash = "sha256:ca891af9f3289d24a490a5fde664ea04fe2f4984cd97e26de7442a4251bd4b7c"}, + {file = "SQLAlchemy-2.0.27-cp37-cp37m-win_amd64.whl", hash = "sha256:fd8aafda7cdff03b905d4426b714601c0978725a19efc39f5f207b86d188ba01"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec1f5a328464daf7a1e4e385e4f5652dd9b1d12405075ccba1df842f7774b4fc"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ad862295ad3f644e3c2c0d8b10a988e1600d3123ecb48702d2c0f26771f1c396"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48217be1de7d29a5600b5c513f3f7664b21d32e596d69582be0a94e36b8309cb"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e56afce6431450442f3ab5973156289bd5ec33dd618941283847c9fd5ff06bf"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:611068511b5531304137bcd7fe8117c985d1b828eb86043bd944cebb7fae3910"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b86abba762ecfeea359112b2bb4490802b340850bbee1948f785141a5e020de8"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-win32.whl", hash = "sha256:30d81cc1192dc693d49d5671cd40cdec596b885b0ce3b72f323888ab1c3863d5"}, + {file = "SQLAlchemy-2.0.27-cp38-cp38-win_amd64.whl", hash = "sha256:120af1e49d614d2525ac247f6123841589b029c318b9afbfc9e2b70e22e1827d"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d07ee7793f2aeb9b80ec8ceb96bc8cc08a2aec8a1b152da1955d64e4825fcbac"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cb0845e934647232b6ff5150df37ceffd0b67b754b9fdbb095233deebcddbd4a"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fc19ae2e07a067663dd24fca55f8ed06a288384f0e6e3910420bf4b1270cc51"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b90053be91973a6fb6020a6e44382c97739736a5a9d74e08cc29b196639eb979"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2f5c9dfb0b9ab5e3a8a00249534bdd838d943ec4cfb9abe176a6c33408430230"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:33e8bde8fff203de50399b9039c4e14e42d4d227759155c21f8da4a47fc8053c"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-win32.whl", hash = "sha256:d873c21b356bfaf1589b89090a4011e6532582b3a8ea568a00e0c3aab09399dd"}, + {file = "SQLAlchemy-2.0.27-cp39-cp39-win_amd64.whl", hash = "sha256:ff2f1b7c963961d41403b650842dc2039175b906ab2093635d8319bef0b7d620"}, + {file = "SQLAlchemy-2.0.27-py3-none-any.whl", hash = "sha256:1ab4e0448018d01b142c916cc7119ca573803a4745cfe341b8f95657812700ac"}, + {file = "SQLAlchemy-2.0.27.tar.gz", hash = "sha256:86a6ed69a71fe6b88bf9331594fa390a2adda4a49b5c06f98e47bf0d392534f8"}, ] [package.dependencies] aiosqlite = {version = "*", optional = true, markers = "extra == \"aiosqlite\""} greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"aiosqlite\""} -typing-extensions = {version = ">=4.6.0", optional = true, markers = "extra == \"aiosqlite\""} +typing-extensions = ">=4.6.0" [package.extras] aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] @@ -3949,13 +3994,13 @@ reference = "offical-source" [[package]] name = "tzdata" -version = "2023.4" +version = "2024.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2023.4-py2.py3-none-any.whl", hash = "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3"}, - {file = "tzdata-2023.4.tar.gz", hash = "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9"}, + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] [package.source] @@ -4460,4 +4505,4 @@ yaml = [] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0.0" -content-hash = "29341c2d9cdb57d908f22fafd4296e31555d967bf894acaf6517ef4ad1166128" +content-hash = "44bdefe131938db105fdb9fa223ccdd964aab8878778d4b0b5db555852c566d7" diff --git a/pyproject.toml b/pyproject.toml index 250c4c5..3da0d86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ pillow = ">=8.1,<11.0" pyjwt = "^2.1.0" python-socketio = "^5.4.0" tinydb = "^4.3.0" +qrcode = "^7.4.2" [tool.poetry.group.dev.dependencies] black = ">=23.7,<25.0" diff --git a/tests/conftest.py b/tests/conftest.py index 9132116..8872a99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,7 @@ async def app(tmp_path: Path, request: pytest.FixtureRequest, mocker: MockerFixt plugin_config.bison_config_path = str(tmp_path / "legacy_config") plugin_config.bison_filter_log = False + plugin_config.bison_theme_use_browser = True datastore_config.datastore_config_dir = tmp_path / "config" datastore_config.datastore_cache_dir = tmp_path / "cache" diff --git a/tests/platforms/test_arknights.py b/tests/platforms/test_arknights.py index 8175a47..153aa69 100644 --- a/tests/platforms/test_arknights.py +++ b/tests/platforms/test_arknights.py @@ -1,3 +1,5 @@ +from time import time + import respx import pytest from nonebug.app import App @@ -49,6 +51,7 @@ async def test_fetch_new( monster_siren_list_0, monster_siren_list_1, ): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit ak_list_router = respx.get("https://ak-webview.hypergryph.com/api/game/bulletinList?target=IOS") @@ -60,37 +63,50 @@ async def test_fetch_new( monster_siren_router = respx.get("https://monster-siren.hypergryph.com/api/news") terra_list = respx.get("https://terra-historicus.hypergryph.com/api/recentUpdate") ak_list_router.mock(return_value=Response(200, json=arknights_list__1)) - detail_router.mock(return_value=Response(200, text=get_file("arknights-detail-807"))) + mock_detail = get_json("arknights-detail-807") + mock_detail["data"]["bannerImageUrl"] = "https://example.com/1.jpg" + detail_router.mock(return_value=Response(200, json=mock_detail)) version_router.mock(return_value=Response(200, json=get_json("arknights-version-0.json"))) preannouncement_router.mock(return_value=Response(200, json=get_json("arknights-pre-0.json"))) monster_siren_router.mock(return_value=Response(200, json=monster_siren_list_0)) terra_list.mock(return_value=Response(200, json=get_json("terra-hist-0.json"))) + target = Target("") - res = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) + + res1 = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) assert ak_list_router.called - assert len(res) == 0 + assert len(res1) == 0 assert not detail_router.called + mock_data = arknights_list_0 + mock_data["data"]["list"][0]["updatedAt"] = int(time()) ak_list_router.mock(return_value=Response(200, json=mock_data)) - res3 = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) - assert len(res3[0][1]) == 1 + res2 = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) + assert len(res2[0][1]) == 1 assert detail_router.called - post = res3[0][1][0] - assert post.target_type == "arknights" - assert post.text == "" - assert post.url == "" - assert post.target_name == "明日方舟游戏内公告" - assert len(post.pics) == 1 + post2: Post = res2[0][1][0] + assert post2.platform.platform_name == "arknights" + assert post2.content + assert post2.title == "2023「夏日嘉年华」限时活动即将开启" + assert not post2.url + assert post2.nickname == "明日方舟游戏内公告" + assert post2.images + assert post2.images == ["https://example.com/1.jpg"] + assert post2.timestamp + assert "arknights" == post2.get_priority_themes()[0] # assert(post.pics == ['https://ak-fs.hypergryph.com/announce/images/20210623/e6f49aeb9547a2278678368a43b95b07.jpg']) - await post.generate_messages() + terra_list.mock(return_value=Response(200, json=get_json("terra-hist-1.json"))) - res = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) - assert len(res) == 1 - post = res[0][1][0] - assert post.target_type == "terra-historicus" - assert post.text == "123罗德岛!? - 「掠风」篇" - assert post.url == "https://terra-historicus.hypergryph.com/comic/6253/episode/4938" - assert post.pics == ["https://web.hycdn.cn/comic/pic/20220507/ab8a2ff408ec7d587775aed70b178ec0.png"] + res3 = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) + assert len(res3) == 1 + post3: Post = res3[0][1][0] + assert post3.platform.platform_name == "arknights" + assert post3.nickname == "泰拉记事社漫画" + assert post3.title == "123罗德岛!? - 「掠风」篇" + assert post3.content == "你可能不知道的罗德岛小剧场!" + assert post3.url == "https://terra-historicus.hypergryph.com/comic/6253/episode/4938" + assert post3.images == ["https://web.hycdn.cn/comic/pic/20220507/ab8a2ff408ec7d587775aed70b178ec0.png"] + assert "brief" == post3.get_priority_themes()[0] @pytest.mark.render() @@ -103,6 +119,7 @@ async def test_send_with_render( monster_siren_list_0, monster_siren_list_1, ): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit ak_list_router = respx.get("https://ak-webview.hypergryph.com/api/game/bulletinList?target=IOS") @@ -119,22 +136,26 @@ async def test_send_with_render( preannouncement_router.mock(return_value=Response(200, json=get_json("arknights-pre-0.json"))) monster_siren_router.mock(return_value=Response(200, json=monster_siren_list_0)) terra_list.mock(return_value=Response(200, json=get_json("terra-hist-0.json"))) + target = Target("") - res = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) + + res1 = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) assert ak_list_router.called - assert len(res) == 0 + assert len(res1) == 0 assert not detail_router.called + mock_data = arknights_list_1 + mock_data["data"]["list"][0]["updatedAt"] = int(time()) ak_list_router.mock(return_value=Response(200, json=mock_data)) - res3 = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) - assert len(res3[0][1]) == 1 + res2 = await arknights.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) + assert len(res2[0][1]) == 1 assert detail_router.called - post = res3[0][1][0] - assert post.target_type == "arknights" - assert post.text == "" - assert post.url == "" - assert post.target_name == "明日方舟游戏内公告" - assert len(post.pics) == 1 + post2: Post = res2[0][1][0] + assert post2.platform.platform_name == "arknights" + assert "《明日方舟》将于08月01日10:00 ~16:00的更新维护中对游戏内【公开招募】进行新增干员。" in post2.content + assert post2.title == "【公开招募】标签强制刷新通知" + assert post2.nickname == "明日方舟游戏内公告" + assert not post2.images # assert(post.pics == ['https://ak-fs.hypergryph.com/announce/images/20210623/e6f49aeb9547a2278678368a43b95b07.jpg']) - r = await post.generate_messages() + r = await post2.generate_messages() assert r diff --git a/tests/platforms/test_bilibili.py b/tests/platforms/test_bilibili.py index 1e5f418..7658aff 100644 --- a/tests/platforms/test_bilibili.py +++ b/tests/platforms/test_bilibili.py @@ -72,14 +72,17 @@ async def test_get_tag_without_topic_info(bilibili, bing_dy_list): @pytest.mark.asyncio async def test_video_forward(bilibili, bing_dy_list): - post = await bilibili.parse(bing_dy_list[1]) + from nonebot_bison.post import Post + + post: Post = await bilibili.parse(bing_dy_list[1]) assert ( - post.text + post.content == "答案揭晓:宿舍!来看看投票结果\nhttps://t.bilibili.com/568093580488553786\n--------------\n#可露希尔的秘密档案#" " \n11:来宿舍休息一下吧 \n档案来源:lambda:\\罗德岛内务\\秘密档案 \n发布时间:9/12 1:00 P.M." " \n档案类型:可见 \n档案描述:今天请了病假在宿舍休息。很舒适。" " \n提供者:赫默\n=================\n《可露希尔的秘密档案》11话:来宿舍休息一下吧" ) + assert post.get_priority_themes()[0] == "basic" @pytest.mark.asyncio @@ -87,7 +90,7 @@ async def test_video_forward_without_dynamic(bilibili, bing_dy_list): # 视频简介和动态文本其中一方为空的情况 post = await bilibili.parse(bing_dy_list[2]) assert ( - post.text + post.content == "阿消的罗德岛闲谈直播#01:《女人最喜欢的女人,就是在战场上熠熠生辉的女人》" + "\n\n" + "本系列视频为饼组成员的有趣直播录播,主要内容为方舟相关,未来可能系列其他视频会包含部分饼组团建日常等。" @@ -96,13 +99,14 @@ async def test_video_forward_without_dynamic(bilibili, bing_dy_list): "包含慕夏对新PV的个人解读,风笛厨力疯狂放出,CP言论输出,9.16轮换池预测视频分析和理智规划杂谈内容。" "\n注意:内含大量个人性质对风笛的厨力观点,与多CP混乱发言,不适者请及时点击退出或跳到下一片段。" ) + assert post.get_priority_themes()[0] == "basic" @pytest.mark.asyncio async def test_article_forward(bilibili, bing_dy_list): post = await bilibili.parse(bing_dy_list[4]) assert ( - post.text + post.content == "#明日方舟##饼学大厦#\n9.11专栏更新完毕," "这还塌了实属没跟新运营对上\n后边除了周日发饼和PV没提及的中文语音," "稳了\n别忘了来参加#可露希尔的秘密档案#的主题投票\nhttps://t.bilibili.com/568093580488553786?tab=2" @@ -121,7 +125,7 @@ async def test_article_forward(bilibili, bing_dy_list): async def test_dynamic_forward(bilibili, bing_dy_list): post = await bilibili.parse(bing_dy_list[5]) assert ( - post.text + post.content == "饼组主线饼学预测——9.11版\n①今日结果\n9.11 殿堂上的游禽-星极(x," "新运营实锤了)\n②后续预测\n9.12 #罗德岛相簿#+#可露希尔的秘密档案#11话\n9.13" " 六星先锋(执旗手)干员-琴柳\n9.14 宣传策略-空弦+家具\n9.15 轮换池(+中文语音前瞻)\n9.16" @@ -161,7 +165,9 @@ async def test_fetch_new(bilibili, dummy_user_subinfo): post_router.mock(return_value=Response(200, json=get_json("bilibili_strange_post-0.json"))) bilibili_main_page_router = respx.get("https://www.bilibili.com/") bilibili_main_page_router.mock(return_value=Response(200)) + target = Target("161775300") + res = await bilibili.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) assert post_router.called assert len(res) == 0 @@ -173,7 +179,7 @@ async def test_fetch_new(bilibili, dummy_user_subinfo): assert len(res2[0][1]) == 1 post = res2[0][1][0] assert ( - post.text + post.content == "#罗德厨房——回甘##明日方舟#\r\n明日方舟官方美食漫画,正式开餐。\r\n往事如烟,安然即好。\r\nMenu" " 01:高脚羽兽烤串与罗德岛的领袖\r\n\r\n哔哩哔哩漫画阅读:https://manga.bilibili.com/detail/mc31998?from=manga_search\r\n\r\n关注并转发本动态," "我们将会在5月27日抽取10位博士赠送【兔兔奇境】周边礼盒一份。 互动抽奖" diff --git a/tests/platforms/test_bilibili_bangumi.py b/tests/platforms/test_bilibili_bangumi.py index a2d37d7..0df1701 100644 --- a/tests/platforms/test_bilibili_bangumi.py +++ b/tests/platforms/test_bilibili_bangumi.py @@ -24,10 +24,13 @@ async def test_parse_target(bili_bangumi: "BilibiliBangumi"): res1 = await bili_bangumi.parse_target("28339726") assert res1 == "28339726" + res2 = await bili_bangumi.parse_target("md28339726") assert res2 == "28339726" + res3 = await bili_bangumi.parse_target("https://www.bilibili.com/bangumi/media/md28339726") assert res3 == "28339726" + with pytest.raises(Platform.ParseTargetException): await bili_bangumi.parse_target("https://www.bilibili.com/bangumi/play/ep683045") @@ -42,21 +45,25 @@ async def test_fetch_bilibili_bangumi_status(bili_bangumi: "BilibiliBangumi", du bili_bangumi_router.mock(return_value=Response(200, json=get_json("bilibili-gangumi-hanhua0.json"))) bilibili_main_page_router = respx.get("https://www.bilibili.com/") bilibili_main_page_router.mock(return_value=Response(200)) - target = Target("28235413") - res = await bili_bangumi.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) - assert len(res) == 0 - res = await bili_bangumi.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) - assert len(res) == 0 + target = Target("28235413") + + res0 = await bili_bangumi.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) + assert len(res0) == 0 + + res1 = await bili_bangumi.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) + assert len(res1) == 0 bili_bangumi_router.mock(return_value=Response(200, json=get_json("bilibili-gangumi-hanhua1.json"))) bili_bangumi_detail_router.mock(return_value=Response(200, json=get_json("bilibili-gangumi-hanhua1-detail.json"))) res2 = await bili_bangumi.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) - post = res2[0][1][0] - assert post.target_type == "Bilibili剧集" - assert post.text == "《汉化日记 第三季》第2话 什么是战区导弹防御系统工作日" - assert post.url == "https://www.bilibili.com/bangumi/play/ep519207" - assert post.target_name == "汉化日记 第三季" - assert post.pics == ["http://i0.hdslb.com/bfs/archive/ea0a302c954f9dbc3d593e676486396c551529c9.jpg"] - assert post.compress is True + post2 = res2[0][1][0] + assert post2.platform.name == "Bilibili剧集" + assert post2.title == "《汉化日记 第三季》第2话 什么是战区导弹防御系统工作日" + assert post2.content == "更新至第2话" + assert post2.url == "https://www.bilibili.com/bangumi/play/ep519207" + assert post2.nickname == "汉化日记 第三季" + assert post2.images == ["http://i0.hdslb.com/bfs/archive/ea0a302c954f9dbc3d593e676486396c551529c9.jpg"] + assert post2.compress is True + assert "brief" == post2.get_priority_themes()[0] diff --git a/tests/platforms/test_bilibili_live.py b/tests/platforms/test_bilibili_live.py index 1889694..be46f1a 100644 --- a/tests/platforms/test_bilibili_live.py +++ b/tests/platforms/test_bilibili_live.py @@ -1,4 +1,5 @@ from copy import deepcopy +from typing import TYPE_CHECKING import respx import pytest @@ -7,6 +8,9 @@ from httpx import Response, AsyncClient from .utils import get_json +if TYPE_CHECKING: + from nonebot_bison.platform.bilibili import Bilibililive + @pytest.fixture() def bili_live(app: App): @@ -48,6 +52,7 @@ async def test_fetch_bililive_no_room(bili_live, dummy_only_open_user_subinfo): @pytest.mark.asyncio @respx.mock async def test_fetch_first_live(bili_live, dummy_only_open_user_subinfo): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit mock_bili_live_status = get_json("bili_live_status.json") @@ -60,27 +65,31 @@ async def test_fetch_first_live(bili_live, dummy_only_open_user_subinfo): bilibili_main_page_router.mock(return_value=Response(200)) target = Target("13164144") - res = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_open_user_subinfo]))]) + + res1 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_open_user_subinfo]))]) assert bili_live_router.call_count == 1 - assert len(res) == 0 + assert len(res1) == 0 mock_bili_live_status["data"][target]["live_status"] = 1 bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) res2 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_open_user_subinfo]))]) assert bili_live_router.call_count == 2 assert len(res2) == 1 - post = res2[0][1][0] - assert post.target_type == "Bilibili直播" - assert post.text == "[开播] 【Zc】从0挑战到15肉鸽!目前10难度" - assert post.url == "https://live.bilibili.com/3044248" - assert post.target_name == "魔法Zc目录 其他单机" - assert post.pics == ["https://i0.hdslb.com/bfs/live/new_room_cover/fd357f0f3cbbb48e9acfbcda616b946c2454c56c.jpg"] - assert post.compress is True + post2: Post = res2[0][1][0] + assert post2.platform.name == "Bilibili直播" + assert post2.title == "[开播] 【Zc】从0挑战到15肉鸽!目前10难度" + assert post2.content == "" + assert post2.url == "https://live.bilibili.com/3044248" + assert post2.nickname == "魔法Zc目录 其他单机" + assert post2.images == ["https://i0.hdslb.com/bfs/live/new_room_cover/fd357f0f3cbbb48e9acfbcda616b946c2454c56c.jpg"] + assert post2.compress is True + assert "brief" == post2.get_priority_themes()[0] @pytest.mark.asyncio @respx.mock -async def test_fetch_bililive_only_live_open(bili_live, dummy_only_open_user_subinfo): +async def test_fetch_bililive_only_live_open(bili_live: "Bilibililive", dummy_only_open_user_subinfo): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit mock_bili_live_status = get_json("bili_live_status.json") @@ -92,20 +101,22 @@ async def test_fetch_bililive_only_live_open(bili_live, dummy_only_open_user_sub bilibili_main_page_router.mock(return_value=Response(200)) target = Target("13164144") - res = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_open_user_subinfo]))]) + + bili_live.set_stored_data(target, None) + res1 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_open_user_subinfo]))]) assert bili_live_router.call_count == 1 - assert len(res[0][1]) == 0 + assert len(res1) == 0 # 直播状态更新-上播 mock_bili_live_status["data"][target]["live_status"] = 1 bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) res2 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_open_user_subinfo]))]) - post = res2[0][1][0] - assert post.target_type == "Bilibili直播" - assert post.text == "[开播] 【Zc】从0挑战到15肉鸽!目前10难度" - assert post.url == "https://live.bilibili.com/3044248" - assert post.target_name == "魔法Zc目录 其他单机" - assert post.pics == ["https://i0.hdslb.com/bfs/live/new_room_cover/fd357f0f3cbbb48e9acfbcda616b946c2454c56c.jpg"] - assert post.compress is True + post2: Post = res2[0][1][0] + assert post2.platform.name == "Bilibili直播" + assert post2.title == "[开播] 【Zc】从0挑战到15肉鸽!目前10难度" + assert post2.url == "https://live.bilibili.com/3044248" + assert post2.nickname == "魔法Zc目录 其他单机" + assert post2.images == ["https://i0.hdslb.com/bfs/live/new_room_cover/fd357f0f3cbbb48e9acfbcda616b946c2454c56c.jpg"] + assert post2.compress is True # 标题变更 mock_bili_live_status["data"][target]["title"] = "【Zc】从0挑战到15肉鸽!目前11难度" bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) @@ -133,6 +144,7 @@ def dummy_only_title_user_subinfo(app: App): @pytest.mark.asyncio() @respx.mock async def test_fetch_bililive_only_title_change(bili_live, dummy_only_title_user_subinfo): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit mock_bili_live_status = get_json("bili_live_status.json") @@ -163,13 +175,13 @@ async def test_fetch_bililive_only_title_change(bili_live, dummy_only_title_user mock_bili_live_status["data"][target]["title"] = "【Zc】从0挑战到15肉鸽!目前12难度" bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) res3 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_title_user_subinfo]))]) - post = res3[0][1][0] - assert post.target_type == "Bilibili直播" - assert post.text == "[标题更新] 【Zc】从0挑战到15肉鸽!目前12难度" - assert post.url == "https://live.bilibili.com/3044248" - assert post.target_name == "魔法Zc目录 其他单机" - assert post.pics == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] - assert post.compress is True + post3: Post = res3[0][1][0] + assert post3.platform.name == "Bilibili直播" + assert post3.title == "[标题更新] 【Zc】从0挑战到15肉鸽!目前12难度" + assert post3.url == "https://live.bilibili.com/3044248" + assert post3.nickname == "魔法Zc目录 其他单机" + assert post3.images == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] + assert post3.compress is True # 直播状态更新-下播 mock_bili_live_status["data"][target]["live_status"] = 0 bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) @@ -191,6 +203,7 @@ def dummy_only_close_user_subinfo(app: App): @pytest.mark.asyncio @respx.mock async def test_fetch_bililive_only_close(bili_live, dummy_only_close_user_subinfo): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit mock_bili_live_status = get_json("bili_live_status.json") @@ -228,13 +241,13 @@ async def test_fetch_bililive_only_close(bili_live, dummy_only_close_user_subinf bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) res4 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_only_close_user_subinfo]))]) assert bili_live_router.call_count == 5 - post = res4[0][1][0] - assert post.target_type == "Bilibili直播" - assert post.text == "[下播] 【Zc】从0挑战到15肉鸽!目前12难度" - assert post.url == "https://live.bilibili.com/3044248" - assert post.target_name == "魔法Zc目录 其他单机" - assert post.pics == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] - assert post.compress is True + post4: Post = res4[0][1][0] + assert post4.platform.name == "Bilibili直播" + assert post4.title == "[下播] 【Zc】从0挑战到15肉鸽!目前12难度" + assert post4.url == "https://live.bilibili.com/3044248" + assert post4.nickname == "魔法Zc目录 其他单机" + assert post4.images == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] + assert post4.compress is True @pytest.fixture() @@ -250,6 +263,7 @@ def dummy_bililive_user_subinfo(app: App): @pytest.mark.asyncio @respx.mock async def test_fetch_bililive_combo(bili_live, dummy_bililive_user_subinfo): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit mock_bili_live_status = get_json("bili_live_status.json") @@ -274,32 +288,32 @@ async def test_fetch_bililive_combo(bili_live, dummy_bililive_user_subinfo): mock_bili_live_status["data"][target]["live_status"] = 1 bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) res2 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_bililive_user_subinfo]))]) - post2 = res2[0][1][0] - assert post2.target_type == "Bilibili直播" - assert post2.text == "[开播] 【Zc】从0挑战到15肉鸽!目前11难度" + post2: Post = res2[0][1][0] + assert post2.platform.name == "Bilibili直播" + assert post2.title == "[开播] 【Zc】从0挑战到15肉鸽!目前11难度" assert post2.url == "https://live.bilibili.com/3044248" - assert post2.target_name == "魔法Zc目录 其他单机" - assert post2.pics == ["https://i0.hdslb.com/bfs/live/new_room_cover/fd357f0f3cbbb48e9acfbcda616b946c2454c56c.jpg"] + assert post2.nickname == "魔法Zc目录 其他单机" + assert post2.images == ["https://i0.hdslb.com/bfs/live/new_room_cover/fd357f0f3cbbb48e9acfbcda616b946c2454c56c.jpg"] assert post2.compress is True # 标题变更 mock_bili_live_status["data"][target]["title"] = "【Zc】从0挑战到15肉鸽!目前12难度" bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) res3 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_bililive_user_subinfo]))]) - post3 = res3[0][1][0] - assert post3.target_type == "Bilibili直播" - assert post3.text == "[标题更新] 【Zc】从0挑战到15肉鸽!目前12难度" + post3: Post = res3[0][1][0] + assert post3.platform.name == "Bilibili直播" + assert post3.title == "[标题更新] 【Zc】从0挑战到15肉鸽!目前12难度" assert post3.url == "https://live.bilibili.com/3044248" - assert post3.target_name == "魔法Zc目录 其他单机" - assert post3.pics == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] + assert post3.nickname == "魔法Zc目录 其他单机" + assert post3.images == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] assert post3.compress is True # 直播状态更新-下播 mock_bili_live_status["data"][target]["live_status"] = 0 bili_live_router.mock(return_value=Response(200, json=mock_bili_live_status)) res4 = await bili_live.batch_fetch_new_post([(SubUnit(target, [dummy_bililive_user_subinfo]))]) - post4 = res4[0][1][0] - assert post4.target_type == "Bilibili直播" - assert post4.text == "[下播] 【Zc】从0挑战到15肉鸽!目前12难度" + post4: Post = res4[0][1][0] + assert post4.platform.name == "Bilibili直播" + assert post4.title == "[下播] 【Zc】从0挑战到15肉鸽!目前12难度" assert post4.url == "https://live.bilibili.com/3044248" - assert post4.target_name == "魔法Zc目录 其他单机" - assert post4.pics == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] + assert post4.nickname == "魔法Zc目录 其他单机" + assert post4.images == ["https://i0.hdslb.com/bfs/live-key-frame/keyframe10170435000003044248mwowx0.jpg"] assert post4.compress is True diff --git a/tests/platforms/test_ff14.py b/tests/platforms/test_ff14.py index 9631c70..485226c 100644 --- a/tests/platforms/test_ff14.py +++ b/tests/platforms/test_ff14.py @@ -27,6 +27,7 @@ def ff14_newdata_json_1(): @pytest.mark.asyncio @respx.mock async def test_fetch_new(ff14, dummy_user_subinfo, ff14_newdata_json_0, ff14_newdata_json_1): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit newdata = respx.get( @@ -40,8 +41,9 @@ async def test_fetch_new(ff14, dummy_user_subinfo, ff14_newdata_json_0, ff14_new newdata.mock(return_value=Response(200, json=ff14_newdata_json_1)) res = await ff14.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) assert newdata.called - post = res[0][1][0] - assert post.target_type == "ff14" - assert post.text == "最终幻想XIV 银质坠饰 <友谊永存>预售开启!\n最终幻想XIV 银质坠饰 <友谊永存>现已开启预售!" + post: Post = res[0][1][0] + assert post.platform.name == "最终幻想XIV官方公告" + assert post.title == "最终幻想XIV 银质坠饰 <友谊永存>预售开启!" + assert post.content == "最终幻想XIV 银质坠饰 <友谊永存>现已开启预售!" assert post.url == "https://ff.web.sdo.com/web8/index.html#/newstab/newscont/336870" - assert post.target_name == "最终幻想XIV官方公告" + assert post.nickname == "最终幻想XIV官方公告" diff --git a/tests/platforms/test_mcbbsnews.py b/tests/platforms/test_mcbbsnews.py index 55291f2..b30cfaa 100644 --- a/tests/platforms/test_mcbbsnews.py +++ b/tests/platforms/test_mcbbsnews.py @@ -25,24 +25,29 @@ def raw_post_list(): @respx.mock @flaky(max_runs=3, min_passes=1) async def test_fetch_new(mcbbsnews, dummy_user_subinfo, raw_post_list): + from nonebot_bison.post import Post + news_router = respx.get("https://www.mcbbs.net/forum-news-1.html") news_router.mock(return_value=Response(200, text=get_file("mcbbsnews/mock/mcbbsnews_post_list_html-0.html"))) new_post = respx.get("https://www.mcbbs.net/thread-1340927-1-1.html") new_post.mock(return_value=Response(200, text=get_file("mcbbsnews/mock/mcbbsnews_new_post_html.html"))) + target = "" res = await mcbbsnews.fetch_new_post(target, [dummy_user_subinfo]) assert news_router.called assert len(res) == 0 + news_router.mock(return_value=Response(200, text=get_file("mcbbsnews/mock/mcbbsnews_post_list_html-1.html"))) - res = await mcbbsnews.fetch_new_post(target, [dummy_user_subinfo]) + res1 = await mcbbsnews.fetch_new_post(target, [dummy_user_subinfo]) assert news_router.called - post = res[0][1][0] + post: Post = res1[0][1][0] raw_post = raw_post_list[0] - assert post.target_type == "MCBBS幻翼块讯" - assert post.text == "{}\n│\n└由 {} 发表".format(raw_post["title"], raw_post["author"]) + assert post.platform.name == "MCBBS幻翼块讯" + assert post.content == "{}\n│\n└由 {} 发表".format(raw_post["title"], raw_post["author"]) assert post.url == "https://www.mcbbs.net/{}".format(raw_post["url"]) - assert post.target_name == raw_post["category"] - assert len(post.pics) == 1 + assert post.nickname == raw_post["category"] + assert post.images + assert len(post.images) == 1 @pytest.mark.asyncio diff --git a/tests/platforms/test_ncm_artist.py b/tests/platforms/test_ncm_artist.py index d982229..0550d2e 100644 --- a/tests/platforms/test_ncm_artist.py +++ b/tests/platforms/test_ncm_artist.py @@ -52,8 +52,8 @@ async def test_fetch_new(ncm_artist, ncm_artist_0, ncm_artist_1, dummy_user_subi ncm_router.mock(return_value=Response(200, json=ncm_artist_1)) res2 = await ncm_artist.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) post = res2[0][1][0] - assert post.target_type == "ncm-artist" - assert post.text == "新专辑发布:Y1K" + assert post.platform.platform_name == "ncm-artist" + assert post.content == "新专辑发布:Y1K" assert post.url == "https://music.163.com/#/album?id=131074504" diff --git a/tests/platforms/test_ncm_radio.py b/tests/platforms/test_ncm_radio.py index 7547540..461b57b 100644 --- a/tests/platforms/test_ncm_radio.py +++ b/tests/platforms/test_ncm_radio.py @@ -41,6 +41,7 @@ def ncm_radio_1(ncm_radio_raw: dict): @pytest.mark.asyncio @respx.mock async def test_fetch_new(ncm_radio, ncm_radio_0, ncm_radio_1, dummy_user_subinfo): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit ncm_router = respx.post("http://music.163.com/api/dj/program/byradio") @@ -51,12 +52,12 @@ async def test_fetch_new(ncm_radio, ncm_radio_0, ncm_radio_1, dummy_user_subinfo assert len(res) == 0 ncm_router.mock(return_value=Response(200, json=ncm_radio_1)) res2 = await ncm_radio.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) - post = res2[0][1][0] - assert post.target_type == "ncm-radio" - assert post.text == "网易云电台更新:「松烟行动」灰齐山麓" + post: Post = res2[0][1][0] + assert post.platform.platform_name == "ncm-radio" + assert post.content == "网易云电台更新:「松烟行动」灰齐山麓" assert post.url == "https://music.163.com/#/program/2494997688" - assert post.pics == ["http://p1.music.126.net/H5em5xUNIYXcjJhOmeaSqQ==/109951166647436789.jpg"] - assert post.target_name == "《明日方舟》游戏原声OST" + assert post.images == ["http://p1.music.126.net/H5em5xUNIYXcjJhOmeaSqQ==/109951166647436789.jpg"] + assert post.nickname == "《明日方舟》游戏原声OST" async def test_parse_target(ncm_radio: "NcmRadio"): diff --git a/tests/platforms/test_platform.py b/tests/platforms/test_platform.py index eb1fefd..9415994 100644 --- a/tests/platforms/test_platform.py +++ b/tests/platforms/test_platform.py @@ -65,10 +65,10 @@ def mock_platform_without_cats_tags(app: App): async def parse(self, raw_post: "RawPost") -> "Post": return Post( - "mock_platform", + self, raw_post["text"], "http://t.tt/" + str(self.get_id(raw_post)), - target_name="Mock", + nickname="Mock", ) @classmethod @@ -127,10 +127,10 @@ def mock_platform(app: App): async def parse(self, raw_post: "RawPost") -> "Post": return Post( - "mock_platform", + self, raw_post["text"], "http://t.tt/" + str(self.get_id(raw_post)), - target_name="Mock", + nickname="Mock", ) @classmethod @@ -194,10 +194,10 @@ def mock_platform_no_target(app: App, mock_scheduler_conf): async def parse(self, raw_post: "RawPost") -> "Post": return Post( - "mock_platform", + self, raw_post["text"], "http://t.tt/" + str(self.get_id(raw_post)), - target_name="Mock", + nickname="Mock", ) @classmethod @@ -250,10 +250,10 @@ def mock_platform_no_target_2(app: App, mock_scheduler_conf): async def parse(self, raw_post: "RawPost") -> "Post": return Post( - "mock_platform_2", + self, raw_post["text"], "http://t.tt/" + str(self.get_id(raw_post)), - target_name="Mock", + nickname="Mock", ) @classmethod @@ -314,7 +314,7 @@ def mock_status_change(app: App): return [] async def parse(self, raw_post) -> "Post": - return Post("mock_status", raw_post["text"], "") + return Post(self, raw_post["text"], "") def get_category(self, raw_post): return raw_post["cat"] @@ -337,7 +337,7 @@ async def test_new_message_target_without_cats_tags(mock_platform_without_cats_t assert len(res2) == 1 posts_1 = res2[0][1] assert len(posts_1) == 3 - id_set_1 = {x.text for x in posts_1} + id_set_1 = {x.content for x in posts_1} assert "p2" in id_set_1 assert "p3" in id_set_1 assert "p4" in id_set_1 @@ -369,9 +369,9 @@ async def test_new_message_target(mock_platform, user_info_factory): assert len(posts_1) == 2 assert len(posts_2) == 1 assert len(posts_3) == 1 - id_set_1 = {x.text for x in posts_1} - id_set_2 = {x.text for x in posts_2} - id_set_3 = {x.text for x in posts_3} + id_set_1 = {x.content for x in posts_1} + id_set_2 = {x.content for x in posts_2} + id_set_3 = {x.content for x in posts_3} assert "p2" in id_set_1 assert "p3" in id_set_1 assert "p2" in id_set_2 @@ -404,9 +404,9 @@ async def test_new_message_no_target(mock_platform_no_target, user_info_factory) assert len(posts_1) == 2 assert len(posts_2) == 1 assert len(posts_3) == 1 - id_set_1 = {x.text for x in posts_1} - id_set_2 = {x.text for x in posts_2} - id_set_3 = {x.text for x in posts_3} + id_set_1 = {x.content for x in posts_1} + id_set_2 = {x.content for x in posts_2} + id_set_3 = {x.content for x in posts_3} assert "p2" in id_set_1 assert "p3" in id_set_1 assert "p2" in id_set_2 @@ -432,7 +432,7 @@ async def test_status_change(mock_status_change, user_info_factory): assert len(res2) == 1 posts = res2[0][1] assert len(posts) == 1 - assert posts[0].text == "on" + assert posts[0].content == "on" res3 = await mock_status_change(ProcessContext(), AsyncClient()).fetch_new_post( SubUnit( Target("dummy"), @@ -444,7 +444,7 @@ async def test_status_change(mock_status_change, user_info_factory): ) assert len(res3) == 2 assert len(res3[0][1]) == 1 - assert res3[0][1][0].text == "off" + assert res3[0][1][0].content == "off" assert len(res3[1][1]) == 0 res4 = await mock_status_change(ProcessContext(), AsyncClient()).fetch_new_post( SubUnit(Target("dummy"), [user_info_factory([1, 2], [])]) @@ -473,7 +473,7 @@ async def test_group( assert len(res2) == 1 posts = res2[0][1] assert len(posts) == 2 - id_set_2 = {x.text for x in posts} + id_set_2 = {x.content for x in posts} assert "p2" in id_set_2 assert "p6" in id_set_2 res3 = await group_platform.fetch_new_post(SubUnit(dummy, [user_info_factory([1, 4], [])])) @@ -512,10 +512,10 @@ async def test_batch_fetch_new_message(app: App): async def parse(self, raw_post: "RawPost") -> "Post": return Post( - "mock_platform", + self, raw_post["text"], "http://t.tt/" + str(self.get_id(raw_post)), - target_name="Mock", + nickname="Mock", ) @classmethod @@ -556,7 +556,7 @@ async def test_batch_fetch_new_message(app: App): send_set = set() for platform_target, posts in res2: for post in posts: - send_set.add((platform_target, post.text)) + send_set.add((platform_target, post.content)) assert (TargetQQGroup(group_id=123), "p3") in send_set assert (TargetQQGroup(group_id=123), "p4") in send_set assert (TargetQQGroup(group_id=234), "p4") in send_set @@ -578,7 +578,7 @@ async def test_batch_fetch_compare_status(app: App): enable_tag = False schedule_type = "interval" schedule_kw = {"seconds": 10} - has_target = False + has_target = True categories = { Category(1): "转发", Category(2): "视频", @@ -603,7 +603,7 @@ async def test_batch_fetch_compare_status(app: App): return [] async def parse(self, raw_post) -> "Post": - return Post("mock_status", raw_post["text"], "") + return Post(self, raw_post["text"], "") def get_category(self, raw_post): return raw_post["cat"] @@ -627,7 +627,7 @@ async def test_batch_fetch_compare_status(app: App): send_set = set() for platform_target, posts in res2: for post in posts: - send_set.add((platform_target, post.text)) + send_set.add((platform_target, post.content)) assert len(send_set) == 3 assert (TargetQQGroup(group_id=123), "off") in send_set assert (TargetQQGroup(group_id=123), "on") in send_set diff --git a/tests/platforms/test_rss.py b/tests/platforms/test_rss.py index f8bd103..7821088 100644 --- a/tests/platforms/test_rss.py +++ b/tests/platforms/test_rss.py @@ -46,7 +46,7 @@ def update_time_feed_1(): root = ET.fromstring(file) item = root.find("channel/item") current_time = datetime.now(pytz.timezone("GMT")).strftime("%a, %d %b %Y %H:%M:%S %Z") - assert item + assert item is not None pubdate_elem = item.find("pubDate") assert pubdate_elem is not None pubdate_elem.text = current_time @@ -85,8 +85,9 @@ async def test_fetch_new_1( assert len(res2[0][1]) == 1 post1 = res2[0][1][0] assert post1.url == "https://twitter.com/ArknightsStaff/status/1659091539023282178" + assert post1.title is None assert ( - post1.text + post1.content == "【#統合戦略】 引き続き新テーマ「ミヅキと紺碧の樹」の新要素及びシステムの変更点を一部ご紹介します!" " 今回は「灯火」、「ダイス」、「記号認識」、「鍵」についてです。詳細は添付の画像をご確認ください。" "#アークナイツ https://t.co/ARmptV0Zvu" @@ -114,9 +115,8 @@ async def test_fetch_new_2( assert len(res2[0][1]) == 1 post1 = res2[0][1][0] assert post1.url == "http://www.ruanyifeng.com/blog/2023/05/weekly-issue-255.html" - assert ( - post1.text == "科技爱好者周刊(第 255 期):对待 AI 的正确态度\n\n这里记录每周值得分享的科技内容,周五发布。..." - ) + assert post1.title == "科技爱好者周刊(第 255 期):对待 AI 的正确态度" + assert post1.content == "这里记录每周值得分享的科技内容,周五发布。..." @pytest.fixture() @@ -150,7 +150,8 @@ async def test_fetch_new_3( assert len(res2[0][1]) == 1 post1 = res2[0][1][0] assert post1.url == "https://github.com/R3nzTheCodeGOD/R3nzSkin/releases/tag/v3.0.9" - assert post1.text == "R3nzSkin\n\nNo content." + assert post1.title == "R3nzSkin" + assert post1.content == "No content." @pytest.mark.asyncio @@ -173,7 +174,7 @@ async def test_fetch_new_4( assert len(res2[0][1]) == 1 post1 = res2[0][1][0] assert post1.url == "https://wallhaven.cc/w/85rjej" - assert post1.text == "85rjej.jpg" + assert post1.content == "85rjej.jpg" def test_similar_text_process(): diff --git a/tests/platforms/test_weibo.py b/tests/platforms/test_weibo.py index ee6f7cb..2419604 100644 --- a/tests/platforms/test_weibo.py +++ b/tests/platforms/test_weibo.py @@ -41,6 +41,7 @@ async def test_get_name(weibo): @pytest.mark.asyncio @respx.mock async def test_fetch_new(weibo, dummy_user_subinfo): + from nonebot_bison.post import Post from nonebot_bison.types import Target, SubUnit ak_list_router = respx.get("https://m.weibo.cn/api/container/getIndex?containerid=1076036279793937") @@ -64,12 +65,13 @@ async def test_fetch_new(weibo, dummy_user_subinfo): res3 = await weibo.fetch_new_post(SubUnit(target, [dummy_user_subinfo])) assert len(res3[0][1]) == 1 assert not detail_router.called - post = res3[0][1][0] - assert post.target_type == "weibo" - assert post.text == "#明日方舟#\nSideStory「沃伦姆德的薄暮」复刻现已开启! " + post: Post = res3[0][1][0] + assert post.platform.platform_name == "weibo" + assert post.content == "#明日方舟#\nSideStory「沃伦姆德的薄暮」复刻现已开启! " assert post.url == "https://weibo.com/6279793937/KkBtUx2dv" - assert post.target_name == "明日方舟Arknights" - assert len(post.pics) == 1 + assert post.nickname == "明日方舟Arknights" + assert post.images + assert len(post.images) == 1 @pytest.mark.asyncio @@ -93,7 +95,7 @@ async def test_parse_long(weibo): detail_router.mock(return_value=Response(200, text=get_file("weibo_detail_4645748019299849"))) raw_post = get_json("weibo_ak_list_1.json")["data"]["cards"][0] post = await weibo.parse(raw_post) - assert "全文" not in post.text + assert "全文" not in post.content assert detail_router.called diff --git a/tests/post/test_generate.py b/tests/post/test_generate.py new file mode 100644 index 0000000..2f27125 --- /dev/null +++ b/tests/post/test_generate.py @@ -0,0 +1,111 @@ +from time import time +from typing import Any + +import pytest +from nonebug.app import App +from httpx import AsyncClient + +now = time() +passed = now - 3 * 60 * 60 + +raw_post_list_1 = [{"id": 1, "text": "p1", "date": now, "tags": ["tag1"], "category": 1}] + +raw_post_list_2 = raw_post_list_1 + [ + {"id": 2, "text": "p2", "date": now, "tags": ["tag1"], "category": 1}, + {"id": 3, "text": "p3", "date": now, "tags": ["tag2"], "category": 2}, + {"id": 4, "text": "p4", "date": now, "tags": ["tag2"], "category": 3}, +] + + +@pytest.fixture() +def mock_platform(app: App): + from nonebot_bison.post import Post + from nonebot_bison.types import Target, RawPost + from nonebot_bison.platform.platform import NewMessage + + class MockPlatform(NewMessage): + platform_name = "mock_platform" + name = "Mock Platform" + enabled = True + is_common = True + schedule_interval = 10 + enable_tag = False + categories = {} + has_target = True + + sub_index = 0 + + @classmethod + async def get_target_name(cls, client, _: "Target"): + return "MockPlatform" + + def get_id(self, post: "RawPost") -> Any: + return post["id"] + + def get_date(self, raw_post: "RawPost") -> float: + return raw_post["date"] + + async def parse(self, raw_post: "RawPost") -> "Post": + return Post( + self, + raw_post["text"], + url="http://t.tt/" + str(self.get_id(raw_post)), + nickname="Mock", + ) + + @classmethod + async def get_sub_list(cls, _: "Target"): + if cls.sub_index == 0: + cls.sub_index += 1 + return raw_post_list_1 + else: + return raw_post_list_2 + + return MockPlatform + + +@pytest.mark.asyncio +async def test_generate_msg(mock_platform): + from nonebot_plugin_saa import Text, Image + + from nonebot_bison.post import Post + from nonebot_bison.utils import ProcessContext + from nonebot_bison.plugin_config import plugin_config + + post: Post = await mock_platform(ProcessContext(), AsyncClient()).parse(raw_post_list_1[0]) + assert post.platform.default_theme == "basic" + res = await post.generate() + assert len(res) == 1 + assert isinstance(res[0], Text) + assert str(res[0]) == "p1\n来源: Mock Platform Mock\n详情: http://t.tt/1" + + post.platform.default_theme = "ht2i" + assert post.get_config_theme() is None + assert set(post.get_priority_themes()) == {"basic", "ht2i"} + assert post.get_priority_themes()[0] == "ht2i" + res1 = await post.generate() + assert isinstance(res1[0], Image) + + plugin_config.bison_theme_use_browser = False + + res3 = await post.generate() + assert res3[0] + assert isinstance(res3[0], Text) + + +@pytest.mark.asyncio +@pytest.mark.render +async def test_msg_segments_convert(mock_platform): + from nonebot_plugin_saa import Image + + from nonebot_bison.post import Post + from nonebot_bison.utils import ProcessContext + from nonebot_bison.plugin_config import plugin_config + + plugin_config.bison_use_pic = True + + post: Post = await mock_platform(ProcessContext(), AsyncClient()).parse(raw_post_list_1[0]) + assert post.platform.default_theme == "basic" + res = await post.generate_messages() + assert len(res) == 1 + assert isinstance(res[0][0], Image) diff --git a/tests/test_custom_post.py b/tests/test_custom_post.py deleted file mode 100644 index 1baac4e..0000000 --- a/tests/test_custom_post.py +++ /dev/null @@ -1,58 +0,0 @@ -from pathlib import Path - -import respx -import pytest -from httpx import Response -from nonebug.app import App - - -@pytest.fixture() -def ms_list(): - from nonebot_plugin_saa import Text, Image, MessageSegmentFactory - - msg_segments: list[MessageSegmentFactory] = [] - msg_segments.append(Text("【Zc】每早合约日替攻略!")) - msg_segments.append( - Image( - image="http://i0.hdslb.com/bfs/live/new_room_cover/cf7d4d3b2f336c6dba299644c3af952c5db82612.jpg", - ) - ) - msg_segments.append(Text("来源: Bilibili直播 魔法Zc目录")) - msg_segments.append(Text("详情: https://live.bilibili.com/3044248")) - - return msg_segments - - -@pytest.fixture() -def expected_md(): - return ( - "【Zc】每早合约日替攻略!\n来源:" - " Bilibili直播 魔法Zc目录详情: https://live.bilibili.com/3044248" - ) - - -def test_gene_md(app: App, expected_md, ms_list): - from nonebot_bison.post.custom_post import CustomPost - - cp = CustomPost(ms_factories=ms_list) - cp_md = cp._generate_md() - assert cp_md == expected_md - - -@respx.mock -@pytest.mark.asyncio -async def test_gene_pic(app: App, ms_list, expected_md): - from nonebot_bison.post.custom_post import CustomPost - - pic_router = respx.get("http://i0.hdslb.com/bfs/live/new_room_cover/cf7d4d3b2f336c6dba299644c3af952c5db82612.jpg") - - pic_path = Path(__file__).parent / "platforms" / "static" / "custom_post_pic.jpg" - with open(pic_path, mode="rb") as f: - mock_pic = f.read() - - pic_router.mock(return_value=Response(200, stream=mock_pic)) # type: ignore - - cp = CustomPost(ms_factories=ms_list) - cp_pic_msg_md: str = cp._generate_md() - - assert cp_pic_msg_md == expected_md diff --git a/tests/test_merge_pic.py b/tests/test_merge_pic.py index 636e843..2581e1d 100644 --- a/tests/test_merge_pic.py +++ b/tests/test_merge_pic.py @@ -65,60 +65,52 @@ async def downloaded_resource_2(): @pytest.mark.external @flaky async def test_9_merge(app: App, downloaded_resource: list[bytes]): - from nonebot_bison.post import Post + from nonebot_bison.utils import pic_merge, http_client - post = Post("", "", "", pics=list(downloaded_resource)) - await post._pic_merge() - assert len(post.pics) == 5 - await post.generate_messages() + pics = await pic_merge(list(downloaded_resource), http_client()) + assert len(pics) == 5 @pytest.mark.external @flaky async def test_9_merge_2(app: App, downloaded_resource_2: list[bytes]): - from nonebot_bison.post import Post + from nonebot_bison.utils import pic_merge, http_client - post = Post("", "", "", pics=list(downloaded_resource_2)) - await post._pic_merge() - assert len(post.pics) == 4 - await post.generate_messages() + pics = await pic_merge(list(downloaded_resource_2), http_client()) + assert len(pics) == 4 @pytest.mark.external @flaky async def test_6_merge(app: App, downloaded_resource: list[bytes]): - from nonebot_bison.post import Post + from nonebot_bison.utils import pic_merge, http_client - post = Post("", "", "", pics=list(downloaded_resource[0:6] + downloaded_resource[9:])) - await post._pic_merge() - assert len(post.pics) == 5 + pics = await pic_merge(list(downloaded_resource[0:6] + downloaded_resource[9:]), http_client()) + assert len(pics) == 5 @pytest.mark.external @flaky async def test_3_merge(app: App, downloaded_resource: list[bytes]): - from nonebot_bison.post import Post + from nonebot_bison.utils import pic_merge, http_client - post = Post("", "", "", pics=list(downloaded_resource[0:3] + downloaded_resource[9:])) - await post._pic_merge() - assert len(post.pics) == 5 + pics = await pic_merge(list(downloaded_resource[0:3] + downloaded_resource[9:]), http_client()) + assert len(pics) == 5 @pytest.mark.external @flaky async def test_6_merge_only(app: App, downloaded_resource: list[bytes]): - from nonebot_bison.post import Post + from nonebot_bison.utils import pic_merge, http_client - post = Post("", "", "", pics=list(downloaded_resource[0:6])) - await post._pic_merge() - assert len(post.pics) == 1 + pics = await pic_merge(list(downloaded_resource[0:6]), http_client()) + assert len(pics) == 1 @pytest.mark.external @flaky async def test_3_merge_only(app: App, downloaded_resource: list[bytes]): - from nonebot_bison.post import Post + from nonebot_bison.utils import pic_merge, http_client - post = Post("", "", "", pics=list(downloaded_resource[0:3])) - await post._pic_merge() - assert len(post.pics) == 1 + pics = await pic_merge(list(downloaded_resource[0:3]), http_client()) + assert len(pics) == 1 diff --git a/tests/test_render.py b/tests/test_render.py index aad9677..a5229a7 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -20,3 +20,18 @@ async def test_render(app: App): "并将其转换成一个完整的单页应用(SPA),其他的页面则会只在用户浏览到的时候才按需加载。" ) assert isinstance(res, Image) + + +@pytest.mark.asyncio +@pytest.mark.render +async def test_convert(app: App): + from nonebot_plugin_saa import Text, Image + + from nonebot_bison.utils import text_to_image + from nonebot_bison.plugin_config import plugin_config + + plugin_config.bison_use_pic = True + + text = Text("如果,生命的脚印终有一天会被时间的尘埃掩埋......那我们就永远不能——停下脚步") + res = await text_to_image(text) + assert isinstance(res, Image) diff --git a/tests/theme/test_registry.py b/tests/theme/test_registry.py new file mode 100644 index 0000000..99f57cd --- /dev/null +++ b/tests/theme/test_registry.py @@ -0,0 +1,33 @@ +from typing import Literal + +import pytest +from nonebug import App + + +@pytest.mark.asyncio +async def test_registry_new_theme(app: App): + from nonebot_bison.theme import Theme, ThemeRegistrationError, theme_manager + + class MockTheme(Theme): + name: Literal["mock_theme"] = "mock_theme" + + async def render(self, _): + return "" + + assert len(theme_manager) == 5 + assert "arknights" in theme_manager + assert "basic" in theme_manager + assert "brief" in theme_manager + assert "ceobecanteen" in theme_manager + assert "ht2i" in theme_manager + assert "mock_theme" not in theme_manager + + theme_manager.register(MockTheme()) + assert len(theme_manager) == 6 + assert "mock_theme" in theme_manager + + # duplicated registration + with pytest.raises(ThemeRegistrationError, match="duplicate"): + theme_manager.register(MockTheme()) + + theme_manager.unregister("mock_theme") diff --git a/tests/theme/test_themes.py b/tests/theme/test_themes.py new file mode 100644 index 0000000..addff30 --- /dev/null +++ b/tests/theme/test_themes.py @@ -0,0 +1,222 @@ +from time import time +from typing import Any + +import pytest +from flaky import flaky +from nonebug import App +from httpx import AsyncClient + +now = time() +passed = now - 3 * 60 * 60 +raw_post_list_1 = [{"id": 1, "text": "p1", "date": now, "tags": ["tag1"], "category": 1}] + +raw_post_list_2 = raw_post_list_1 + [ + {"id": 2, "text": "p2", "date": now, "tags": ["tag1"], "category": 1}, + {"id": 3, "text": "p3", "date": now, "tags": ["tag2"], "category": 2}, + {"id": 4, "text": "p4", "date": now, "tags": ["tag2"], "category": 3}, +] + + +@pytest.fixture() +def mock_platform(app: App): + from nonebot_bison.post import Post + from nonebot_bison.types import Target, RawPost + from nonebot_bison.platform.platform import NewMessage + + class MockPlatform(NewMessage): + platform_name = "mock_platform" + name = "Mock Platform" + enabled = True + is_common = True + schedule_interval = 10 + enable_tag = False + categories = {} + has_target = True + + sub_index = 0 + + @classmethod + async def get_target_name(cls, client, _: "Target"): + return "MockPlatform" + + def get_id(self, post: "RawPost") -> Any: + return post["id"] + + def get_date(self, raw_post: "RawPost") -> float: + return raw_post["date"] + + async def parse(self, raw_post: "RawPost") -> "Post": + return Post( + self, + raw_post["text"], + url="http://t.tt/" + str(self.get_id(raw_post)), + nickname="Mock", + ) + + @classmethod + async def get_sub_list(cls, _: "Target"): + if cls.sub_index == 0: + cls.sub_index += 1 + return raw_post_list_1 + else: + return raw_post_list_2 + + return MockPlatform + + +@pytest.fixture() +def mock_post(app: App, mock_platform): + from nonebot_bison.post import Post + from nonebot_bison.utils import ProcessContext + + return Post( + mock_platform(ProcessContext(), AsyncClient()), + "text", + title="title", + images=["http://t.tt/1.jpg"], + timestamp=1234567890, + url="http://t.tt/1", + avatar="http://t.tt/avatar.jpg", + nickname="Mock", + description="description", + ) + + +@pytest.mark.asyncio +async def test_theme_need_browser(app: App, mock_post): + from nonebot_bison.theme import Theme, theme_manager + + class MockTheme(Theme): + name: str = "mock_theme" + need_browser: bool = False + + async def render(self, post): + return [] + + theme = MockTheme() + theme_manager.register(theme) + mock_post.platform.default_theme = theme.name + + await theme.do_render(mock_post) + assert not theme._browser_checked + theme.need_browser = True + await theme.do_render(mock_post) + assert theme._browser_checked + + theme_manager.unregister(theme.name) + + +@pytest.mark.asyncio +async def test_theme_no_enable_use_browser(app: App, mock_post): + from nonebot_bison.plugin_config import plugin_config + + plugin_config.bison_theme_use_browser = False + + from nonebot_bison.theme import Theme, ThemeRenderUnsupportError, theme_manager + + class MockTheme(Theme): + name: str = "mock_theme" + need_browser: bool = True + + async def render(self, post): + return [] + + theme = MockTheme() + theme_manager.register(theme) + mock_post.platform.default_theme = theme.name + with pytest.raises(ThemeRenderUnsupportError, match="not support render"): + await theme.do_render(mock_post) + + theme_manager.unregister(theme.name) + plugin_config.bison_theme_use_browser = True + + +@pytest.mark.asyncio +@flaky(max_runs=3, min_passes=1) +async def test_arknights_theme(app: App, mock_post): + from nonebot_plugin_saa import Image + + from nonebot_bison.theme import theme_manager + from nonebot_bison.theme.themes.arknights import ArknightsTheme + + arknights_theme = theme_manager["arknights"] + + assert isinstance(arknights_theme, ArknightsTheme) + assert arknights_theme.name == "arknights" + res = await arknights_theme.render(mock_post) + assert len(res) == 1 + assert isinstance(res[0], Image) + + +@pytest.mark.asyncio +async def test_basic_theme(app: App, mock_post): + from nonebot_plugin_saa import Text, Image + + from nonebot_bison.theme import theme_manager + from nonebot_bison.theme.themes.basic import BasicTheme + + basic_theme = theme_manager["basic"] + + assert isinstance(basic_theme, BasicTheme) + assert basic_theme.name == "basic" + res = await basic_theme.render(mock_post) + assert len(res) == 2 + assert res[0] == Text("title\n\ntext\n来源: Mock Platform Mock\n详情: http://t.tt/1") + assert isinstance(res[1], Image) + + +@pytest.mark.asyncio +async def test_brief_theme(app: App, mock_post): + from nonebot_plugin_saa import Text, Image + + from nonebot_bison.theme import theme_manager + from nonebot_bison.theme.themes.brief import BriefTheme + + brief_theme = theme_manager["brief"] + + assert isinstance(brief_theme, BriefTheme) + assert brief_theme.name == "brief" + res = await brief_theme.render(mock_post) + assert len(res) == 2 + assert res[0] == Text("title\n\n来源: Mock Platform Mock\n详情: http://t.tt/1") + assert isinstance(res[1], Image) + + +@pytest.mark.render +@pytest.mark.asyncio +@flaky(max_runs=3, min_passes=1) +async def test_ceobecanteen_theme(app: App, mock_post): + from nonebot_plugin_saa import Text, Image + + from nonebot_bison.theme import theme_manager + from nonebot_bison.theme.themes.ceobe_canteen import CeobeCanteenTheme + + ceobecanteen_theme = theme_manager["ceobecanteen"] + + assert isinstance(ceobecanteen_theme, CeobeCanteenTheme) + assert ceobecanteen_theme.name == "ceobecanteen" + res = await ceobecanteen_theme.render(mock_post) + assert len(res) == 3 + assert isinstance(res[0], Image) + assert isinstance(res[2], Image) + assert res[1] == Text("来源: Mock Platform Mock\n详情: http://t.tt/1") + + +@pytest.mark.render +@pytest.mark.asyncio +@flaky(max_runs=3, min_passes=1) +async def test_ht2i_theme(app: App, mock_post): + from nonebot_plugin_saa import Text, Image + + from nonebot_bison.theme import theme_manager + from nonebot_bison.theme.themes.ht2i import Ht2iTheme + + ht2i_theme = theme_manager["ht2i"] + + assert isinstance(ht2i_theme, Ht2iTheme) + assert ht2i_theme.name == "ht2i" + res = await ht2i_theme.render(mock_post) + assert len(res) == 3 + assert isinstance(res[0], Image) + assert isinstance(res[2], Image) + assert res[1] == Text("详情: http://t.tt/1")