添加 Theme 功能

This commit is contained in:
Azide
2023-10-13 23:18:39 +08:00
committed by felinae98
parent 6aaec45d15
commit f202071e9f
57 changed files with 1709 additions and 802 deletions
+2 -1
View File
@@ -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",
]
+87 -69
View File
@@ -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,
)
+16 -11
View File
@@ -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,
)
+3 -2
View File
@@ -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官方公告")
+4 -4
View File
@@ -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]:
+2 -2
View File
@@ -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)
+2
View File
@@ -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
+11 -10
View File
@@ -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"],
)
+4 -4
View File
@@ -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"],
)
+12 -4
View File
@@ -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:
+1 -3
View File
@@ -1,3 +1 @@
from .post import Post
__all__ = ["Post"]
from .post import Post as Post
+41 -42
View File
@@ -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
-68
View File
@@ -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}<br>"
case Image(data={"image": image}):
# use onebot v11 to convert image into url
ob11_image = MessageSegment.image(image)
md += "![Image]({})\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
+72 -139
View File
@@ -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__}")
@@ -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 <florian.wolters.85@googlemail.com>
* @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;
}
+22
View File
@@ -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__)
+36
View File
@@ -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 <b><u>{theme.name}</u></b> requires browser, but not allowed")
self.__themes[theme.name] = theme
logger.opt(colors=True).success(f"Theme <b><u>{theme.name}</u></b> 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 <b><u>{theme_name}</u></b> 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()
@@ -0,0 +1,3 @@
from .build import ArknightsTheme
__theme_meta__ = ArknightsTheme()
@@ -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)]
@@ -15,15 +15,15 @@
<div class="main">
<div class="container">
<div class="standerd-container">
{% if bannerImageUrl %}
{% if data.banner_image_url %}
<div class="banner-image-container">
<img class="banner-image" src="{{ bannerImageUrl }}" />
<img class="banner-image" src="{{ data.banner_image_url }}" />
</div>
{% endif %}
<div class="head-title-container">
<span class="head-title">{{ announce_title }}</span>
<span class="head-title">{{ data.announce_title }}</span>
</div>
<div class="content">{{ content }}</div>
<div class="content">{{ data.content }}</div>
</div>
</div>
</div>
@@ -0,0 +1,3 @@
from .build import BasicTheme
__theme_meta__ = BasicTheme()
+40
View File
@@ -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
@@ -0,0 +1,3 @@
from .build import BriefTheme
__theme_meta__ = BriefTheme()
+32
View File
@@ -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
@@ -0,0 +1,10 @@
# Jinja模版与LOGO图片说明
## LOGO图片
- `templates/ceobecanteen_logo.png`
### 版权声明
<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt="知识共享许可协议" style="border-width:0" src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png" /></a><br />logo图片采用<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议</a>进行许可。
本项目<img src="templates/ceobecanteen_logo.png" style="width:100px">使用已经过 [Ceobe Canteen](https://github.com/Enraged-Dun-Cookie-Development-Team) 授权许可使用。
@@ -0,0 +1,3 @@
from .build import CeobeCanteenTheme
__theme_meta__ = CeobeCanteenTheme()
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>小刻食堂分享卡片</title>
</head>
<body>
<div id="ceobecanteen-card">
{% if card.content.image %}
<img src="{{ card.content.image }}" class="cover-img">
{% endif %}
{% if card.content.text %}
<div class="main-content">{{ card.content.text }}</div>
{% endif %}
<div class="footer">
<div class="datasource">
<div class="datasource-text">
<div class="datasource-name">{{ card.info.datasource }}</div>
<div class="time">{{ card.info.time }}</div>
</div>
<img class='qr' src="{{ card.qr }}">
</div>
<div class="source">
<img class='bison-logo' src="bison_logo.jpg">
<div class="source-text">
<div class="slogan">小刻吃到饼啦!</div>
<div class="linkage">bison&amp;小刻食堂联动</div>
<div class="description">来自小刻食堂再转发</div>
</div>
<img class='ceobe-logo' src="ceobecanteen_logo.png">
</div>
</div>
</div>
</body>
</html>
<style type="text/css">
#ceobecanteen-card {
width: 700px;
background-color: rgb(240, 236, 233);
}
.cover-img {
width: 100%;
}
.main-content {
padding: 30px;
white-space: pre-line;
}
.footer {
margin: 0 2%;
height: 80px;
border-top: 1px solid rgb(185, 181, 177);
display: flex;
padding: 10px 0;
}
.footer .datasource {
width: 45%;
padding-right: 5px;
border-right: 1px solid rgb(185, 181, 177);
display: flex;
justify-content: space-around;
}
.footer .datasource .datasource-text {
align-self: center;
}
.footer .datasource .datasource-text .datasource-name {
font-weight: bold;
font-size: 24px;
}
.footer .datasource .datasource-text .time {
font-size: 13px;
color: rgb(138, 136, 134);
}
.footer .datasource .qr {
width: 80px;
object-fit: contain;
}
.source {
width: 55%;
display: flex;
justify-content: space-evenly;
color: rgb(138, 136, 134);
font-size: 13px;
}
.footer .source .source-text {
align-self: center;
}
.source .slogan {
font-weight: bold;
font-size: 24px;
color: black;
}
.source .bison-logo,
.source .ceobe-logo {
width: 60px;
object-fit: contain;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

@@ -0,0 +1,3 @@
from .build import Ht2iTheme
__theme_meta__ = Ht2iTheme()
+50
View File
@@ -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
+78
View File
@@ -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
+22
View File
@@ -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")
+5
View File
@@ -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",
]
+110
View File
@@ -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)