mirror of
https://github.com/suyiiyii/nonebot-bison.git
synced 2026-05-09 10:17:56 +08:00
✨ 添加 Theme 功能
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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官方公告")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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 +1 @@
|
||||
from .post import Post
|
||||
|
||||
__all__ = ["Post"]
|
||||
from .post import Post as Post
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 += "\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
@@ -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;
|
||||
}
|
||||
@@ -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__)
|
||||
@@ -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)]
|
||||
+4
-4
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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&小刻食堂联动</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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user