mirror of
https://github.com/suyiiyii/nonebot-bison.git
synced 2025-06-02 09:26:12 +08:00
🐛 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:
parent
9729d4b776
commit
af72b6c3d0
7
nonebot_bison/platform/bilibili/__init__.py
Normal file
7
nonebot_bison/platform/bilibili/__init__.py
Normal 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
|
394
nonebot_bison/platform/bilibili/models.py
Normal file
394
nonebot_bison/platform/bilibili/models.py
Normal 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
|
||||
"""文件大小,KiB(1024)"""
|
||||
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
|
||||
"""文件大小,KiB(1024)"""
|
||||
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)
|
@ -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 = "请输入剧集主页"
|
80
nonebot_bison/platform/bilibili/scheduler.py
Normal file
80
nonebot_bison/platform/bilibili/scheduler.py
Normal 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"
|
@ -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
1726
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
269
tests/platforms/static/bilibili-dynamic-live-rcmd.json
vendored
Normal file
269
tests/platforms/static/bilibili-dynamic-live-rcmd.json
vendored
Normal 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
4062
tests/platforms/static/bilibili-new.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2078
tests/platforms/static/bilibili_bing_list.json
vendored
2078
tests/platforms/static/bilibili_bing_list.json
vendored
File diff suppressed because one or more lines are too long
1933
tests/platforms/static/bilibili_strange_post-0.json
vendored
1933
tests/platforms/static/bilibili_strange_post-0.json
vendored
File diff suppressed because one or more lines are too long
2112
tests/platforms/static/bilibili_strange_post.json
vendored
2112
tests/platforms/static/bilibili_strange_post.json
vendored
File diff suppressed because one or more lines are too long
@ -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"
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user