nonebot-plugin-bililive 2.0.4__tar.gz → 2.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/PKG-INFO +1 -1
  2. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/database/db.py +49 -0
  3. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/libs/dynamic/web.py +11 -8
  4. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/pusher/dynamic_pusher.py +76 -52
  5. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/utils/__init__.py +5 -1
  6. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/utils/browser.py +27 -0
  7. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/utils/mobile.js +3 -3
  8. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/version.py +1 -1
  9. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/pyproject.toml +1 -1
  10. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/tests/test_maintenance.py +45 -1
  11. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/LICENSE +0 -0
  12. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/README.md +0 -0
  13. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/__init__.py +0 -0
  14. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/__main__.py +0 -0
  15. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/cli/__init__.py +0 -0
  16. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/cli/bot.py +0 -0
  17. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/cli/utils.py +0 -0
  18. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/compat.py +0 -0
  19. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/config.py +0 -0
  20. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/database/__init__.py +0 -0
  21. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/database/models.py +0 -0
  22. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/libs/__init__.py +0 -0
  23. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/libs/dynamic/__init__.py +0 -0
  24. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/libs/dynamic/card.py +0 -0
  25. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/libs/dynamic/desc.py +0 -0
  26. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/libs/dynamic/display.py +0 -0
  27. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/libs/dynamic/user_profile.py +0 -0
  28. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/__init__.py +0 -0
  29. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/at/__init__.py +0 -0
  30. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/at/at_off.py +0 -0
  31. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/at/at_on.py +0 -0
  32. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/auto_agree.py +0 -0
  33. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/auto_delete.py +0 -0
  34. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/dynamic/__init__.py +0 -0
  35. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/dynamic/dynamic_off.py +0 -0
  36. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/dynamic/dynamic_on.py +0 -0
  37. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/help.py +0 -0
  38. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/live/__init__.py +0 -0
  39. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/live/live_now.py +0 -0
  40. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/live/live_off.py +0 -0
  41. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/live/live_on.py +0 -0
  42. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/permission/__init__.py +0 -0
  43. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/permission/permission_off.py +0 -0
  44. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/permission/permission_on.py +0 -0
  45. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/pusher/__init__.py +0 -0
  46. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/pusher/live_pusher.py +0 -0
  47. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/sub/__init__.py +0 -0
  48. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/sub/add_sub.py +0 -0
  49. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/sub/delete_sub.py +0 -0
  50. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/plugins/sub/sub_list.py +0 -0
  51. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/utils/captcha_solver.py +0 -0
  52. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/bililive/utils/fonts_provider.py +0 -0
  53. {nonebot_plugin_bililive-2.0.4 → nonebot_plugin_bililive-2.0.6}/nonebot_plugin_bililive/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nonebot-plugin-bililive
3
- Version: 2.0.4
3
+ Version: 2.0.6
4
4
  Summary: Push bilibili dynamics and live notifications to QQ with NoneBot2.
5
5
  Keywords: nonebot,nonebot2,nonebot-plugin,qqbot,bilibili
6
6
  Author-Email: SK-415 <2967923486@qq.com>
@@ -21,6 +21,10 @@ class DB:
21
21
 
22
22
  _ready = False
23
23
 
24
+ @classmethod
25
+ def get_dynamic_offset_path(cls) -> Path:
26
+ return Path(get_path("dynamic_offset.json"))
27
+
24
28
  @classmethod
25
29
  async def init(cls):
26
30
  """初始化数据库"""
@@ -46,11 +50,14 @@ class DB:
46
50
  await Tortoise.generate_schemas()
47
51
  await cls.migrate()
48
52
  await cls.update_uid_list()
53
+ await cls.load_dynamic_offsets()
54
+ await cls.save_dynamic_offsets()
49
55
  cls._ready = True
50
56
 
51
57
  @classmethod
52
58
  async def close(cls):
53
59
  cls._ready = False
60
+ await cls.save_dynamic_offsets()
54
61
  await connections.close_all()
55
62
 
56
63
  @classmethod
@@ -68,6 +75,43 @@ class DB:
68
75
 
69
76
  return cls._ready
70
77
 
78
+ @classmethod
79
+ async def load_dynamic_offsets(cls):
80
+ path = cls.get_dynamic_offset_path()
81
+ if not path.exists():
82
+ return
83
+ try:
84
+ raw_offsets = json.loads(path.read_text(encoding="utf-8"))
85
+ except Exception:
86
+ logger.warning("动态偏移量缓存读取失败,将使用当前内存状态继续运行")
87
+ return
88
+
89
+ for uid_str, value in raw_offsets.items():
90
+ try:
91
+ uid = int(uid_str)
92
+ dynamic_id = int(value)
93
+ except (TypeError, ValueError):
94
+ continue
95
+ if uid in dynamic_offset:
96
+ dynamic_offset[uid] = dynamic_id
97
+
98
+ @classmethod
99
+ async def save_dynamic_offsets(cls):
100
+ path = cls.get_dynamic_offset_path()
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ serialized_offsets = {
103
+ str(uid): dynamic_offset[uid] for uid in sorted(dynamic_offset)
104
+ }
105
+ path.write_text(
106
+ json.dumps(serialized_offsets, ensure_ascii=False, sort_keys=True),
107
+ encoding="utf-8",
108
+ )
109
+
110
+ @classmethod
111
+ async def set_dynamic_offset(cls, uid: int, value: int):
112
+ dynamic_offset[int(uid)] = int(value)
113
+ await cls.save_dynamic_offsets()
114
+
71
115
  @classmethod
72
116
  async def get_user(cls, **kwargs):
73
117
  """获取 UP 主信息"""
@@ -277,12 +321,17 @@ class DB:
277
321
  )
278
322
 
279
323
  # 清除没有订阅的 offset
324
+ changed = False
280
325
  dynamic_offset_keys = set(dynamic_offset)
281
326
  dynamic_uids = set(uid_list["dynamic"]["list"])
282
327
  for uid in dynamic_offset_keys - dynamic_uids:
283
328
  del dynamic_offset[uid]
329
+ changed = True
284
330
  for uid in dynamic_uids - dynamic_offset_keys:
285
331
  dynamic_offset[uid] = -1
332
+ changed = True
333
+ if changed:
334
+ await cls.save_dynamic_offsets()
286
335
 
287
336
  async def backup(self):
288
337
  """备份数据库"""
@@ -47,6 +47,16 @@ def parse_web_dynamic_items(payload: dict) -> list[WebDynamicItem]:
47
47
  return parsed_items
48
48
 
49
49
 
50
+ def parse_web_dynamic_payload(payload: dict) -> list[WebDynamicItem]:
51
+ if payload.get("code") != 0:
52
+ raise WebDynamicError(
53
+ payload.get("code"),
54
+ payload.get("message") or "unknown error",
55
+ payload.get("data"),
56
+ )
57
+ return parse_web_dynamic_items(payload)
58
+
59
+
50
60
  async def get_user_dynamics_web(
51
61
  uid: int,
52
62
  cookies: dict[str, str],
@@ -69,11 +79,4 @@ async def get_user_dynamics_web(
69
79
  follow_redirects=True,
70
80
  ) as client:
71
81
  response = await client.get(WEB_DYNAMIC_URL, params={"host_mid": uid})
72
- payload = response.json()
73
- if payload.get("code") != 0:
74
- raise WebDynamicError(
75
- payload.get("code"),
76
- payload.get("message") or "unknown error",
77
- payload.get("data"),
78
- )
79
- return parse_web_dynamic_items(payload)
82
+ return parse_web_dynamic_payload(response.json())
@@ -19,15 +19,22 @@ from nonebot.log import logger
19
19
  from ...config import plugin_config
20
20
  from ...database import DB as db
21
21
  from ...database import dynamic_offset as offset
22
- from ...libs.dynamic.web import WebDynamicError, get_user_dynamics_web
22
+ from ...libs.dynamic.web import (
23
+ WebDynamicError,
24
+ get_user_dynamics_web,
25
+ parse_web_dynamic_payload,
26
+ )
23
27
  from ...utils import (
24
28
  get_bilibili_cookies,
25
29
  get_dynamic_screenshot,
30
+ get_user_dynamics_payload_in_browser,
26
31
  safe_send,
27
32
  scheduler,
28
33
  )
29
34
 
30
- RISK_CONTROL_RETRY_SECONDS = 3600
35
+ GRPC_RISK_CONTROL_RETRY_SECONDS = 3600
36
+ WEB_REQUEST_BANNED_RETRY_SECONDS = 300
37
+ WEB_REQUEST_ERROR_RETRY_SECONDS = 600
31
38
  dynamic_risk_control_until = {}
32
39
  dynamic_web_fallback_until = {}
33
40
  WEB_SKIP_DYNAMIC_TYPES = {
@@ -44,6 +51,7 @@ WEB_DYNAMIC_TYPE_MESSAGES = {
44
51
  "DYNAMIC_TYPE_ARTICLE": "发布了新专栏",
45
52
  "DYNAMIC_TYPE_MUSIC": "发布了新音频",
46
53
  }
54
+ DYNAMIC_FETCH_CONCURRENCY = 4
47
55
 
48
56
 
49
57
  async def throttle_dynamic_loop():
@@ -95,21 +103,32 @@ def should_skip_dynamic(dynamic_type, use_web_fallback: bool) -> bool:
95
103
 
96
104
 
97
105
  async def get_user_dynamics_with_web_fallback(uid: int) -> tuple[list, bool]:
106
+ async def fetch_web_dynamics() -> list:
107
+ try:
108
+ payload = await get_user_dynamics_payload_in_browser(uid)
109
+ return parse_web_dynamic_payload(payload)
110
+ except WebDynamicError as browser_error:
111
+ logger.debug(
112
+ f"浏览器上下文动态接口获取失败,尝试直连 Web API:{uid} "
113
+ f"{browser_error.code} {browser_error.msg}"
114
+ )
115
+
116
+ cookies = await get_bilibili_cookies()
117
+ if not cookies:
118
+ raise WebDynamicError(-1, "browser cookies unavailable")
119
+ return await get_user_dynamics_web(
120
+ uid,
121
+ cookies,
122
+ proxy=plugin_config.bililive_proxy,
123
+ user_agent=plugin_config.bililive_browser_ua or None,
124
+ timeout=plugin_config.bililive_dynamic_timeout,
125
+ )
126
+
98
127
  fallback_until = dynamic_web_fallback_until.get(uid)
99
128
  if fallback_until is not None:
100
129
  if fallback_until > monotonic():
101
130
  logger.debug(f"动态 gRPC 接口仍在风控,继续使用 Web 接口:{uid}")
102
- cookies = await get_bilibili_cookies()
103
- if not cookies:
104
- raise WebDynamicError(-1, "browser cookies unavailable")
105
- dynamics = await get_user_dynamics_web(
106
- uid,
107
- cookies,
108
- proxy=plugin_config.bililive_proxy,
109
- user_agent=plugin_config.bililive_browser_ua or None,
110
- timeout=plugin_config.bililive_dynamic_timeout,
111
- )
112
- return dynamics, True
131
+ return await fetch_web_dynamics(), True
113
132
  del dynamic_web_fallback_until[uid]
114
133
 
115
134
  try:
@@ -129,36 +148,14 @@ async def get_user_dynamics_with_web_fallback(uid: int) -> tuple[list, bool]:
129
148
  f"动态 gRPC 接口触发风控,切换 Web 接口:{uid} "
130
149
  f"{e.code} {e.msg}"
131
150
  )
132
- dynamic_web_fallback_until[uid] = monotonic() + RISK_CONTROL_RETRY_SECONDS
133
- cookies = await get_bilibili_cookies()
134
- if not cookies:
135
- raise WebDynamicError(-1, "browser cookies unavailable")
136
- dynamics = await get_user_dynamics_web(
137
- uid,
138
- cookies,
139
- proxy=plugin_config.bililive_proxy,
140
- user_agent=plugin_config.bililive_browser_ua or None,
141
- timeout=plugin_config.bililive_dynamic_timeout,
142
- )
143
- return dynamics, True
151
+ dynamic_web_fallback_until[uid] = monotonic() + GRPC_RISK_CONTROL_RETRY_SECONDS
152
+ return await fetch_web_dynamics(), True
144
153
 
145
154
 
146
- async def dy_sched():
147
- """动态推送"""
148
- if not await db.wait_until_ready():
149
- logger.debug("数据库尚未初始化完成,跳过本轮动态推送")
150
- await throttle_dynamic_loop()
151
- return
152
-
153
- uid = await db.next_uid("dynamic")
154
- if not uid:
155
- # 没有订阅先暂停一秒再跳过,不然会导致 CPU 占用过高
156
- await throttle_dynamic_loop()
157
- return
155
+ async def process_dynamic_uid(uid: int):
158
156
  user = await db.get_user(uid=uid)
159
157
  if user is None:
160
158
  logger.warning(f"动态推送跳过异常订阅 UID:{uid}")
161
- await throttle_dynamic_loop()
162
159
  return
163
160
  name = user.name
164
161
 
@@ -186,36 +183,36 @@ async def dy_sched():
186
183
  return
187
184
  except GrpcError as e:
188
185
  logger.error(f"爬取动态失败:{e.code} {e.msg}")
189
- await throttle_dynamic_loop()
190
186
  return
191
187
  except WebDynamicError as e:
192
- dynamic_risk_control_until[uid] = monotonic() + RISK_CONTROL_RETRY_SECONDS
193
- retry_minutes = RISK_CONTROL_RETRY_SECONDS // 60
188
+ retry_seconds = (
189
+ WEB_REQUEST_BANNED_RETRY_SECONDS
190
+ if e.code == -412
191
+ else WEB_REQUEST_ERROR_RETRY_SECONDS
192
+ )
193
+ dynamic_risk_control_until[uid] = monotonic() + retry_seconds
194
+ retry_minutes = max(retry_seconds // 60, 1)
194
195
  logger.warning(
195
196
  f"动态 Web 接口获取失败,{name}({uid})将在 "
196
197
  f"{retry_minutes} 分钟后重试:{e.code} {e.msg}"
197
198
  )
198
- await throttle_dynamic_loop()
199
199
  return
200
200
 
201
201
  dynamic_risk_control_until.pop(uid, None)
202
202
 
203
203
  if not dynamics: # 没发过动态
204
204
  if uid in offset and offset[uid] == -1: # 不记录会导致第一次发动态不推送
205
- offset[uid] = 0
205
+ await db.set_dynamic_offset(uid, 0)
206
206
  return
207
207
  name = get_dynamic_author_name(dynamics[0], use_web_fallback)
208
208
 
209
209
  if uid not in offset: # 已删除
210
210
  return
211
211
  elif offset[uid] == -1: # 第一次爬取
212
- if len(dynamics) == 1: # 只有一条动态
213
- offset[uid] = get_dynamic_id(dynamics[0], use_web_fallback)
214
- else: # 第一个可能是置顶动态,但置顶也可能是最新一条,所以取前两条的最大值
215
- offset[uid] = max(
216
- get_dynamic_id(dynamics[0], use_web_fallback),
217
- get_dynamic_id(dynamics[1], use_web_fallback),
218
- )
212
+ await db.set_dynamic_offset(
213
+ uid,
214
+ max(get_dynamic_id(item, use_web_fallback) for item in dynamics),
215
+ )
219
216
  return
220
217
 
221
218
  dynamic = None
@@ -234,7 +231,7 @@ async def dy_sched():
234
231
  return
235
232
  elif should_skip_dynamic(dynamic_type, use_web_fallback):
236
233
  logger.debug(f"无需推送的动态 {dynamic_type},已跳过:{url}")
237
- offset[uid] = dynamic_id
234
+ await db.set_dynamic_offset(uid, dynamic_id)
238
235
  return
239
236
  message = (
240
237
  f"{name} {get_dynamic_type_message(dynamic_type, use_web_fallback)}:\n"
@@ -253,12 +250,36 @@ async def dy_sched():
253
250
  at=bool(sets.at) and plugin_config.bililive_dynamic_at,
254
251
  )
255
252
 
256
- offset[uid] = dynamic_id
253
+ await db.set_dynamic_offset(uid, dynamic_id)
257
254
 
258
255
  if dynamic:
259
256
  await db.update_user(uid, name)
260
257
 
261
258
 
259
+ async def dy_sched():
260
+ """动态推送"""
261
+ if not await db.wait_until_ready():
262
+ logger.debug("数据库尚未初始化完成,跳过本轮动态推送")
263
+ await throttle_dynamic_loop()
264
+ return
265
+
266
+ uids = await db.get_uid_list("dynamic")
267
+ if not uids:
268
+ # 没有订阅先暂停一秒再跳过,不然会导致 CPU 占用过高
269
+ await throttle_dynamic_loop()
270
+ return
271
+
272
+ logger.debug(f"爬取动态列表,总共 {len(uids)} 人")
273
+ semaphore = asyncio.Semaphore(DYNAMIC_FETCH_CONCURRENCY)
274
+
275
+ async def run_for_uid(uid: int):
276
+ async with semaphore:
277
+ await process_dynamic_uid(uid)
278
+
279
+ await asyncio.gather(*(run_for_uid(uid) for uid in uids))
280
+ await throttle_dynamic_loop()
281
+
282
+
262
283
  def dynamic_lisener(event):
263
284
  if hasattr(event, "job_id") and event.job_id != "dynamic_sched":
264
285
  return
@@ -280,4 +301,7 @@ else:
280
301
  "interval",
281
302
  seconds=plugin_config.bililive_dynamic_interval,
282
303
  id="dynamic_sched",
304
+ coalesce=True,
305
+ max_instances=1,
306
+ misfire_grace_time=5,
283
307
  )
@@ -304,4 +304,8 @@ PROXIES = {"all://": plugin_config.bililive_proxy}
304
304
  require("nonebot_plugin_apscheduler")
305
305
  from nonebot_plugin_apscheduler import scheduler # noqa
306
306
 
307
- from .browser import get_bilibili_cookies, get_dynamic_screenshot # noqa
307
+ from .browser import ( # noqa
308
+ get_bilibili_cookies,
309
+ get_dynamic_screenshot,
310
+ get_user_dynamics_payload_in_browser,
311
+ )
@@ -16,6 +16,7 @@ from .fonts_provider import fill_font
16
16
 
17
17
  _browser: BrowserContext | None = None
18
18
  mobile_js = Path(__file__).parent.joinpath("mobile.js")
19
+ WEB_DYNAMIC_URL = "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space"
19
20
 
20
21
 
21
22
  async def init_browser(proxy=plugin_config.bililive_proxy, **kwargs) -> BrowserContext:
@@ -72,6 +73,32 @@ async def get_bilibili_cookies() -> dict[str, str]:
72
73
  return {cookie["name"]: cookie["value"] for cookie in cookies}
73
74
 
74
75
 
76
+ async def get_user_dynamics_payload_in_browser(uid: int) -> dict:
77
+ browser = await get_browser()
78
+ page = await browser.new_page()
79
+ try:
80
+ await page.goto(
81
+ "https://www.bilibili.com/",
82
+ wait_until="domcontentloaded",
83
+ timeout=plugin_config.bililive_dynamic_timeout * 1000,
84
+ )
85
+ return await page.evaluate(
86
+ """async ({ url, uid }) => {
87
+ const response = await fetch(`${url}?host_mid=${uid}`, {
88
+ credentials: 'include',
89
+ headers: {
90
+ accept: 'application/json, text/plain, */*',
91
+ },
92
+ });
93
+ return await response.json();
94
+ }""",
95
+ {"url": WEB_DYNAMIC_URL, "uid": str(uid)},
96
+ )
97
+ finally:
98
+ with contextlib.suppress(Exception):
99
+ await page.close()
100
+
101
+
75
102
  async def get_dynamic_screenshot(
76
103
  dynamic_id,
77
104
  style=plugin_config.bililive_screenshot_style,
@@ -183,13 +183,13 @@ function setFont(font = "", fontSource = "local") {
183
183
  // 将字体样式设置到 div#app 上
184
184
  const appDom = document.querySelector("#app");
185
185
  const emojiFont = emojiFontList.join(",");
186
+ const fallbackFonts = needLoadFontList.map(fontObject => fontObject.fontFamily).join(",");
186
187
  if (appDom) {
187
188
  // 动态加字体, 并给与默认值 sans-serif
188
189
  if (fontSource === "system") {
189
- appDom.style.fontFamily = font + "," + emojiFont + ",sans-serif";
190
+ appDom.style.fontFamily = [font, fallbackFonts, emojiFont, "sans-serif"].filter(Boolean).join(",");
190
191
  } else {
191
- const needLoadFont = needLoadFontList.reduce((defaultString, fontObject) => defaultString + fontObject.fontFamily + ",", "");
192
- appDom.style.fontFamily = needLoadFont + emojiFont + ",sans-serif";
192
+ appDom.style.fontFamily = [fallbackFonts, emojiFont, "sans-serif"].filter(Boolean).join(",");
193
193
  };
194
194
  appDom.style.overflowWrap = "break-word";
195
195
  }
@@ -1,4 +1,4 @@
1
1
  from packaging.version import Version
2
2
 
3
- __version__ = "2.0.4"
3
+ __version__ = "2.0.6"
4
4
  VERSION = Version(__version__)
@@ -38,7 +38,7 @@ dependencies = [
38
38
  "msvc-runtime>=14.34.31931; sys_platform == \"win32\"",
39
39
  ]
40
40
  dynamic = []
41
- version = "2.0.4"
41
+ version = "2.0.6"
42
42
 
43
43
  [project.license]
44
44
  text = "AGPL-3.0-or-later"
@@ -1,6 +1,8 @@
1
1
  import sys
2
+ import tempfile
2
3
  import unittest
3
4
  from importlib import import_module
5
+ from pathlib import Path
4
6
  from types import ModuleType, SimpleNamespace
5
7
  from unittest.mock import AsyncMock, patch
6
8
 
@@ -18,7 +20,20 @@ class DummyDriver:
18
20
 
19
21
 
20
22
  fake_apscheduler = ModuleType("nonebot_plugin_apscheduler")
21
- fake_apscheduler.scheduler = SimpleNamespace()
23
+
24
+
25
+ class DummyScheduler:
26
+ def add_listener(self, *args, **kwargs):
27
+ return None
28
+
29
+ def add_job(self, *args, **kwargs):
30
+ return None
31
+
32
+ def get_job(self, *args, **kwargs):
33
+ return None
34
+
35
+
36
+ fake_apscheduler.scheduler = DummyScheduler()
22
37
 
23
38
 
24
39
  with patch("nonebot.get_driver", return_value=DummyDriver()), patch(
@@ -129,6 +144,18 @@ class WebDynamicTests(unittest.TestCase):
129
144
 
130
145
  self.assertEqual(web_dynamic.parse_web_dynamic_items(payload), [])
131
146
 
147
+ def test_parse_web_dynamic_payload_raises_for_error_code(self):
148
+ payload = {
149
+ "code": -412,
150
+ "message": "request was banned",
151
+ "data": None,
152
+ }
153
+
154
+ with self.assertRaises(web_dynamic.WebDynamicError) as context:
155
+ web_dynamic.parse_web_dynamic_payload(payload)
156
+
157
+ self.assertEqual(context.exception.code, -412)
158
+
132
159
 
133
160
  class DBPermissionTests(unittest.IsolatedAsyncioTestCase):
134
161
  async def test_db_init_enables_global_fallback(self):
@@ -157,6 +184,23 @@ class DBPermissionTests(unittest.IsolatedAsyncioTestCase):
157
184
 
158
185
  self.assertFalse(ready)
159
186
 
187
+ async def test_dynamic_offsets_are_persisted_and_restored(self):
188
+ with tempfile.TemporaryDirectory() as tmpdir:
189
+ offset_path = Path(tmpdir) / "dynamic_offset.json"
190
+ db_module.dynamic_offset.clear()
191
+ db_module.dynamic_offset[123] = 456
192
+
193
+ with patch.object(db_module, "get_path", return_value=str(offset_path)):
194
+ await DB.save_dynamic_offsets()
195
+
196
+ db_module.dynamic_offset.clear()
197
+ db_module.dynamic_offset[123] = -1
198
+ db_module.dynamic_offset[789] = -1
199
+ await DB.load_dynamic_offsets()
200
+
201
+ self.assertEqual(db_module.dynamic_offset[123], 456)
202
+ self.assertEqual(db_module.dynamic_offset[789], -1)
203
+
160
204
  async def test_set_permission_creates_group_when_missing(self):
161
205
  with (
162
206
  patch.object(DB, "get_group", new=AsyncMock(return_value=None)),