🐛 B站蹲饼修复 (#525)

*  使用新接口

* ♻️ 调整刷新逻辑

* 🐛 调整刷新逻辑

* ♻️ 将单个哔哩哔哩文件拆开

* 🐛 修修补补边界情况

*  添加UID:xxx匹配

*  调整测试中的导入

*  调整测试的断言

* 🐛 添加unicode字符的escape

*  不再主动刷新cookies

* 🔀 适配新版Site

* 🐛 解析live_rcmd中的json string

* 🚨 make ruff happy

* 🐛 调整并测试bilibili retry函数

*  修正测试

* ♻️ 按review意见调整

* ♻️ 清理一些遗留的复杂写法

* ♻️ 移出函数内的NameTuple

* 🔇 删除不必要的日志输出

Co-authored-by: felinae98 <731499577@qq.com>

* Update nonebot_bison/platform/bilibili/scheduler.py

* Update scheduler.py

---------

Co-authored-by: felinae98 <731499577@qq.com>
This commit is contained in:
Azide 2024-06-17 13:24:04 +08:00 committed by GitHub
parent 9729d4b776
commit af72b6c3d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 6387 additions and 7213 deletions

View File

@ -0,0 +1,7 @@
from .platforms import Bilibili as Bilibili
from .platforms import Bilibililive as Bilibililive
from .scheduler import BilibiliSite as BilibiliSite
from .scheduler import BililiveSite as BililiveSite
from .platforms import BilibiliBangumi as BilibiliBangumi
from .scheduler import BiliBangumiSite as BiliBangumiSite
from .scheduler import BilibiliClientManager as BilibiliClientManager

View File

@ -0,0 +1,394 @@
from typing import Any, Literal, TypeVar, TypeAlias
from pydantic import BaseModel
from nonebot.compat import PYDANTIC_V2, ConfigDict
from nonebot_bison.compat import model_rebuild
TBaseModel = TypeVar("TBaseModel", bound=type[BaseModel])
# 不能当成装饰器用
# 当装饰器用时global namespace 中还没有被装饰的类,会报错
def model_rebuild_recurse(cls: TBaseModel) -> TBaseModel:
"""Recursively rebuild all BaseModel subclasses in the class."""
if not PYDANTIC_V2:
from inspect import isclass, getmembers
for _, sub_cls in getmembers(cls, lambda x: isclass(x) and issubclass(x, BaseModel)):
model_rebuild_recurse(sub_cls)
model_rebuild(cls)
return cls
class Base(BaseModel):
if PYDANTIC_V2:
model_config = ConfigDict(from_attributes=True)
else:
class Config:
orm_mode = True
class APIBase(Base):
"""Bilibili API返回的基础数据"""
code: int
message: str
class UserAPI(APIBase):
class Card(Base):
name: str
class Data(Base):
card: "UserAPI.Card"
data: Data | None = None
DynamicType = Literal[
"DYNAMIC_TYPE_ARTICLE",
"DYNAMIC_TYPE_AV",
"DYNAMIC_TYPE_WORD",
"DYNAMIC_TYPE_DRAW",
"DYNAMIC_TYPE_FORWARD",
"DYNAMIC_TYPE_LIVE",
"DYNAMIC_TYPE_LIVE_RCMD",
"DYNAMIC_TYPE_PGC",
"DYNAMIC_TYPE_PGC_UNION",
"DYNAMIC_TYPE_NONE", # 已删除的动态,一般只会出现在转发动态的源动态被删除
"DYNAMIC_TYPE_COMMON_SQUARE",
"DYNAMIC_TYPE_COMMON_VERTICAL",
"DYNAMIC_TYPE_COURSES_SEASON",
]
# 参考 https://github.com/Yun-Shan/bilibili-dynamic
class PostAPI(APIBase):
class Basic(Base):
rid_str: str
"""可能含义是referrer id表示引用的对象的ID
已知专栏动态时该ID与专栏ID一致视频动态时与av号一致
"""
class Modules(Base):
class Author(Base):
face: str
mid: int
name: str
jump_url: str
pub_ts: int
type: Literal["AUTHOR_TYPE_NORMAL", "AUTHOR_TYPE_PGC"]
"""作者类型一般情况下都是NORMAL番剧推送是PGC"""
class Additional(Base):
type: str
"""用户发视频时同步发布的动态带图片: ADDITIONAL_TYPE_UGC
显示相关游戏: ADDITIONAL_TYPE_COMMON
显示预约: ADDITIONAL_TYPE_RESERVE
显示投票: ADDITIONAL_TYPE_VOTE
显示包月充电专属抽奖: ADDITIONAL_TYPE_UPOWER_LOTTERY
显示赛事时(暂时只看到回放的理论上直播时应该也是这个): ADDITIONAL_TYPE_MATCH
"""
class Desc(Base):
rich_text_nodes: list[dict[str, Any]]
"""描述的富文本节点,组成动态的各种内容
一个可能通用的结构:
```json
[
{
"jump_url": "//search.bilibili.com/all?keyword=鸣潮公测定档",
"orig_text": "#鸣潮公测定档#",
"text": "#鸣潮公测定档#",
"type": "RICH_TEXT_NODE_TYPE_TOPIC"
},
//...
]
```
"""
text: str
"""描述的纯文本内容"""
class Dynamic(Base):
additional: "PostAPI.Modules.Additional | None" = None
desc: "PostAPI.Modules.Desc | None" = None
"""动态描述,可能为空"""
major: "Major | None" = None
"""主要内容,可能为空"""
module_author: "PostAPI.Modules.Author"
module_dynamic: "PostAPI.Modules.Dynamic"
class Topic(Base):
id: int
name: str
jump_url: str
class Item(Base):
basic: "PostAPI.Basic"
id_str: str
modules: "PostAPI.Modules"
orig: "PostAPI.Item | None" = None
topic: "PostAPI.Topic | None" = None
type: DynamicType
class DeletedItem(Base):
basic: "PostAPI.Basic"
id_str: None
modules: "PostAPI.Modules"
type: Literal["DYNAMIC_TYPE_NONE"]
class Data(Base):
items: "list[PostAPI.Item | PostAPI.DeletedItem] | None" = None
data: "PostAPI.Data | None" = None
class VideoMajor(Base):
class Archive(Base):
aid: str
bvid: str
title: str
desc: str
"""视频简介,太长的话会被截断"""
cover: str
jump_url: str
type: Literal["MAJOR_TYPE_ARCHIVE"]
archive: "VideoMajor.Archive"
class LiveRecommendMajor(Base):
class LiveRecommand(Base):
content: str
"""直播卡片的内容值为JSON文本可以解析为LiveRecommendMajor.Content"""
class Content(Base):
type: int
"""直播类型"""
live_play_info: "LiveRecommendMajor.LivePlayInfo"
class LivePlayInfo(Base):
uid: int
"""用户UID不是直播间号"""
room_type: int
"""房间类型"""
room_paid_type: int
"""付费类型"""
play_type: int
"""播放类型?"""
live_status: int
"""直播状态"""
live_screen_type: int
"""直播画面类型?"""
room_id: int
"""直播间号"""
cover: str
"""直播封面"""
title: str
online: int
"""开播时长?"""
parent_area_id: int
"""主分区ID"""
parent_area_name: str
"""主分区名称"""
area_id: int
"""分区ID"""
area_name: str
"""分区名称"""
live_start_time: int
"""开播时间戳"""
link: str
"""跳转链接,相对协议(即//开头而不是https://开头)"""
live_id: str
"""直播ID不知道有什么用"""
watched_show: "LiveRecommendMajor.WatchedShow"
class WatchedShow(Base):
num: int
"""观看人数"""
text_small: str
"""观看人数的文本描述: 例如 1.2万"""
text_large: str
"""观看人数的文本描述: 例如 1.2万人看过"""
switch: bool
"""未知"""
icon: str
"""观看文本前的图标"""
icon_web: str
"""观看文本前的图标(网页版)"""
icon_location: str
"""图标位置?"""
type: Literal["MAJOR_TYPE_LIVE_RCMD"]
live_rcmd: "LiveRecommendMajor.LiveRecommand"
class LiveMajor(Base):
class Live(Base):
id: int
"""直播间号"""
title: str
live_state: int
"""直播状态1为直播中0为未开播"""
cover: str
desc_first: str
"""直播信息的第一部分,用来显示分区"""
desc_second: str
jump_url: str
"""跳转链接,目前用的是相对协议(即//开头而不是https://开头)"""
type: Literal["MAJOR_TYPE_LIVE"]
live: "LiveMajor.Live"
class ArticleMajor(Base):
class Article(Base):
id: int
"""专栏CID"""
title: str
desc: str
"""专栏简介"""
covers: list[str]
"""专栏封面,一般是一张图片"""
jump_url: str
type: Literal["MAJOR_TYPE_ARTICLE"]
article: "ArticleMajor.Article"
class DrawMajor(Base):
class Item(Base):
width: int
height: int
size: float
"""文件大小KiB1024"""
src: str
"""图片链接"""
class Draw(Base):
id: int
items: "list[DrawMajor.Item]"
type: Literal["MAJOR_TYPE_DRAW"]
draw: "DrawMajor.Draw"
class PGCMajor(Base):
"""番剧推送"""
class PGC(Base):
title: str
cover: str
jump_url: str
"""通常https://www.bilibili.com/bangumi/play/ep{epid}"""
epid: int
season_id: int
type: Literal["MAJOR_TYPE_PGC"]
pgc: "PGCMajor.PGC"
class OPUSMajor(Base):
"""通用图文内容"""
class Summary(Base):
rich_text_nodes: list[dict[str, Any]]
"""描述的富文本节点,组成动态的各种内容"""
text: str
class Pic(Base):
width: int
height: int
size: int
"""文件大小KiB1024"""
url: str
"""图片链接"""
class Opus(Base):
jump_url: str
title: str
summary: "OPUSMajor.Summary"
pics: "list[OPUSMajor.Pic]"
type: Literal["MAJOR_TYPE_OPUS"]
opus: "OPUSMajor.Opus"
class CommonMajor(Base):
"""还是通用图文内容
主要跟特殊官方功能有关系例如专属活动页会员购漫画赛事中心游戏中心小黑屋工房集市装扮等
"""
class Common(Base):
cover: str
"""卡片左侧图片的URL"""
title: str
desc: str
"""内容"""
jump_url: str
type: Literal["MAJOR_TYPE_COMMON"]
common: "CommonMajor.Common"
class CoursesMajor(Base):
"""课程推送"""
class Courses(Base):
title: str
sub_title: str
"""副标题,一般是课程的简介"""
desc: str
"""课时信息"""
cover: str
jump_url: str
id: int
"""课程ID"""
type: Literal["MAJOR_TYPE_COURSES"]
courses: "CoursesMajor.Courses"
class DeletedMajor(Base):
class None_(Base):
tips: str
type: Literal["MAJOR_TYPE_NONE"]
none: "DeletedMajor.None_"
class UnknownMajor(Base):
type: str
Major = (
VideoMajor
| LiveRecommendMajor
| LiveMajor
| ArticleMajor
| DrawMajor
| PGCMajor
| OPUSMajor
| CommonMajor
| CoursesMajor
| DeletedMajor
| UnknownMajor
)
DynRawPost: TypeAlias = PostAPI.Item
model_rebuild_recurse(VideoMajor)
model_rebuild_recurse(LiveRecommendMajor)
model_rebuild_recurse(LiveMajor)
model_rebuild_recurse(ArticleMajor)
model_rebuild_recurse(DrawMajor)
model_rebuild_recurse(PGCMajor)
model_rebuild_recurse(OPUSMajor)
model_rebuild_recurse(CommonMajor)
model_rebuild_recurse(CoursesMajor)
model_rebuild_recurse(UserAPI)
model_rebuild_recurse(PostAPI)

View File

@ -1,148 +1,86 @@
import re
import json
from copy import deepcopy
from functools import wraps
from enum import Enum, unique
from typing_extensions import Self
from datetime import datetime, timedelta
from typing import Any, TypeVar, TypeAlias, NamedTuple
from typing import TypeVar, NamedTuple
from collections.abc import Callable, Awaitable
from yarl import URL
from nonebot import logger
from httpx import AsyncClient
from nonebot.log import logger
from pydantic import Field, BaseModel
from nonebot.compat import PYDANTIC_V2, ConfigDict, type_validate_json, type_validate_python
from httpx import URL as HttpxURL
from pydantic import Field, BaseModel, ValidationError
from nonebot.compat import type_validate_json, type_validate_python
from ..post import Post
from ..compat import model_rebuild
from ..types import Tag, Target, RawPost, ApiError, Category
from ..utils import Site, ClientManager, http_client, text_similarity
from .platform import NewMessage, StatusChange, CategoryNotSupport, CategoryNotRecognize
from nonebot_bison.post.post import Post
from nonebot_bison.compat import model_rebuild
from nonebot_bison.utils import text_similarity, decode_unicode_escapes
from nonebot_bison.types import Tag, Target, RawPost, ApiError, Category
TBaseModel = TypeVar("TBaseModel", bound=type[BaseModel])
from .scheduler import BilibiliSite, BililiveSite, BiliBangumiSite
from ..platform import NewMessage, StatusChange, CategoryNotSupport, CategoryNotRecognize
from .models import (
PostAPI,
UserAPI,
PGCMajor,
DrawMajor,
LiveMajor,
OPUSMajor,
DynRawPost,
VideoMajor,
CommonMajor,
DynamicType,
ArticleMajor,
CoursesMajor,
UnknownMajor,
LiveRecommendMajor,
)
B = TypeVar("B", bound="Bilibili")
MAX_352_RETRY_COUNT = 3
# 不能当成装饰器用
# 当装饰器用时global namespace 中还没有被装饰的类,会报错
def model_rebuild_recurse(cls: TBaseModel) -> TBaseModel:
"""Recursively rebuild all BaseModel subclasses in the class."""
if not PYDANTIC_V2:
from inspect import isclass, getmembers
for _, sub_cls in getmembers(cls, lambda x: isclass(x) and issubclass(x, BaseModel)):
model_rebuild_recurse(sub_cls)
model_rebuild(cls)
return cls
class ApiCode352Error(Exception):
def __init__(self, url: HttpxURL) -> None:
msg = f"api {url} error"
super().__init__(msg)
class Base(BaseModel):
if PYDANTIC_V2:
model_config = ConfigDict(from_attributes=True)
else:
def retry_for_352(func: Callable[[B, Target], Awaitable[list[DynRawPost]]]):
retried_times = 0
class Config:
orm_mode = True
class APIBase(Base):
"""Bilibili API返回的基础数据"""
code: int
message: str
class UserAPI(APIBase):
class Card(Base):
name: str
class Data(Base):
card: "UserAPI.Card"
data: Data | None = None
class PostAPI(APIBase):
class Info(Base):
uname: str
class UserProfile(Base):
info: "PostAPI.Info"
class Origin(Base):
uid: int
dynamic_id: int
dynamic_id_str: str
timestamp: int
type: int
rid: int
bvid: str | None = None
class Desc(Base):
dynamic_id: int
dynamic_id_str: str
timestamp: int
type: int
user_profile: "PostAPI.UserProfile"
rid: int
bvid: str | None = None
origin: "PostAPI.Origin | None" = None
class Card(Base):
desc: "PostAPI.Desc"
card: str
class Data(Base):
cards: "list[PostAPI.Card] | None"
data: Data | None = None
DynRawPost: TypeAlias = PostAPI.Card
model_rebuild_recurse(UserAPI)
model_rebuild_recurse(PostAPI)
class BilibiliClient(ClientManager):
_client: AsyncClient
_refresh_time: datetime
cookie_expire_time = timedelta(hours=5)
def __init__(self) -> None:
self._client = http_client()
self._refresh_time = datetime(year=2000, month=1, day=1) # an expired time
async def _init_session(self):
res = await self._client.get("https://www.bilibili.com/")
if res.status_code != 200:
logger.warning("unable to refresh temp cookie")
@wraps(func)
async def wrapper(bls: B, *args, **kwargs):
nonlocal retried_times
try:
res = await func(bls, *args, **kwargs)
except ApiCode352Error as e:
if retried_times < MAX_352_RETRY_COUNT:
retried_times += 1
logger.warning(f"获取动态列表失败尝试刷新cookie: {retried_times}/{MAX_352_RETRY_COUNT}")
await bls.ctx.refresh_client()
return [] # 返回空列表
else:
raise ApiError(e.args[0])
else:
self._refresh_time = datetime.now()
retried_times = 0
return res
async def _refresh_client(self):
if datetime.now() - self._refresh_time > self.cookie_expire_time:
await self._init_session()
async def get_client(self, target: Target | None) -> AsyncClient:
await self._refresh_client()
return self._client
async def get_query_name_client(self) -> AsyncClient:
await self._refresh_client()
return self._client
return wrapper
class BilibiliSite(Site):
name = "bilibili.com"
schedule_type = "interval"
schedule_setting = {"seconds": 10}
client_mgr = BilibiliClient
class _ProcessedText(NamedTuple):
title: str
content: str
class BililiveSchedConf(Site):
name = "live.bilibili.com"
schedule_type = "interval"
schedule_setting = {"seconds": 3}
client_mgr = BilibiliClient
class _ParsedMojarPost(NamedTuple):
title: str
content: str
pics: list[str]
url: str | None = None
class Bilibili(NewMessage):
@ -152,6 +90,7 @@ class Bilibili(NewMessage):
3: "视频",
4: "纯文字",
5: "转发",
6: "直播推送",
# 5: "短视频"
}
platform_name = "bilibili"
@ -176,7 +115,7 @@ class Bilibili(NewMessage):
async def parse_target(cls, target_text: str) -> Target:
if re.match(r"\d+", target_text):
return Target(target_text)
elif re.match(r"UID:\d+", target_text):
elif re.match(r"UID:(\d+)", target_text):
return Target(target_text[4:])
elif m := re.match(r"(?:https?://)?space\.bilibili\.com/(\d+)", target_text):
return Target(m.group(1))
@ -185,142 +124,202 @@ class Bilibili(NewMessage):
prompt="正确格式:\n1. 用户纯数字id\n2. UID:<用户id>\n3. 用户主页链接: https://space.bilibili.com/xxxx"
)
@retry_for_352
async def get_sub_list(self, target: Target) -> list[DynRawPost]:
client = await self.ctx.get_client()
params = {"host_uid": target, "offset": 0, "need_top": 0}
client = await self.ctx.get_client(target)
params = {"host_mid": target, "timezone_offset": -480, "offset": ""}
res = await client.get(
"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history",
"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space",
params=params,
timeout=4.0,
)
res.raise_for_status()
res_obj = type_validate_json(PostAPI, res.content)
try:
res_obj = type_validate_json(PostAPI, res.content)
except ValidationError as e:
logger.exception("解析B站动态列表失败")
logger.error(res.json())
raise ApiError(res.request.url) from e
# 0: 成功
# -352: 需要cookie
if res_obj.code == 0:
if (data := res_obj.data) and (card := data.cards):
return card
return []
raise ApiError(res.request.url)
if (data := res_obj.data) and (items := data.items):
logger.trace(f"获取用户{target}的动态列表成功,共{len(items)}条动态")
logger.trace(f"用户{target}的动态列表: {':'.join(x.id_str or x.basic.rid_str for x in items)}")
return [item for item in items if item.type != "DYNAMIC_TYPE_NONE"]
def get_id(self, post: DynRawPost) -> int:
return post.desc.dynamic_id
logger.trace(f"获取用户{target}的动态列表成功,但是没有动态")
return []
elif res_obj.code == -352:
raise ApiCode352Error(res.request.url)
else:
raise ApiError(res.request.url)
def get_id(self, post: DynRawPost) -> str:
return post.id_str
def get_date(self, post: DynRawPost) -> int:
return post.desc.timestamp
return post.modules.module_author.pub_ts
def _do_get_category(self, post_type: int) -> Category:
def _do_get_category(self, post_type: DynamicType) -> Category:
match post_type:
case 2:
case "DYNAMIC_TYPE_DRAW" | "DYNAMIC_TYPE_COMMON_VERTICAL" | "DYNAMIC_TYPE_COMMON_SQUARE":
return Category(1)
case 64:
case "DYNAMIC_TYPE_ARTICLE":
return Category(2)
case 8:
case "DYNAMIC_TYPE_AV":
return Category(3)
case 4:
case "DYNAMIC_TYPE_WORD":
return Category(4)
case 1:
case "DYNAMIC_TYPE_FORWARD":
# 转发
return Category(5)
case "DYNAMIC_TYPE_LIVE_RCMD" | "DYNAMIC_TYPE_LIVE":
return Category(6)
case unknown_type:
raise CategoryNotRecognize(unknown_type)
def get_category(self, post: DynRawPost) -> Category:
post_type = post.desc.type
post_type = post.type
return self._do_get_category(post_type)
def get_tags(self, raw_post: DynRawPost) -> list[Tag]:
card_content = json.loads(raw_post.card)
text: str = card_content["item"]["content"]
result: list[str] = re.findall(r"#(.*?)#", text)
return result
tags: list[Tag] = []
if raw_post.topic:
tags.append(raw_post.topic.name)
if desc := raw_post.modules.module_dynamic.desc:
for node in desc.rich_text_nodes:
if (node_type := node.get("type", None)) and node_type == "RICH_TEXT_NODE_TYPE_TOPIC":
tags.append(node["text"].strip("#"))
return tags
def _text_process(self, dynamic: str, desc: str, title: str) -> str:
similarity = 1.0 if len(dynamic) == 0 or len(desc) == 0 else text_similarity(dynamic, desc)
if len(dynamic) == 0 and len(desc) == 0:
text = title
elif similarity > 0.8:
text = title + "\n\n" + desc if len(dynamic) < len(desc) else dynamic + "\n=================\n" + title
def _text_process(self, dynamic: str, desc: str, title: str) -> _ProcessedText:
# 计算视频标题和视频描述相似度
title_similarity = 0.0 if len(title) == 0 or len(desc) == 0 else text_similarity(title, desc[: len(title)])
if title_similarity > 0.9:
desc = desc[len(title) :].lstrip()
# 计算视频描述和动态描述相似度
content_similarity = 0.0 if len(dynamic) == 0 or len(desc) == 0 else text_similarity(dynamic, desc)
if content_similarity > 0.8:
return _ProcessedText(title, desc if len(dynamic) < len(desc) else dynamic) # 选择较长的描述
else:
text = dynamic + "\n=================\n" + title + "\n\n" + desc
return text
return _ProcessedText(title, f"{desc}" + (f"\n=================\n{dynamic}" if dynamic else ""))
def _raw_post_parse(self, raw_post: DynRawPost, in_repost: bool = False):
class ParsedPost(NamedTuple):
text: str
pics: list[str]
url: str | None
repost_owner: str | None = None
repost: "ParsedPost | None" = None
def pre_parse_by_mojar(self, raw_post: DynRawPost) -> _ParsedMojarPost:
dyn = raw_post.modules.module_dynamic
card_content: dict[str, Any] = json.loads(raw_post.card)
repost_owner: str | None = ou["info"]["uname"] if (ou := card_content.get("origin_user")) else None
def extract_url_id(url_template: str, name: str) -> str | None:
if in_repost:
if origin := raw_post.desc.origin:
return url_template.format(getattr(origin, name))
return None
return url_template.format(getattr(raw_post.desc, name))
match self._do_get_category(raw_post.desc.type):
case 1:
# 一般动态
url = extract_url_id("https://t.bilibili.com/{}", "dynamic_id_str")
text: str = card_content["item"]["description"]
pic: list[str] = [img["img_src"] for img in card_content["item"]["pictures"]]
return ParsedPost(text, pic, url, repost_owner)
case 2:
# 专栏文章
url = extract_url_id("https://www.bilibili.com/read/cv{}", "rid")
text = "{} {}".format(card_content["title"], card_content["summary"])
pic = card_content["image_urls"]
return ParsedPost(text, pic, url, repost_owner)
case 3:
# 视频
url = extract_url_id("https://www.bilibili.com/video/{}", "bvid")
dynamic = card_content.get("dynamic", "")
title = card_content["title"]
desc = card_content.get("desc", "")
text = self._text_process(dynamic, desc, title)
pic = [card_content["pic"]]
return ParsedPost(text, pic, url, repost_owner)
case 4:
# 纯文字
url = extract_url_id("https://t.bilibili.com/{}", "dynamic_id_str")
text = card_content["item"]["content"]
pic = []
return ParsedPost(text, pic, url, repost_owner)
case 5:
# 转发
url = extract_url_id("https://t.bilibili.com/{}", "dynamic_id_str")
text = card_content["item"]["content"]
orig_type: int = card_content["item"]["orig_type"]
orig_card: str = card_content["origin"]
orig_post = DynRawPost(desc=raw_post.desc, card=orig_card)
orig_post.desc.type = orig_type
orig_parsed_post = self._raw_post_parse(orig_post, in_repost=True)
return ParsedPost(text, [], url, repost_owner, orig_parsed_post)
case unsupported_type:
raise CategoryNotSupport(unsupported_type)
match raw_post.modules.module_dynamic.major:
case VideoMajor(archive=archive):
desc_text = dyn.desc.text if dyn.desc else ""
parsed = self._text_process(desc_text, archive.desc, archive.title)
return _ParsedMojarPost(
title=parsed.title,
content=parsed.content,
pics=[archive.cover],
url=URL(archive.jump_url).with_scheme("https").human_repr(),
)
case LiveRecommendMajor(live_rcmd=live_rcmd):
live_play_info = type_validate_json(LiveRecommendMajor.Content, live_rcmd.content).live_play_info
return _ParsedMojarPost(
title=live_play_info.title,
content=f"{live_play_info.parent_area_name} {live_play_info.area_name}",
pics=[live_play_info.cover],
url=URL(live_play_info.link).with_scheme("https").with_query(None).human_repr(),
)
case LiveMajor(live=live):
return _ParsedMojarPost(
title=live.title,
content=f"{live.desc_first}\n{live.desc_second}",
pics=[live.cover],
url=URL(live.jump_url).with_scheme("https").human_repr(),
)
case ArticleMajor(article=article):
return _ParsedMojarPost(
title=article.title,
content=article.desc,
pics=article.covers,
url=URL(article.jump_url).with_scheme("https").human_repr(),
)
case DrawMajor(draw=draw):
return _ParsedMojarPost(
title="",
content=dyn.desc.text if dyn.desc else "",
pics=[item.src for item in draw.items],
url=f"https://t.bilibili.com/{raw_post.id_str}",
)
case PGCMajor(pgc=pgc):
return _ParsedMojarPost(
title=pgc.title,
content="",
pics=[pgc.cover],
url=URL(pgc.jump_url).with_scheme("https").human_repr(),
)
case OPUSMajor(opus=opus):
return _ParsedMojarPost(
title=opus.title,
content=opus.summary.text,
pics=[pic.url for pic in opus.pics],
url=URL(opus.jump_url).with_scheme("https").human_repr(),
)
case CommonMajor(common=common):
return _ParsedMojarPost(
title=common.title,
content=common.desc,
pics=[common.cover],
url=URL(common.jump_url).with_scheme("https").human_repr(),
)
case CoursesMajor(courses=courses):
return _ParsedMojarPost(
title=courses.title,
content=f"{courses.sub_title}\n{courses.desc}",
pics=[courses.cover],
url=URL(courses.jump_url).with_scheme("https").human_repr(),
)
case UnknownMajor(type=unknown_type):
raise CategoryNotSupport(unknown_type)
case None: # 没有major的情况
return _ParsedMojarPost(
title="",
content=dyn.desc.text if dyn.desc else "",
pics=[],
url=f"https://t.bilibili.com/{raw_post.id_str}",
)
case _:
raise CategoryNotSupport(f"{raw_post.id_str=}")
async def parse(self, raw_post: DynRawPost) -> Post:
parsed_raw_post = self._raw_post_parse(raw_post)
parsed_raw_post = self.pre_parse_by_mojar(raw_post)
parsed_raw_repost = None
if self._do_get_category(raw_post.type) == Category(5):
if raw_post.orig:
parsed_raw_repost = self.pre_parse_by_mojar(raw_post.orig)
else:
logger.warning(f"转发动态{raw_post.id_str}没有原动态")
post = Post(
self,
parsed_raw_post.text,
url=parsed_raw_post.url,
content=decode_unicode_escapes(parsed_raw_post.content),
title=parsed_raw_post.title,
images=list(parsed_raw_post.pics),
nickname=raw_post.desc.user_profile.info.uname,
timestamp=self.get_date(raw_post),
url=parsed_raw_post.url,
avatar=raw_post.modules.module_author.face,
nickname=raw_post.modules.module_author.name,
)
if rp := parsed_raw_post.repost:
if parsed_raw_repost:
orig = raw_post.orig
assert orig
post.repost = Post(
self,
rp.text,
url=rp.url,
images=list(rp.pics),
nickname=rp.repost_owner,
content=decode_unicode_escapes(parsed_raw_repost.content),
title=parsed_raw_repost.title,
images=list(parsed_raw_repost.pics),
timestamp=self.get_date(orig),
url=parsed_raw_repost.url,
avatar=orig.modules.module_author.face,
nickname=orig.modules.module_author.name,
)
return post
@ -331,7 +330,7 @@ class Bilibililive(StatusChange):
enable_tag = False
enabled = True
is_common = True
site = BililiveSchedConf
site = BililiveSite
name = "Bilibili直播"
has_target = True
use_batch = True
@ -481,7 +480,7 @@ class BilibiliBangumi(StatusChange):
enable_tag = False
enabled = True
is_common = True
site = BilibiliSite
site = BiliBangumiSite
name = "Bilibili剧集"
has_target = True
parse_target_promot = "请输入剧集主页"

View File

@ -0,0 +1,80 @@
from random import randint
from typing_extensions import override
from httpx import AsyncClient
from nonebot import logger, require
from playwright.async_api import Cookie
from nonebot_bison.types import Target
from nonebot_bison.utils import Site, ClientManager, http_client
require("nonebot_plugin_htmlrender")
from nonebot_plugin_htmlrender import get_browser
class BilibiliClientManager(ClientManager):
_client: AsyncClient
_inited: bool = False
def __init__(self) -> None:
self._client = http_client()
async def _get_cookies(self) -> list[Cookie]:
browser = await get_browser()
async with await browser.new_page() as page:
await page.goto(f"https://space.bilibili.com/{randint(1, 1000)}/dynamic")
await page.wait_for_load_state("load")
cookies = await page.context.cookies()
return cookies
async def _reset_client_cookies(self, cookies: list[Cookie]):
for cookie in cookies:
self._client.cookies.set(
name=cookie.get("name", ""),
value=cookie.get("value", ""),
domain=cookie.get("domain", ""),
path=cookie.get("path", "/"),
)
@override
async def refresh_client(self):
cookies = await self._get_cookies()
await self._reset_client_cookies(cookies)
logger.debug("刷新B站客户端的cookie")
@override
async def get_client(self, target: Target | None) -> AsyncClient:
if not self._inited:
logger.debug("初始化B站客户端")
await self.refresh_client()
self._inited = True
return self._client
@override
async def get_client_for_static(self) -> AsyncClient:
return http_client()
@override
async def get_query_name_client(self) -> AsyncClient:
return http_client()
class BilibiliSite(Site):
name = "bilibili.com"
schedule_setting = {"seconds": 30}
schedule_type = "interval"
client_mgr = BilibiliClientManager
require_browser = True
class BililiveSite(Site):
name = "live.bilibili.com"
schedule_setting = {"seconds": 5}
schedule_type = "interval"
class BiliBangumiSite(Site):
name = "bilibili.com/bangumi"
schedule_setting = {"seconds": 30}
schedule_type = "interval"

View File

@ -91,10 +91,20 @@ if plugin_config.bison_filter_log:
default_filter.level = ("DEBUG" if config.debug else "INFO") if config.log_level is None else config.log_level
def text_similarity(str1, str2) -> float:
def text_similarity(str1: str, str2: str) -> float:
"""利用最长公共子序列的算法判断两个字符串是否相似并返回0到1.0的相似度"""
if len(str1) == 0 or len(str2) == 0:
raise ValueError("The length of string can not be 0")
matcher = difflib.SequenceMatcher(None, str1, str2)
t = sum(temp.size for temp in matcher.get_matching_blocks())
return t / min(len(str1), len(str2))
def decode_unicode_escapes(s: str):
"""解码 \\r, \\n, \\t, \\uXXXX 等转义序列"""
def decode_match(match: re.Match[str]) -> str:
return bytes(match.group(0), "utf-8").decode("unicode_escape")
regex = re.compile(r"\\[rnt]|\\u[0-9a-fA-F]{4}")
return regex.sub(decode_match, s)

1726
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -48,7 +48,7 @@ isort = "^5.10.1"
nonemoji = "^0.1.4"
nb-cli = "^1.2.8"
pre-commit = "^3.3.0"
ruff = ">=0.0.278,<0.3.6"
ruff = ">=0.1.0"
[tool.poetry.group.test.dependencies]
flaky = "^3.7.0"

View File

@ -0,0 +1,269 @@
{
"code": 0,
"message": "0",
"ttl": 1,
"data": {
"has_more": true,
"items": [
{
"basic": {
"comment_id_str": "940610847313494066",
"comment_type": 17,
"like_icon": {
"action_url": "https://i0.hdslb.com/bfs/garb/item/8860c7c01179f9984f88fb61bc55cab9dc1d28cb.bin",
"end_url": "",
"id": 33772,
"start_url": ""
},
"rid_str": "507312380136289176"
},
"id_str": "940610847313494066",
"modules": {
"module_author": {
"avatar": {
"container_size": {
"height": 1.375,
"width": 1.375
},
"fallback_layers": {
"is_critical_group": true,
"layers": [
{
"general_spec": {
"pos_spec": {
"axis_x": 0.6875,
"axis_y": 0.6875,
"coordinate_pos": 2
},
"render_spec": {
"opacity": 1
},
"size_spec": {
"height": 0.787,
"width": 0.787
}
},
"layer_config": {
"is_critical": true,
"tags": {
"AVATAR_LAYER": {},
"GENERAL_CFG": {
"config_type": 1,
"general_config": {
"web_css_style": {
"borderRadius": "50%"
}
}
}
}
},
"resource": {
"res_image": {
"image_src": {
"placeholder": 6,
"remote": {
"bfs_style": "widget-layer-avatar",
"url": "https://i0.hdslb.com/bfs/face/a84fa10f90f7060d0336384954ee1cde7a8e9bc6.jpg"
},
"src_type": 1
}
},
"res_type": 3
},
"visible": true
},
{
"general_spec": {
"pos_spec": {
"axis_x": 0.6875,
"axis_y": 0.6875,
"coordinate_pos": 2
},
"render_spec": {
"opacity": 1
},
"size_spec": {
"height": 1.375,
"width": 1.375
}
},
"layer_config": {
"tags": {
"PENDENT_LAYER": {}
}
},
"resource": {
"res_image": {
"image_src": {
"remote": {
"bfs_style": "widget-layer-avatar",
"url": "https://i0.hdslb.com/bfs/garb/item/7f8aa8ef1eed8c2dce0796801ddc82552a4164f9.png"
},
"src_type": 1
}
},
"res_type": 3
},
"visible": true
},
{
"general_spec": {
"pos_spec": {
"axis_x": 0.7560000000000001,
"axis_y": 0.7726666666666667,
"coordinate_pos": 1
},
"render_spec": {
"opacity": 1
},
"size_spec": {
"height": 0.41666666666666663,
"width": 0.41666666666666663
}
},
"layer_config": {
"tags": {
"GENERAL_CFG": {
"config_type": 1,
"general_config": {
"web_css_style": {
"background-color": "rgb(255,255,255)",
"border": "2px solid rgba(255,255,255,1)",
"borderRadius": "50%",
"boxSizing": "border-box"
}
}
},
"ICON_LAYER": {}
}
},
"resource": {
"res_image": {
"image_src": {
"local": 3,
"src_type": 2
}
},
"res_type": 3
},
"visible": true
}
]
},
"mid": "13164144"
},
"decorate": {
"card_url": "https://i0.hdslb.com/bfs/garb/item/a1c3db0829e7a1dff2fe476421cf587702b09293.png",
"fan": {
"color": "#f9b636",
"color_format": {
"colors": ["#f9b636FF", "#f9b636FF"],
"end_point": "0,100",
"gradients": [0, 100],
"start_point": "0,0"
},
"is_fan": true,
"num_prefix": "NO.",
"num_str": "000001",
"number": 1
},
"id": 36354,
"jump_url": "https://www.bilibili.com/h5/mall/equity-link/collect-home?item_id=36354&isdiy=0&part=card&from=post&f_source=garb&vmid=13164144&native.theme=1&navhide=1",
"name": "魔法美少女ZC粉丝",
"type": 3
},
"face": "https://i0.hdslb.com/bfs/face/a84fa10f90f7060d0336384954ee1cde7a8e9bc6.jpg",
"face_nft": false,
"following": true,
"jump_url": "//space.bilibili.com/13164144/dynamic",
"label": "",
"mid": 13164144,
"name": "魔法Zc目录",
"official_verify": {
"desc": "",
"type": 0
},
"pendant": {
"expire": 0,
"image": "https://i0.hdslb.com/bfs/garb/item/7f8aa8ef1eed8c2dce0796801ddc82552a4164f9.png",
"image_enhance": "https://i0.hdslb.com/bfs/garb/item/7f8aa8ef1eed8c2dce0796801ddc82552a4164f9.png",
"image_enhance_frame": "",
"n_pid": 3860,
"name": "2021拜年纪",
"pid": 3860
},
"pub_action": "直播了",
"pub_location_text": "",
"pub_time": "",
"pub_ts": 1717841429,
"type": "AUTHOR_TYPE_NORMAL",
"vip": {
"avatar_subscript": 1,
"avatar_subscript_url": "",
"due_date": 1737475200000,
"label": {
"bg_color": "#FB7299",
"bg_style": 1,
"border_color": "",
"img_label_uri_hans": "https://i0.hdslb.com/bfs/activity-plat/static/20220608/e369244d0b14644f5e1a06431e22a4d5/0DFy9BHgwE.gif",
"img_label_uri_hans_static": "https://i0.hdslb.com/bfs/vip/8d7e624d13d3e134251e4174a7318c19a8edbd71.png",
"img_label_uri_hant": "",
"img_label_uri_hant_static": "https://i0.hdslb.com/bfs/activity-plat/static/20220614/e369244d0b14644f5e1a06431e22a4d5/uckjAv3Npy.png",
"label_theme": "annual_vip",
"path": "",
"text": "年度大会员",
"text_color": "#FFFFFF",
"use_img_label": true
},
"nickname_color": "#FB7299",
"status": 1,
"theme_type": 0,
"type": 2
}
},
"module_dynamic": {
"additional": null,
"desc": null,
"major": {
"live_rcmd": {
"content": "{\"type\":1,\"live_play_info\":{\"room_paid_type\":0,\"room_id\":3044248,\"cover\":\"http://i0.hdslb.com/bfs/live/new_room_cover/fdada58af9fdc0068562da17298815de72ec82e0.jpg\",\"parent_area_name\":\"手游\",\"live_screen_type\":0,\"link\":\"//live.bilibili.com/3044248?live_from=85002\",\"live_status\":1,\"title\":\"【Zc】灵异地铁站深夜恐怖档\",\"parent_area_id\":3,\"area_name\":\"明日方舟\",\"live_start_time\":1717840829,\"live_id\":\"507312380136289176\",\"pendants\":{\"list\":{\"mobile_index_badge\":{\"list\":{\"1\":{\"text\":\"\",\"bg_color\":\"#FB9E60\",\"bg_pic\":\"https://i0.hdslb.com/bfs/live/539ce26c45cd4019f55b64cfbcedc3c01820e539.png\",\"pendant_id\":426,\"type\":\"mobile_index_badge\",\"name\":\"百人成就\",\"position\":1}}},\"index_badge\":{\"list\":{\"1\":{\"type\":\"index_badge\",\"name\":\"百人成就\",\"position\":1,\"text\":\"\",\"bg_color\":\"#FB9E60\",\"bg_pic\":\"https://i0.hdslb.com/bfs/live/539ce26c45cd4019f55b64cfbcedc3c01820e539.png\",\"pendant_id\":425}}}}},\"uid\":13164144,\"play_type\":0,\"area_id\":255,\"room_type\":0,\"online\":1269096,\"watched_show\":{\"switch\":true,\"num\":122343,\"text_small\":\"12.2万\",\"text_large\":\"12.2万人看过\",\"icon\":\"https://i0.hdslb.com/bfs/live/a725a9e61242ef44d764ac911691a7ce07f36c1d.png\",\"icon_location\":\"\",\"icon_web\":\"https://i0.hdslb.com/bfs/live/8d9d0f33ef8bf6f308742752d13dd0df731df19c.png\"}},\"live_record_info\":null}",
"reserve_type": 0
},
"type": "MAJOR_TYPE_LIVE_RCMD"
},
"topic": null
},
"module_more": {
"three_point_items": [
{
"label": "举报",
"type": "THREE_POINT_REPORT"
}
]
},
"module_stat": {
"comment": {
"count": 0,
"forbidden": false,
"hidden": true
},
"forward": {
"count": 0,
"forbidden": false
},
"like": {
"count": 351,
"forbidden": false,
"status": false
}
}
},
"type": "DYNAMIC_TYPE_LIVE_RCMD",
"visible": true
}
],
"offset": "915793667264872453",
"update_baseline": "",
"update_num": 0
}
}

4062
tests/platforms/static/bilibili-new.json vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,10 +1,11 @@
import typing
from time import time
from datetime import datetime
from typing import TYPE_CHECKING, Any
import respx
import pytest
from httpx import Response
from nonebug.app import App
from httpx import URL, Response
from nonebot.compat import model_dump, type_validate_python
from .utils import get_json
@ -12,26 +13,27 @@ from .utils import get_json
@pytest.fixture()
def bing_dy_list(app: App):
from nonebot_bison.platform.bilibili import PostAPI
from nonebot_bison.platform.bilibili.models import PostAPI
return type_validate_python(PostAPI, get_json("bilibili_bing_list.json")).data.cards # type: ignore
return type_validate_python(PostAPI, get_json("bilibili-new.json")).data.items # type: ignore
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from nonebot_bison.platform.bilibili import Bilibili
@pytest.fixture()
def bilibili(app: App) -> "Bilibili":
from nonebot_bison.utils import ProcessContext
from nonebot_bison.platform import platform_manager
from nonebot_bison.utils import ProcessContext, DefaultClientManager
from nonebot_bison.platform.bilibili import BilibiliClientManager
return platform_manager["bilibili"](ProcessContext(DefaultClientManager())) # type: ignore
return platform_manager["bilibili"](ProcessContext(BilibiliClientManager())) # type: ignore
@pytest.fixture()
def without_dynamic(app: App):
from nonebot_bison.platform.bilibili import PostAPI
from nonebot_bison.platform.bilibili.models import PostAPI
# 先验证实际的空动态返回能否通过校验,再重新导出
return model_dump(
@ -42,7 +44,7 @@ def without_dynamic(app: App):
"ttl": 1,
"message": "",
"data": {
"cards": None,
"items": None,
"has_more": 0,
"next_offset": 0,
"_gt_": 0,
@ -52,115 +54,195 @@ def without_dynamic(app: App):
)
@pytest.mark.asyncio
async def test_retry_for_352(app: App):
from nonebot_bison.post import Post
from nonebot_bison.platform.platform import NewMessage
from nonebot_bison.types import Target, RawPost, ApiError
from nonebot_bison.utils import ClientManager, ProcessContext, http_client
from nonebot_bison.platform.bilibili.platforms import MAX_352_RETRY_COUNT, ApiCode352Error, retry_for_352
now = time()
raw_post_1 = {"id": 1, "text": "p1", "date": now, "tags": ["tag1"], "category": 1}
raw_post_2 = {"id": 2, "text": "p2", "date": now + 1, "tags": ["tag2"], "category": 2}
class MockPlatform(NewMessage):
platform_name = "fakebili"
name = "fakebili"
enabled = True
is_common = True
schedule_interval = 10
enable_tag = False
categories = {}
has_target = True
raise352 = False
sub_index = 0
@classmethod
async def get_target_name(cls, client, _: "Target"):
return "MockPlatform"
def get_id(self, post: RawPost) -> Any:
return post["id"]
def get_date(self, raw_post: RawPost) -> float:
return raw_post["date"]
async def parse(self, raw_post: RawPost) -> Post:
return Post(
self,
raw_post["text"],
"http://t.tt/" + str(self.get_id(raw_post)),
nickname="Mock",
)
def set_raise352(self, value: bool):
self.raise352 = value
@retry_for_352 # type: ignore 保证接收self、target参数返回list即可
async def get_sub_list(self, t: Target):
await self.ctx.get_client(t)
if not self.raise352:
if self.sub_index == 0:
self.sub_index += 1
return [raw_post_1]
return [raw_post_1, raw_post_2]
else:
raise ApiCode352Error(URL("http://t.tt/1"))
class MockClientManager(ClientManager):
get_client_call_count = 0
get_client_for_static_call_count = 0
get_query_name_client_call_count = 0
refresh_client_call_count = 0
async def get_client(self, target: Target | None):
self.get_client_call_count += 1
return http_client()
async def get_client_for_static(self):
self.get_client_for_static_call_count += 1
return http_client()
async def get_query_name_client(self):
self.get_query_name_client_call_count += 1
return http_client()
async def refresh_client(self):
self.refresh_client_call_count += 1
fakebili = MockPlatform(ProcessContext(MockClientManager()))
client_mgr = fakebili.ctx._client_mgr
assert isinstance(client_mgr, MockClientManager)
assert client_mgr.get_client_call_count == 0
assert client_mgr.get_client_for_static_call_count == 0
assert client_mgr.get_query_name_client_call_count == 0
assert client_mgr.refresh_client_call_count == 0
# 无异常
res: list[dict[str, Any]] = await fakebili.get_sub_list(Target("1")) # type: ignore
assert len(res) == 1
assert res[0]["id"] == 1
assert client_mgr.get_client_call_count == 1
assert client_mgr.refresh_client_call_count == 0
res = await fakebili.get_sub_list(Target("1")) # type: ignore
assert len(res) == 2
assert res[0]["id"] == 1
assert res[1]["id"] == 2
assert client_mgr.get_client_call_count == 2
assert client_mgr.refresh_client_call_count == 0
# 有异常
fakebili.set_raise352(True)
for i in range(MAX_352_RETRY_COUNT):
res1: list[dict[str, Any]] = await fakebili.get_sub_list(Target("1")) # type: ignore
assert len(res1) == 0
assert client_mgr.get_client_call_count == 3 + i
assert client_mgr.refresh_client_call_count == i + 1
# 超过最大重试次数,抛出异常
with pytest.raises(ApiError):
await fakebili.get_sub_list(Target("1"))
@pytest.mark.asyncio
async def test_parser(bilibili: "Bilibili"):
from nonebot_bison.platform.bilibili.models import PostAPI, UnknownMajor
test_data = get_json("bilibili-new.json")
res = type_validate_python(PostAPI, test_data)
assert res.data is not None
for item in res.data.items or []:
assert item.modules
assert not isinstance(item.modules.module_dynamic.major, UnknownMajor)
@pytest.mark.asyncio
async def test_get_tag(bilibili: "Bilibili", bing_dy_list):
from nonebot_bison.platform.bilibili import DynRawPost
from nonebot_bison.platform.bilibili.models import DynRawPost
raw_post_has_tag = type_validate_python(DynRawPost, bing_dy_list[0])
raw_post_has_tag.card = '{"user":{"uid":111111,"uname":"1111","face":"https://i2.hdslb.com/bfs/face/0b.jpg"},"item":{"rp_id":11111,"uid":31111,"content":"#测试1#\\n测试\\n#测试2#\\n#测\\n测\\n测#","ctrl":"","reply":0}}'
raw_post_has_no_tag = type_validate_python(DynRawPost, bing_dy_list[1])
raw_post_has_no_tag.card = '{"user":{"uid":111111,"uname":"1111","face":"https://i2.hdslb.com/bfs/face/0b.jpg"},"item":{"rp_id":11111,"uid":31111,"content":"测试1\\n测试\\n测试2\\n#测\\n测\\n测#","ctrl":"","reply":0}}'
raw_post_has_tag = type_validate_python(DynRawPost, bing_dy_list[6])
res1 = bilibili.get_tags(raw_post_has_tag)
assert res1 == ["测试1", "测试2"]
res2 = bilibili.get_tags(raw_post_has_no_tag)
assert res2 == []
assert set(res1) == {"明日方舟", "123罗德岛"}
async def test_video_forward(bilibili, bing_dy_list):
async def test_dynamic_video(bilibili: "Bilibili", bing_dy_list: list):
from nonebot_bison.post import Post
post: Post = await bilibili.parse(bing_dy_list[1])
assert post.content == """答案揭晓:宿舍!来看看投票结果\nhttps://t.bilibili.com/568093580488553786"""
assert post.repost is not None
# 注意原文前几行末尾是有空格的
assert post.repost.content == (
"#可露希尔的秘密档案# \n"
"11来宿舍休息一下吧 \n"
"档案来源lambda:\\罗德岛内务\\秘密档案 \n"
"发布时间9/12 1:00 P.M. \n"
"档案类型:可见 \n"
"档案描述:今天请了病假在宿舍休息。很舒适。 \n"
"提供者:赫默\n"
"=================\n"
"《可露希尔的秘密档案》11话来宿舍休息一下吧"
)
assert post.url == "https://t.bilibili.com/569448354910819194"
assert post.repost.url == "https://www.bilibili.com/video/BV1E3411q7nU"
assert post.get_priority_themes()[0] == "basic"
post: Post = await bilibili.parse(bing_dy_list[8])
@pytest.mark.asyncio
async def test_video_forward_without_dynamic(bilibili, bing_dy_list):
# 视频简介和动态文本其中一方为空的情况
post = await bilibili.parse(bing_dy_list[2])
assert (
post.content
== "阿消的罗德岛闲谈直播#01:《女人最喜欢的女人,就是在战场上熠熠生辉的女人》"
+ "\n\n"
+ "本系列视频为饼组成员的有趣直播录播,主要内容为方舟相关,未来可能系列其他视频会包含部分饼组团建日常等。"
"仅为娱乐性视频,内容与常规饼学预测无关。视频仅为当期主播主观观点,不代表饼组观点。仅供娱乐。"
"\n\n直播主播:@寒蝉慕夏 \n后期剪辑:@Melodiesviel \n\n本群视频为9.11组员慕夏直播录播,"
"包含慕夏对新PV的个人解读风笛厨力疯狂放出CP言论输出9.16轮换池预测视频分析和理智规划杂谈内容。"
"\n注意:内含大量个人性质对风笛的厨力观点与多CP混乱发言不适者请及时点击退出或跳到下一片段。"
)
assert post.repost is None
assert post.url == "https://www.bilibili.com/video/BV1K44y1h7Xg"
assert post.get_priority_themes()[0] == "basic"
@pytest.mark.asyncio
async def test_article_forward(bilibili: "Bilibili", bing_dy_list):
post = await bilibili.parse(bing_dy_list[4])
assert post.title == "《明日方舟》SideStory「巴别塔」活动宣传PV"
assert post.content == (
"#明日方舟##饼学大厦#\n"
"9.11专栏更新完毕,这还塌了实属没跟新运营对上\n"
"后边除了周日发饼和PV没提及的中文语音稳了\n"
"别忘了来参加#可露希尔的秘密档案#的主题投票\n"
"https://t.bilibili.com/568093580488553786?tab=2"
"SideStory「巴别塔」限时活动即将开启\r\n\r\n"
"追逐未来的道路上,\r\n"
"两种同样伟大的理想对撞,几场同样壮烈的悲剧上演。\r\n\r\n"
"———————————— \r\n"
"详细活动内容敬请关注《明日方舟》官网及游戏内相关公告。"
)
assert post.repost is not None
assert post.repost.content == (
"【明日方舟】饼学大厦#12~14风暴瞭望&玛莉娅·临光&红松林&感谢庆典)"
"9.11更新 更新记录09.11更新覆盖09.10更新;以及排期更新,猜测周一周五开活动"
"09.10更新以周五开活动为底PV/公告调整位置,整体结构更新"
"09.08更新:饼学大厦#12更新新增一件六星商店服饰周日发饼"
"09.06更新饼学大厦整栋整栋翻新改为9.16开主线(四日无饼!)"
"09.05凌晨更新10.13后的排期(两日无饼,鹰角背刺,心狠手辣)"
"前言感谢楪筱祈ぺ的动态-哔哩哔哩 (bilibili.com) 对饼学的贡献!"
"后续排期9.17【风暴瞭望】、10.01【玛莉娅·临光】复刻、10.1"
)
assert post.url == "https://t.bilibili.com/569189870889648693"
assert post.repost.url == "https://www.bilibili.com/read/cv12993752"
assert post.url == "https://www.bilibili.com/video/BV1Jp421y72e/"
@pytest.mark.asyncio
async def test_dynamic_forward(bilibili, bing_dy_list):
post = await bilibili.parse(bing_dy_list[5])
async def test_dynamic_forward(bilibili: "Bilibili", bing_dy_list: list):
from nonebot_bison.post import Post
post: Post = await bilibili.parse(bing_dy_list[7])
assert post.content == (
"饼组主线饼学预测——9.11版\n"
"①今日结果\n"
"9.11 殿堂上的游禽-星极(x新运营实锤了)\n"
"②后续预测\n"
"9.12 #罗德岛相簿#+#可露希尔的秘密档案#11话\n"
"9.13 六星先锋(执旗手)干员-琴柳\n9.14 宣传策略-空弦+家具\n"
"9.15 轮换池(+中文语音前瞻)\n"
"9.16 停机\n"
"9.17 #罗德岛闲逛部#+新六星EP+EP09·风暴瞭望开启\n"
"9.19 #罗德岛相簿#"
"「2024明日方舟音律联觉-不觅浪尘」将于12:00正式开启预售票预售票购票链接https://m.damai.cn/shows/item.html?itemId=778626949623"
)
assert post.repost.content == (
"#明日方舟#\n"
"【新增服饰】\n"
"//殿堂上的游禽 - 星极\n"
"塞壬唱片偶像企划《闪耀阶梯》特供服饰/殿堂上的游禽。星极自费参加了这项企划,尝试着用大众能接受的方式演绎天空之上的故事。\n\n"
"_____________\n"
"谦逊留给观众,骄傲发自歌喉,此夜,唯我璀璨。 "
assert post.url == "https://t.bilibili.com/917092495452536836"
assert (rp := post.repost)
assert rp.content == (
"互动抽奖 #明日方舟##音律联觉#\n\n"
"「2024音律联觉」票务信息公开\n\n\n\n"
"「2024明日方舟音律联觉-不觅浪尘」将于【4月6日12:00】正式开启预售票预售票购票链接"
"https://m.damai.cn/shows/item.html?itemId=778626949623\n\n"
"【活动地点】\n\n"
"上海久事体育旗忠网球中心主场馆上海市闵行区元江路5500弄\n\n"
"【活动时间】\n\n「不觅浪尘-日场」5月1日-5月2日\u0026"
"5月4日-5月5日 13:00\n\n"
"「不觅浪尘-夜场」5月1日-5月2日\u00265月4日-5月5日 18:30\n\n"
"【温馨提醒】\n\n"
"*「2024明日方舟音律联觉-不觅浪尘」演出共计4天每天有日场和夜场各1场演出共计8场次每场演出为相同内容。\n\n"
"*「2024明日方舟音律联觉-不觅浪尘」演出全部录播内容后续将于bilibili独家上线敬请期待。\n\n"
"* 音律联觉将为博士们准备活动场馆往返莘庄、北桥地铁站的免费接驳车,"
"有需要乘坐的博士请合理安排自己的出行时间。\n\n"
"【票务相关】\n\n"
"* 本次票务和大麦网 进行合作应相关要求本次预售仅预先开放70%的票量,"
"剩余票量的开票时间请继续关注明日方舟官方自媒体账号的后续通知。\n\n"
"* 预售期间,由于未正式开票,下单后无法立即为您配票,待正式开票后,您可通过订单详情页或票夹详情,"
"查看票品信息。\n\n* 本次演出为非选座电子票,依照您的付款时间顺序出票,如果您同一订单下购买多张票,"
"正式开票后,系统将会优先为您选择相连座位。\n\n\n"
"更多详情可查看下方长图或查看活动详情页https://ak.hypergryph.com/special/amb-2024/ \n\n\n\n"
"* 请各位购票前务必确认票档和相关购票须知,并提前在对应票务平台后台添加完整的个人购票信息,以方便购票。\n\n"
"* 明日方舟嘉年华及音律联觉活动场地存在一定距离,且活动时间有部分重叠,推荐都参加的博士选择不同日期购票。\n\n"
"\n\n关注并转发本动态我们将会在5月6日抽取20位博士各赠送【2024音律联觉主题亚克力画随机一款】一份。"
"中奖的幸运博士请于开奖后5日内提交获奖信息逾期视为自动放弃奖励。"
)
assert post.url == "https://t.bilibili.com/569107343093484983"
assert post.repost.url == "https://t.bilibili.com/569105539209306328"
assert rp.images
assert len(rp.images) == 9
assert rp.url == "https://t.bilibili.com/915793667264872453"
@pytest.mark.asyncio
@ -168,13 +250,11 @@ async def test_dynamic_forward(bilibili, bing_dy_list):
async def test_fetch_new_without_dynamic(bilibili, dummy_user_subinfo, without_dynamic):
from nonebot_bison.types import Target, SubUnit
target = Target("161775300")
post_router = respx.get(
"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history?host_uid=161775300&offset=0&need_top=0"
f"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?host_mid={target}&timezone_offset=-480&offset="
)
post_router.mock(return_value=Response(200, json=without_dynamic))
bilibili_main_page_router = respx.get("https://www.bilibili.com/")
bilibili_main_page_router.mock(return_value=Response(200))
target = Target("161775300")
res = await bilibili.fetch_new_post(SubUnit(target, [dummy_user_subinfo]))
assert post_router.called
assert len(res) == 0
@ -183,34 +263,81 @@ async def test_fetch_new_without_dynamic(bilibili, dummy_user_subinfo, without_d
@pytest.mark.asyncio
@respx.mock
async def test_fetch_new(bilibili, dummy_user_subinfo):
from nonebot_bison.types import Target, SubUnit
from nonebot.compat import model_dump, type_validate_python
post_router = respx.get(
"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history?host_uid=161775300&offset=0&need_top=0"
)
post_router.mock(return_value=Response(200, json=get_json("bilibili_strange_post-0.json")))
bilibili_main_page_router = respx.get("https://www.bilibili.com/")
bilibili_main_page_router.mock(return_value=Response(200))
from nonebot_bison.types import Target, SubUnit
from nonebot_bison.platform.bilibili.models import PostAPI
target = Target("161775300")
post_router = respx.get(
f"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?host_mid={target}&timezone_offset=-480&offset="
)
post_list = type_validate_python(PostAPI, get_json("bilibili-new.json"))
assert post_list.data
assert post_list.data.items
post_0 = post_list.data.items.pop(0)
post_router.mock(return_value=Response(200, json=model_dump(post_list)))
res = await bilibili.fetch_new_post(SubUnit(target, [dummy_user_subinfo]))
assert post_router.called
assert len(res) == 0
mock_data = get_json("bilibili_strange_post.json")
mock_data["data"]["cards"][0]["desc"]["timestamp"] = int(datetime.now().timestamp())
post_router.mock(return_value=Response(200, json=mock_data))
post_0.modules.module_author.pub_ts = int(datetime.now().timestamp())
post_list.data.items.insert(0, post_0)
post_router.mock(return_value=Response(200, json=model_dump(post_list)))
res2 = await bilibili.fetch_new_post(SubUnit(target, [dummy_user_subinfo]))
assert len(res2[0][1]) == 1
post = res2[0][1][0]
assert (
post.content == "#罗德厨房——回甘##明日方舟#\r\n明日方舟官方美食漫画,正式开餐。\r\n往事如烟,安然即好。\r\nMenu"
" 01高脚羽兽烤串与罗德岛的领袖\r\n\r\n哔哩哔哩漫画阅读https://manga.bilibili.com/detail/mc31998?from=manga_search\r\n\r\n关注并转发本动态,"
"我们将会在5月27日抽取10位博士赠送【兔兔奇境】周边礼盒一份。 互动抽奖"
assert post.content == (
"SideStory「巴别塔」限时活动即将开启\n\n\n\n"
"一、全新SideStory「巴别塔」活动关卡开启\n\n"
"二、【如死亦终】限时寻访开启\n\n"
"三、新干员登场,信赖获取提升\n\n"
"四、【时代】系列,新装限时上架\n\n"
"五、复刻时装限时上架\n\n"
"六、新增【“疤痕商场的回忆”】主题家具,限时获取\n\n"
"七、礼包限时上架\n\n"
"八、【前路回响】限时寻访开启\n\n"
"九、【玛尔特】系列,限时复刻上架\n\n\n\n"
"更多活动内容请持续关注《明日方舟》游戏内公告及官方公告。"
)
@pytest.mark.asyncio
@respx.mock
async def test_fetch_new_live_rcmd(bilibili: "Bilibili", dummy_user_subinfo):
from nonebot.compat import model_dump, type_validate_python
from nonebot_bison.types import Target, SubUnit
from nonebot_bison.platform.bilibili.models import PostAPI
target = Target("13164144")
post_router = respx.get(
f"https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?host_mid={target}&timezone_offset=-480&offset="
)
post_list = type_validate_python(PostAPI, get_json("bilibili-dynamic-live-rcmd.json"))
assert post_list.data
assert post_list.data.items
post_0 = post_list.data.items.pop(0)
post_router.mock(return_value=Response(200, json=model_dump(post_list)))
res = await bilibili.fetch_new_post(SubUnit(target, [dummy_user_subinfo]))
assert post_router.called
assert len(res) == 0
post_0.modules.module_author.pub_ts = int(datetime.now().timestamp())
post_list.data.items.insert(0, post_0)
post_router.mock(return_value=Response(200, json=model_dump(post_list)))
res2 = await bilibili.fetch_new_post(SubUnit(target, [dummy_user_subinfo]))
assert len(res2[0][1]) == 1
post = res2[0][1][0]
assert post.title == "【Zc】灵异地铁站深夜恐怖档"
assert post.content == "手游 明日方舟"
assert post.images == ["http://i0.hdslb.com/bfs/live/new_room_cover/fdada58af9fdc0068562da17298815de72ec82e0.jpg"]
assert post.url == "https://live.bilibili.com/3044248"
async def test_parse_target(bilibili: "Bilibili"):
from nonebot_bison.platform.platform import Platform
@ -224,3 +351,43 @@ async def test_parse_target(bilibili: "Bilibili"):
assert res2 == "161775300"
with pytest.raises(Platform.ParseTargetException):
await bilibili.parse_target("https://www.bilibili.com/video/BV1qP4y1g738?spm_id_from=333.999.0.0")
res3 = await bilibili.parse_target("10086")
assert res3 == "10086"
res4 = await bilibili.parse_target("UID:161775300")
assert res4 == "161775300"
async def test_content_process(bilibili: "Bilibili"):
res = bilibili._text_process(
title="「2024明日方舟音律联觉-不觅浪尘」先导预告公开",
desc=(
"「2024明日方舟音律联觉-不觅浪尘」先导预告公开\n\n"
"“苦难往往相似,邪恶反倒驳杂。”\n"
"“点火者远去了,但火还在燃烧。”\n"
"“没有某种能量和激励,也没有某种责任甚至注定牺牲的命运。”\n"
"“欢迎回家,博士。”\n\n"
"活动详情及票务信息将于近期发布,请持续关注@明日方舟 。"
),
dynamic="投稿了视频",
)
assert res.title == "「2024明日方舟音律联觉-不觅浪尘」先导预告公开"
assert res.content == (
"“苦难往往相似,邪恶反倒驳杂。”\n"
"“点火者远去了,但火还在燃烧。”\n"
"“没有某种能量和激励,也没有某种责任甚至注定牺牲的命运。”\n"
"“欢迎回家,博士。”\n\n"
"活动详情及票务信息将于近期发布,请持续关注@明日方舟 。\n"
"=================\n"
"投稿了视频"
)
res2 = bilibili._text_process(
title="111",
desc="222",
dynamic="2222",
)
assert res2.title == "111"
assert res2.content == "2222"

View File

@ -14,11 +14,10 @@ if TYPE_CHECKING:
@pytest.fixture()
def bili_live(app: App):
from nonebot_bison.utils import ProcessContext
from nonebot_bison.platform import platform_manager
from nonebot_bison.platform.bilibili import BilibiliClient
from nonebot_bison.utils import ProcessContext, DefaultClientManager
return platform_manager["bilibili-live"](ProcessContext(BilibiliClient()))
return platform_manager["bilibili-live"](ProcessContext(DefaultClientManager()))
@pytest.fixture()

View File

@ -26,29 +26,29 @@ async def test_scheduler_without_time(init_scheduler):
from nonebot_plugin_saa import TargetQQGroup
from nonebot_bison.config import config
from nonebot_bison.platform.ncm import NcmSite
from nonebot_bison.types import Target as T_Target
from nonebot_bison.config.db_config import WeightConfig
from nonebot_bison.platform.bilibili import BilibiliSite
from nonebot_bison.scheduler.manager import init_scheduler
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t1"), "target1", "bilibili", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "bilibili", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "bilibili-bangumi", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t1"), "target1", "ncm-artist", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "ncm-artist", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "ncm-radio", [], [])
await config.update_time_weight_config(T_Target("t2"), "bilibili", WeightConfig(default=20, time_config=[]))
await config.update_time_weight_config(T_Target("t2"), "bilibili-bangumi", WeightConfig(default=30, time_config=[]))
await config.update_time_weight_config(T_Target("t2"), "ncm-artist", WeightConfig(default=20, time_config=[]))
await config.update_time_weight_config(T_Target("t2"), "ncm-radio", WeightConfig(default=30, time_config=[]))
await init_scheduler()
static_res = await get_schedule_times(BilibiliSite, 6)
assert static_res["bilibili-t1"] == 1
assert static_res["bilibili-t2"] == 2
assert static_res["bilibili-bangumi-t2"] == 3
static_res = await get_schedule_times(NcmSite, 6)
assert static_res["ncm-artist-t1"] == 1
assert static_res["ncm-artist-t2"] == 2
assert static_res["ncm-radio-t2"] == 3
static_res = await get_schedule_times(BilibiliSite, 6)
assert static_res["bilibili-t1"] == 1
assert static_res["bilibili-t2"] == 2
assert static_res["bilibili-bangumi-t2"] == 3
static_res = await get_schedule_times(NcmSite, 6)
assert static_res["ncm-artist-t1"] == 1
assert static_res["ncm-artist-t2"] == 2
assert static_res["ncm-radio-t2"] == 3
async def test_scheduler_batch_api(init_scheduler, mocker: MockerFixture):
@ -59,13 +59,13 @@ async def test_scheduler_batch_api(init_scheduler, mocker: MockerFixture):
from nonebot_bison.scheduler import scheduler_dict
from nonebot_bison.types import Target as T_Target
from nonebot_bison.utils import DefaultClientManager
from nonebot_bison.platform.bilibili import BililiveSite
from nonebot_bison.scheduler.manager import init_scheduler
from nonebot_bison.platform.bilibili import BililiveSchedConf
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t1"), "target1", "bilibili-live", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target2", "bilibili-live", [], [])
mocker.patch.object(BililiveSchedConf, "client_mgr", DefaultClientManager)
mocker.patch.object(BililiveSite, "client_mgr", DefaultClientManager)
await init_scheduler()
@ -81,7 +81,7 @@ async def test_scheduler_batch_api(init_scheduler, mocker: MockerFixture):
{"bilibili-live": mocker.Mock(return_value=fake_platform_obj)},
)
await scheduler_dict[BililiveSchedConf].exec_fetch()
await scheduler_dict[BililiveSite].exec_fetch()
batch_fetch_mock.assert_called_once_with(
[
@ -94,44 +94,44 @@ async def test_scheduler_batch_api(init_scheduler, mocker: MockerFixture):
async def test_scheduler_with_time(app: App, init_scheduler, mocker: MockerFixture):
from nonebot_plugin_saa import TargetQQGroup
from nonebot_bison.platform.ncm import NcmSite
from nonebot_bison.config import config, db_config
from nonebot_bison.types import Target as T_Target
from nonebot_bison.platform.bilibili import BilibiliSite
from nonebot_bison.scheduler.manager import init_scheduler
from nonebot_bison.config.db_config import WeightConfig, TimeWeightConfig
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t1"), "target1", "bilibili", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "bilibili", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "bilibili-bangumi", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t1"), "target1", "ncm-artist", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "ncm-artist", [], [])
await config.add_subscribe(TargetQQGroup(group_id=123), T_Target("t2"), "target1", "ncm-radio", [], [])
await config.update_time_weight_config(
T_Target("t2"),
"bilibili",
"ncm-artist",
WeightConfig(
default=20,
time_config=[TimeWeightConfig(start_time=time(10), end_time=time(11), weight=1000)],
),
)
await config.update_time_weight_config(T_Target("t2"), "bilibili-bangumi", WeightConfig(default=30, time_config=[]))
await config.update_time_weight_config(T_Target("t2"), "ncm-radio", WeightConfig(default=30, time_config=[]))
await init_scheduler()
mocker.patch.object(db_config, "_get_time", return_value=time(1, 30))
static_res = await get_schedule_times(BilibiliSite, 6)
assert static_res["bilibili-t1"] == 1
assert static_res["bilibili-t2"] == 2
assert static_res["bilibili-bangumi-t2"] == 3
static_res = await get_schedule_times(NcmSite, 6)
assert static_res["ncm-artist-t1"] == 1
assert static_res["ncm-artist-t2"] == 2
assert static_res["ncm-radio-t2"] == 3
static_res = await get_schedule_times(BilibiliSite, 6)
assert static_res["bilibili-t1"] == 1
assert static_res["bilibili-t2"] == 2
assert static_res["bilibili-bangumi-t2"] == 3
static_res = await get_schedule_times(NcmSite, 6)
assert static_res["ncm-artist-t1"] == 1
assert static_res["ncm-artist-t2"] == 2
assert static_res["ncm-radio-t2"] == 3
mocker.patch.object(db_config, "_get_time", return_value=time(10, 30))
static_res = await get_schedule_times(BilibiliSite, 6)
assert static_res["bilibili-t2"] == 6
static_res = await get_schedule_times(NcmSite, 6)
assert static_res["ncm-artist-t2"] == 6
async def test_scheduler_add_new(init_scheduler):