nonebot-plugin-bililive 2.0.2__tar.gz → 2.0.4__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 (54) hide show
  1. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/PKG-INFO +4 -32
  2. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/README.md +3 -31
  3. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/database/db.py +22 -1
  4. nonebot_plugin_bililive-2.0.4/bililive/libs/dynamic/web.py +79 -0
  5. nonebot_plugin_bililive-2.0.4/bililive/plugins/pusher/dynamic_pusher.py +283 -0
  6. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/pusher/live_pusher.py +10 -1
  7. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/utils/__init__.py +1 -1
  8. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/utils/browser.py +9 -0
  9. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/version.py +1 -1
  10. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/pyproject.toml +1 -1
  11. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/tests/test_maintenance.py +69 -1
  12. nonebot_plugin_bililive-2.0.2/bililive/plugins/pusher/dynamic_pusher.py +0 -151
  13. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/LICENSE +0 -0
  14. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/__init__.py +0 -0
  15. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/__main__.py +0 -0
  16. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/cli/__init__.py +0 -0
  17. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/cli/bot.py +0 -0
  18. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/cli/utils.py +0 -0
  19. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/compat.py +0 -0
  20. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/config.py +0 -0
  21. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/database/__init__.py +0 -0
  22. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/database/models.py +0 -0
  23. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/libs/__init__.py +0 -0
  24. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/libs/dynamic/__init__.py +0 -0
  25. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/libs/dynamic/card.py +0 -0
  26. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/libs/dynamic/desc.py +0 -0
  27. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/libs/dynamic/display.py +0 -0
  28. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/libs/dynamic/user_profile.py +0 -0
  29. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/__init__.py +0 -0
  30. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/at/__init__.py +0 -0
  31. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/at/at_off.py +0 -0
  32. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/at/at_on.py +0 -0
  33. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/auto_agree.py +0 -0
  34. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/auto_delete.py +0 -0
  35. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/dynamic/__init__.py +0 -0
  36. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/dynamic/dynamic_off.py +0 -0
  37. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/dynamic/dynamic_on.py +0 -0
  38. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/help.py +0 -0
  39. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/live/__init__.py +0 -0
  40. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/live/live_now.py +0 -0
  41. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/live/live_off.py +0 -0
  42. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/live/live_on.py +0 -0
  43. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/permission/__init__.py +0 -0
  44. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/permission/permission_off.py +0 -0
  45. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/permission/permission_on.py +0 -0
  46. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/pusher/__init__.py +0 -0
  47. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/sub/__init__.py +0 -0
  48. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/sub/add_sub.py +0 -0
  49. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/sub/delete_sub.py +0 -0
  50. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/plugins/sub/sub_list.py +0 -0
  51. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/utils/captcha_solver.py +0 -0
  52. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/utils/fonts_provider.py +0 -0
  53. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/bililive/utils/mobile.js +0 -0
  54. {nonebot_plugin_bililive-2.0.2 → nonebot_plugin_bililive-2.0.4}/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.2
3
+ Version: 2.0.4
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>
@@ -51,42 +51,12 @@ _✨ 将 B 站 UP 主动态与直播推送到 QQ 的 NoneBot2 插件 ✨_
51
51
  <img src="https://img.shields.io/pypi/v/nonebot-plugin-bililive.svg" alt="pypi">
52
52
  </a>
53
53
  <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
54
- <a href="https://jq.qq.com/?_wv=1027&k=sHPbCRAd">
55
- <img src="https://img.shields.io/badge/QQ%E7%BE%A4-629574472-orange" alt="qq group">
56
- </a>
57
54
 
58
55
  </div>
59
56
 
60
- > 当前仓库已按 NoneBot 插件模板整理,可直接作为插件包发布到 PyPI 并在 NoneBot2 项目中安装使用。
61
-
62
- <details>
63
- <summary>配置发布工作流</summary>
64
-
65
- 1. 前往 https://pypi.org/manage/account/#api-tokens 创建新的 PyPI API Token。
66
- 2. 打开当前 GitHub 仓库的 Settings - Secrets and variables - Actions。
67
- 3. 新建名为 PYPI_API_TOKEN 的 Repository Secret,并填入刚刚创建的 Token。
68
-
69
- </details>
70
-
71
- > [!IMPORTANT]
72
- > 当前项目使用符合 PEP 621 的 pyproject.toml,并已补充基于 tag 触发的 PyPI 发布工作流。
73
-
74
- <details>
75
- <summary>触发发布</summary>
76
-
77
- 创建 tag:
78
-
79
- git tag v1.6.0post5
80
-
81
- 推送 tag:
82
-
83
- git push origin --tags
84
-
85
- </details>
86
-
87
57
  ## 📖 介绍
88
58
 
89
- BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。当前项目已将发布名、插件入口、核心实现包和工作流统一整理为 nonebot-plugin-bililive,便于作为独立插件分发。
59
+ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。
90
60
 
91
61
  ### 特性
92
62
 
@@ -142,6 +112,8 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
142
112
  | BILILIVE_DYNAMIC_BIG_IMAGE | 否 | false | 是否优先展示大图 |
143
113
  | BILILIVE_COMMAND_PREFIX | 否 | 空字符串 | 命令额外前缀 |
144
114
 
115
+ 动态抓取默认优先使用 gRPC 接口;当部分 UID 命中 B 站风控时,插件会自动回退到 Playwright 持久化浏览器中的 cookies 请求网页动态接口。通常不需要额外配置 Cookie 登录;如果某些 UID 仍持续抓取失败,建议在插件使用的浏览器数据目录中登录一个常用的 B 站账号,以提高动态抓取成功率。
116
+
145
117
  ## 🎉 使用
146
118
 
147
119
  ### 指令表
@@ -17,42 +17,12 @@ _✨ 将 B 站 UP 主动态与直播推送到 QQ 的 NoneBot2 插件 ✨_
17
17
  <img src="https://img.shields.io/pypi/v/nonebot-plugin-bililive.svg" alt="pypi">
18
18
  </a>
19
19
  <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
20
- <a href="https://jq.qq.com/?_wv=1027&k=sHPbCRAd">
21
- <img src="https://img.shields.io/badge/QQ%E7%BE%A4-629574472-orange" alt="qq group">
22
- </a>
23
20
 
24
21
  </div>
25
22
 
26
- > 当前仓库已按 NoneBot 插件模板整理,可直接作为插件包发布到 PyPI 并在 NoneBot2 项目中安装使用。
27
-
28
- <details>
29
- <summary>配置发布工作流</summary>
30
-
31
- 1. 前往 https://pypi.org/manage/account/#api-tokens 创建新的 PyPI API Token。
32
- 2. 打开当前 GitHub 仓库的 Settings - Secrets and variables - Actions。
33
- 3. 新建名为 PYPI_API_TOKEN 的 Repository Secret,并填入刚刚创建的 Token。
34
-
35
- </details>
36
-
37
- > [!IMPORTANT]
38
- > 当前项目使用符合 PEP 621 的 pyproject.toml,并已补充基于 tag 触发的 PyPI 发布工作流。
39
-
40
- <details>
41
- <summary>触发发布</summary>
42
-
43
- 创建 tag:
44
-
45
- git tag v1.6.0post5
46
-
47
- 推送 tag:
48
-
49
- git push origin --tags
50
-
51
- </details>
52
-
53
23
  ## 📖 介绍
54
24
 
55
- BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。当前项目已将发布名、插件入口、核心实现包和工作流统一整理为 nonebot-plugin-bililive,便于作为独立插件分发。
25
+ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。
56
26
 
57
27
  ### 特性
58
28
 
@@ -108,6 +78,8 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
108
78
  | BILILIVE_DYNAMIC_BIG_IMAGE | 否 | false | 是否优先展示大图 |
109
79
  | BILILIVE_COMMAND_PREFIX | 否 | 空字符串 | 命令额外前缀 |
110
80
 
81
+ 动态抓取默认优先使用 gRPC 接口;当部分 UID 命中 B 站风控时,插件会自动回退到 Playwright 持久化浏览器中的 cookies 请求网页动态接口。通常不需要额外配置 Cookie 登录;如果某些 UID 仍持续抓取失败,建议在插件使用的浏览器数据目录中登录一个常用的 B 站账号,以提高动态抓取成功率。
82
+
111
83
  ## 🎉 使用
112
84
 
113
85
  ### 指令表
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import json
2
3
  from pathlib import Path
3
4
 
@@ -18,9 +19,12 @@ dynamic_offset = {}
18
19
  class DB:
19
20
  """数据库交互类,与增删改查无关的部分不应该在这里面实现"""
20
21
 
22
+ _ready = False
23
+
21
24
  @classmethod
22
25
  async def init(cls):
23
26
  """初始化数据库"""
27
+ cls._ready = False
24
28
  config = {
25
29
  "connections": {
26
30
  # "bililive": {
@@ -37,16 +41,33 @@ class DB:
37
41
  },
38
42
  }
39
43
 
40
- await Tortoise.init(config)
44
+ await Tortoise.init(config, _enable_global_fallback=True)
41
45
 
42
46
  await Tortoise.generate_schemas()
43
47
  await cls.migrate()
44
48
  await cls.update_uid_list()
49
+ cls._ready = True
45
50
 
46
51
  @classmethod
47
52
  async def close(cls):
53
+ cls._ready = False
48
54
  await connections.close_all()
49
55
 
56
+ @classmethod
57
+ async def wait_until_ready(cls, timeout: float = 30) -> bool:
58
+ if cls._ready:
59
+ return True
60
+
61
+ waited = 0.0
62
+ interval = 0.1
63
+ while waited < timeout:
64
+ if cls._ready:
65
+ return True
66
+ await asyncio.sleep(interval)
67
+ waited += interval
68
+
69
+ return cls._ready
70
+
50
71
  @classmethod
51
72
  async def get_user(cls, **kwargs):
52
73
  """获取 UP 主信息"""
@@ -0,0 +1,79 @@
1
+ from dataclasses import dataclass
2
+
3
+ import httpx
4
+
5
+ WEB_DYNAMIC_URL = "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space"
6
+ DEFAULT_BROWSER_USER_AGENT = (
7
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
8
+ "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
9
+ )
10
+
11
+
12
+ class WebDynamicError(RuntimeError):
13
+ def __init__(self, code, msg, data=None):
14
+ super().__init__(f"{code} {msg}")
15
+ self.code = code
16
+ self.msg = msg
17
+ self.data = data
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class WebDynamicItem:
22
+ dynamic_id: int
23
+ dynamic_type: str
24
+ author_name: str
25
+
26
+
27
+ def parse_web_dynamic_items(payload: dict) -> list[WebDynamicItem]:
28
+ items = payload.get("data", {}).get("items") or []
29
+ parsed_items = []
30
+ for item in items:
31
+ dynamic_id = item.get("id_str")
32
+ modules = item.get("modules") or {}
33
+ author_name = (modules.get("module_author") or {}).get("name")
34
+ dynamic_type = item.get("type") or ""
35
+ if not dynamic_id or not author_name:
36
+ continue
37
+ try:
38
+ parsed_items.append(
39
+ WebDynamicItem(
40
+ dynamic_id=int(dynamic_id),
41
+ dynamic_type=dynamic_type,
42
+ author_name=author_name,
43
+ )
44
+ )
45
+ except (TypeError, ValueError):
46
+ continue
47
+ return parsed_items
48
+
49
+
50
+ async def get_user_dynamics_web(
51
+ uid: int,
52
+ cookies: dict[str, str],
53
+ *,
54
+ proxy: str | None = None,
55
+ user_agent: str | None = None,
56
+ timeout: int = 10,
57
+ ) -> list[WebDynamicItem]:
58
+ headers = {
59
+ "User-Agent": user_agent or DEFAULT_BROWSER_USER_AGENT,
60
+ "Referer": f"https://space.bilibili.com/{uid}/dynamic",
61
+ "Accept": "application/json, text/plain, */*",
62
+ "Origin": "https://space.bilibili.com",
63
+ }
64
+ async with httpx.AsyncClient(
65
+ proxy=proxy,
66
+ headers=headers,
67
+ cookies=cookies,
68
+ timeout=timeout,
69
+ follow_redirects=True,
70
+ ) as client:
71
+ 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)
@@ -0,0 +1,283 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ from time import monotonic
4
+
5
+ from apscheduler.events import (
6
+ EVENT_JOB_ERROR,
7
+ EVENT_JOB_EXECUTED,
8
+ EVENT_JOB_MISSED,
9
+ EVENT_SCHEDULER_STARTED,
10
+ )
11
+ from bilireq.exceptions import GrpcError
12
+ from bilireq.grpc.dynamic import grpc_get_user_dynamics
13
+ from bilireq.grpc.protos.bilibili.app.dynamic.v2.dynamic_pb2 import DynamicType
14
+ from grpc import StatusCode
15
+ from grpc.aio import AioRpcError
16
+ from nonebot.adapters.onebot.v11.message import MessageSegment
17
+ from nonebot.log import logger
18
+
19
+ from ...config import plugin_config
20
+ from ...database import DB as db
21
+ from ...database import dynamic_offset as offset
22
+ from ...libs.dynamic.web import WebDynamicError, get_user_dynamics_web
23
+ from ...utils import (
24
+ get_bilibili_cookies,
25
+ get_dynamic_screenshot,
26
+ safe_send,
27
+ scheduler,
28
+ )
29
+
30
+ RISK_CONTROL_RETRY_SECONDS = 3600
31
+ dynamic_risk_control_until = {}
32
+ dynamic_web_fallback_until = {}
33
+ WEB_SKIP_DYNAMIC_TYPES = {
34
+ "DYNAMIC_TYPE_LIVE_RCMD",
35
+ "DYNAMIC_TYPE_LIVE",
36
+ "DYNAMIC_TYPE_AD",
37
+ "DYNAMIC_TYPE_BANNER",
38
+ }
39
+ WEB_DYNAMIC_TYPE_MESSAGES = {
40
+ "DYNAMIC_TYPE_FORWARD": "转发了一条动态",
41
+ "DYNAMIC_TYPE_WORD": "发布了新文字动态",
42
+ "DYNAMIC_TYPE_DRAW": "发布了新图文动态",
43
+ "DYNAMIC_TYPE_AV": "发布了新投稿",
44
+ "DYNAMIC_TYPE_ARTICLE": "发布了新专栏",
45
+ "DYNAMIC_TYPE_MUSIC": "发布了新音频",
46
+ }
47
+
48
+
49
+ async def throttle_dynamic_loop():
50
+ if plugin_config.bililive_dynamic_interval == 0:
51
+ await asyncio.sleep(1)
52
+
53
+
54
+ def get_dynamic_id(dynamic, use_web_fallback: bool) -> int:
55
+ if use_web_fallback:
56
+ return dynamic.dynamic_id
57
+ return int(dynamic.extend.dyn_id_str)
58
+
59
+
60
+ def get_dynamic_type(dynamic, use_web_fallback: bool):
61
+ if use_web_fallback:
62
+ return dynamic.dynamic_type
63
+ return dynamic.card_type
64
+
65
+
66
+ def get_dynamic_author_name(dynamic, use_web_fallback: bool) -> str:
67
+ if use_web_fallback:
68
+ return dynamic.author_name
69
+ return dynamic.modules[0].module_author.author.name
70
+
71
+
72
+ def get_dynamic_type_message(dynamic_type, use_web_fallback: bool) -> str:
73
+ if use_web_fallback:
74
+ return WEB_DYNAMIC_TYPE_MESSAGES.get(dynamic_type, "发布了新动态")
75
+ return {
76
+ 0: "发布了新动态",
77
+ DynamicType.forward: "转发了一条动态",
78
+ DynamicType.word: "发布了新文字动态",
79
+ DynamicType.draw: "发布了新图文动态",
80
+ DynamicType.av: "发布了新投稿",
81
+ DynamicType.article: "发布了新专栏",
82
+ DynamicType.music: "发布了新音频",
83
+ }.get(dynamic_type, "发布了新动态")
84
+
85
+
86
+ def should_skip_dynamic(dynamic_type, use_web_fallback: bool) -> bool:
87
+ if use_web_fallback:
88
+ return dynamic_type in WEB_SKIP_DYNAMIC_TYPES
89
+ return dynamic_type in [
90
+ DynamicType.live_rcmd,
91
+ DynamicType.live,
92
+ DynamicType.ad,
93
+ DynamicType.banner,
94
+ ]
95
+
96
+
97
+ async def get_user_dynamics_with_web_fallback(uid: int) -> tuple[list, bool]:
98
+ fallback_until = dynamic_web_fallback_until.get(uid)
99
+ if fallback_until is not None:
100
+ if fallback_until > monotonic():
101
+ 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
113
+ del dynamic_web_fallback_until[uid]
114
+
115
+ try:
116
+ dynamics = (
117
+ await grpc_get_user_dynamics(
118
+ uid,
119
+ timeout=plugin_config.bililive_dynamic_timeout,
120
+ proxy=plugin_config.bililive_proxy,
121
+ )
122
+ ).list
123
+ dynamic_web_fallback_until.pop(uid, None)
124
+ return list(dynamics), False
125
+ except GrpcError as e:
126
+ if e.code != -352:
127
+ raise
128
+ logger.warning(
129
+ f"动态 gRPC 接口触发风控,切换 Web 接口:{uid} "
130
+ f"{e.code} {e.msg}"
131
+ )
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
144
+
145
+
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
158
+ user = await db.get_user(uid=uid)
159
+ if user is None:
160
+ logger.warning(f"动态推送跳过异常订阅 UID:{uid}")
161
+ await throttle_dynamic_loop()
162
+ return
163
+ name = user.name
164
+
165
+ retry_at = dynamic_risk_control_until.get(uid)
166
+ if retry_at is not None:
167
+ if retry_at > monotonic():
168
+ logger.debug(f"动态接口风控冷却中,跳过 {name}({uid})")
169
+ await throttle_dynamic_loop()
170
+ return
171
+ del dynamic_risk_control_until[uid]
172
+
173
+ logger.debug(f"爬取动态 {name}({uid})")
174
+ use_web_fallback = False
175
+ try:
176
+ dynamics, use_web_fallback = await get_user_dynamics_with_web_fallback(uid)
177
+ except asyncio.CancelledError:
178
+ logger.debug(f"动态轮询任务已取消:{name}({uid})")
179
+ return
180
+ except AioRpcError as e:
181
+ if e.code() == StatusCode.DEADLINE_EXCEEDED:
182
+ logger.error(f"爬取动态超时,将在下个轮询中重试:{e.code()} {e.details()}")
183
+ else:
184
+ logger.error(f"爬取动态失败:{e.code()} {e.details()}")
185
+ await throttle_dynamic_loop()
186
+ return
187
+ except GrpcError as e:
188
+ logger.error(f"爬取动态失败:{e.code} {e.msg}")
189
+ await throttle_dynamic_loop()
190
+ return
191
+ except WebDynamicError as e:
192
+ dynamic_risk_control_until[uid] = monotonic() + RISK_CONTROL_RETRY_SECONDS
193
+ retry_minutes = RISK_CONTROL_RETRY_SECONDS // 60
194
+ logger.warning(
195
+ f"动态 Web 接口获取失败,{name}({uid})将在 "
196
+ f"{retry_minutes} 分钟后重试:{e.code} {e.msg}"
197
+ )
198
+ await throttle_dynamic_loop()
199
+ return
200
+
201
+ dynamic_risk_control_until.pop(uid, None)
202
+
203
+ if not dynamics: # 没发过动态
204
+ if uid in offset and offset[uid] == -1: # 不记录会导致第一次发动态不推送
205
+ offset[uid] = 0
206
+ return
207
+ name = get_dynamic_author_name(dynamics[0], use_web_fallback)
208
+
209
+ if uid not in offset: # 已删除
210
+ return
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
+ )
219
+ return
220
+
221
+ dynamic = None
222
+ for dynamic in sorted(
223
+ dynamics,
224
+ key=lambda x: get_dynamic_id(x, use_web_fallback), # 动态从旧到新排列
225
+ ):
226
+ dynamic_id = get_dynamic_id(dynamic, use_web_fallback)
227
+ dynamic_type = get_dynamic_type(dynamic, use_web_fallback)
228
+ if dynamic_id > offset[uid]:
229
+ logger.info(f"检测到新动态({dynamic_id}):{name}({uid})")
230
+ image, err = await get_dynamic_screenshot(dynamic_id)
231
+ url = f"https://t.bilibili.com/{dynamic_id}"
232
+ if image is None:
233
+ logger.debug(f"动态不存在,已跳过:{url}")
234
+ return
235
+ elif should_skip_dynamic(dynamic_type, use_web_fallback):
236
+ logger.debug(f"无需推送的动态 {dynamic_type},已跳过:{url}")
237
+ offset[uid] = dynamic_id
238
+ return
239
+ message = (
240
+ f"{name} {get_dynamic_type_message(dynamic_type, use_web_fallback)}:\n"
241
+ + str(f"动态图片可能截图异常:{err}\n" if err else "")
242
+ + MessageSegment.image(image)
243
+ + f"\n{url}"
244
+ )
245
+
246
+ push_list = await db.get_push_list(uid, "dynamic")
247
+ for sets in push_list:
248
+ await safe_send(
249
+ bot_id=sets.bot_id,
250
+ send_type=sets.type,
251
+ type_id=sets.type_id,
252
+ message=message,
253
+ at=bool(sets.at) and plugin_config.bililive_dynamic_at,
254
+ )
255
+
256
+ offset[uid] = dynamic_id
257
+
258
+ if dynamic:
259
+ await db.update_user(uid, name)
260
+
261
+
262
+ def dynamic_lisener(event):
263
+ if hasattr(event, "job_id") and event.job_id != "dynamic_sched":
264
+ return
265
+ job = scheduler.get_job("dynamic_sched")
266
+ if not job:
267
+ scheduler.add_job(
268
+ dy_sched, id="dynamic_sched", next_run_time=datetime.now(scheduler.timezone)
269
+ )
270
+
271
+
272
+ if plugin_config.bililive_dynamic_interval == 0:
273
+ scheduler.add_listener(
274
+ dynamic_lisener,
275
+ EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_SCHEDULER_STARTED,
276
+ )
277
+ else:
278
+ scheduler.add_job(
279
+ dy_sched,
280
+ "interval",
281
+ seconds=plugin_config.bililive_dynamic_interval,
282
+ id="dynamic_sched",
283
+ )
@@ -13,11 +13,20 @@ live_time = {}
13
13
 
14
14
 
15
15
  @scheduler.scheduled_job(
16
- "interval", seconds=plugin_config.bililive_live_interval, id="live_sched"
16
+ "interval",
17
+ seconds=plugin_config.bililive_live_interval,
18
+ id="live_sched",
19
+ coalesce=True,
20
+ max_instances=1,
21
+ misfire_grace_time=5,
17
22
  )
18
23
  async def live_sched():
19
24
  # sourcery skip: use-fstring-for-concatenation
20
25
  """直播推送"""
26
+ if not await db.wait_until_ready():
27
+ logger.debug("数据库尚未初始化完成,跳过本轮直播推送")
28
+ return
29
+
21
30
  uids = await db.get_uid_list("live")
22
31
 
23
32
  if not uids: # 订阅为空
@@ -304,4 +304,4 @@ 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_dynamic_screenshot # noqa
307
+ from .browser import get_bilibili_cookies, get_dynamic_screenshot # noqa
@@ -63,6 +63,15 @@ async def get_browser() -> BrowserContext:
63
63
  return _browser
64
64
 
65
65
 
66
+ async def get_bilibili_cookies() -> dict[str, str]:
67
+ browser = await get_browser()
68
+ cookies = await browser.cookies([
69
+ "https://www.bilibili.com/",
70
+ "https://api.bilibili.com/",
71
+ ])
72
+ return {cookie["name"]: cookie["value"] for cookie in cookies}
73
+
74
+
66
75
  async def get_dynamic_screenshot(
67
76
  dynamic_id,
68
77
  style=plugin_config.bililive_screenshot_style,
@@ -1,4 +1,4 @@
1
1
  from packaging.version import Version
2
2
 
3
- __version__ = "2.0.2"
3
+ __version__ = "2.0.4"
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.2"
41
+ version = "2.0.4"
42
42
 
43
43
  [project.license]
44
44
  text = "AGPL-3.0-or-later"
@@ -27,8 +27,10 @@ with patch("nonebot.get_driver", return_value=DummyDriver()), patch(
27
27
  compat = import_module("bililive.compat")
28
28
  Config = import_module("bililive.config").Config
29
29
  core_version = import_module("bililive.version")
30
+ db_module = import_module("bililive.database.db")
31
+ web_dynamic = import_module("bililive.libs.dynamic.web")
30
32
  plugin_entry = import_module("nonebot_plugin_bililive")
31
- DB = import_module("bililive.database.db").DB
33
+ DB = db_module.DB
32
34
  models = import_module("bililive.database.models")
33
35
  Group = models.Group
34
36
 
@@ -88,7 +90,73 @@ class PluginEntryTests(unittest.TestCase):
88
90
  self.assertEqual(plugin_entry.__version__, core_version.__version__)
89
91
 
90
92
 
93
+ class WebDynamicTests(unittest.TestCase):
94
+ def test_parse_web_dynamic_items_extracts_required_fields(self):
95
+ payload = {
96
+ "data": {
97
+ "items": [
98
+ {
99
+ "id_str": "1190297023030493193",
100
+ "type": "DYNAMIC_TYPE_DRAW",
101
+ "modules": {
102
+ "module_author": {"name": "玻啵莉Polly"},
103
+ },
104
+ }
105
+ ]
106
+ }
107
+ }
108
+
109
+ items = web_dynamic.parse_web_dynamic_items(payload)
110
+
111
+ self.assertEqual(len(items), 1)
112
+ self.assertEqual(items[0].dynamic_id, 1190297023030493193)
113
+ self.assertEqual(items[0].dynamic_type, "DYNAMIC_TYPE_DRAW")
114
+ self.assertEqual(items[0].author_name, "玻啵莉Polly")
115
+
116
+ def test_parse_web_dynamic_items_skips_invalid_items(self):
117
+ payload = {
118
+ "data": {
119
+ "items": [
120
+ {"id_str": "bad", "type": "DYNAMIC_TYPE_DRAW", "modules": {}},
121
+ {
122
+ "id_str": "1190297023030493193",
123
+ "type": "DYNAMIC_TYPE_DRAW",
124
+ "modules": {"module_author": {}},
125
+ },
126
+ ]
127
+ }
128
+ }
129
+
130
+ self.assertEqual(web_dynamic.parse_web_dynamic_items(payload), [])
131
+
132
+
91
133
  class DBPermissionTests(unittest.IsolatedAsyncioTestCase):
134
+ async def test_db_init_enables_global_fallback(self):
135
+ with (
136
+ patch.object(db_module.Tortoise, "init", new=AsyncMock()) as init_db,
137
+ patch.object(
138
+ db_module.Tortoise,
139
+ "generate_schemas",
140
+ new=AsyncMock(),
141
+ ) as generate_schemas,
142
+ patch.object(DB, "migrate", new=AsyncMock()) as migrate,
143
+ patch.object(DB, "update_uid_list", new=AsyncMock()) as update_uid_list,
144
+ ):
145
+ await DB.init()
146
+
147
+ self.assertTrue(init_db.await_args.kwargs["_enable_global_fallback"])
148
+ self.assertTrue(DB._ready)
149
+ generate_schemas.assert_awaited_once()
150
+ migrate.assert_awaited_once()
151
+ update_uid_list.assert_awaited_once()
152
+
153
+ async def test_wait_until_ready_returns_false_before_init(self):
154
+ DB._ready = False
155
+
156
+ ready = await DB.wait_until_ready(timeout=0)
157
+
158
+ self.assertFalse(ready)
159
+
92
160
  async def test_set_permission_creates_group_when_missing(self):
93
161
  with (
94
162
  patch.object(DB, "get_group", new=AsyncMock(return_value=None)),
@@ -1,151 +0,0 @@
1
- import asyncio
2
- from datetime import datetime
3
-
4
- from apscheduler.events import (
5
- EVENT_JOB_ERROR,
6
- EVENT_JOB_EXECUTED,
7
- EVENT_JOB_MISSED,
8
- EVENT_SCHEDULER_STARTED,
9
- )
10
- from bilireq.exceptions import GrpcError
11
- from bilireq.grpc.dynamic import grpc_get_user_dynamics
12
- from bilireq.grpc.protos.bilibili.app.dynamic.v2.dynamic_pb2 import DynamicType
13
- from grpc import StatusCode
14
- from grpc.aio import AioRpcError
15
- from nonebot.adapters.onebot.v11.message import MessageSegment
16
- from nonebot.log import logger
17
-
18
- from ...config import plugin_config
19
- from ...database import DB as db
20
- from ...database import dynamic_offset as offset
21
- from ...utils import get_dynamic_screenshot, safe_send, scheduler
22
-
23
-
24
- async def dy_sched():
25
- """动态推送"""
26
- uid = await db.next_uid("dynamic")
27
- if not uid:
28
- # 没有订阅先暂停一秒再跳过,不然会导致 CPU 占用过高
29
- await asyncio.sleep(1)
30
- return
31
- user = await db.get_user(uid=uid)
32
- if user is None:
33
- logger.warning(f"动态推送跳过异常订阅 UID:{uid}")
34
- return
35
- name = user.name
36
-
37
- logger.debug(f"爬取动态 {name}({uid})")
38
- try:
39
- # 获取 UP 最新动态列表
40
- dynamics = (
41
- await grpc_get_user_dynamics(
42
- uid,
43
- timeout=plugin_config.bililive_dynamic_timeout,
44
- proxy=plugin_config.bililive_proxy,
45
- )
46
- ).list
47
- except AioRpcError as e:
48
- if e.code() == StatusCode.DEADLINE_EXCEEDED:
49
- logger.error(f"爬取动态超时,将在下个轮询中重试:{e.code()} {e.details()}")
50
- else:
51
- logger.error(f"爬取动态失败:{e.code()} {e.details()}")
52
- return
53
- except GrpcError as e:
54
- logger.error(f"爬取动态失败:{e.code} {e.msg}")
55
- return
56
-
57
- if not dynamics: # 没发过动态
58
- if uid in offset and offset[uid] == -1: # 不记录会导致第一次发动态不推送
59
- offset[uid] = 0
60
- return
61
- # 更新昵称
62
- name = dynamics[0].modules[0].module_author.author.name
63
-
64
- if uid not in offset: # 已删除
65
- return
66
- elif offset[uid] == -1: # 第一次爬取
67
- if len(dynamics) == 1: # 只有一条动态
68
- offset[uid] = int(dynamics[0].extend.dyn_id_str)
69
- else: # 第一个可能是置顶动态,但置顶也可能是最新一条,所以取前两条的最大值
70
- offset[uid] = max(
71
- int(dynamics[0].extend.dyn_id_str), int(dynamics[1].extend.dyn_id_str)
72
- )
73
- return
74
-
75
- dynamic = None
76
- for dynamic in sorted(
77
- dynamics,
78
- key=lambda x: int(x.extend.dyn_id_str), # 动态从旧到新排列
79
- ):
80
- dynamic_id = int(dynamic.extend.dyn_id_str)
81
- if dynamic_id > offset[uid]:
82
- logger.info(f"检测到新动态({dynamic_id}):{name}({uid})")
83
- image, err = await get_dynamic_screenshot(dynamic_id)
84
- url = f"https://t.bilibili.com/{dynamic_id}"
85
- if image is None:
86
- logger.debug(f"动态不存在,已跳过:{url}")
87
- return
88
- elif dynamic.card_type in [
89
- DynamicType.live_rcmd,
90
- DynamicType.live,
91
- DynamicType.ad,
92
- DynamicType.banner,
93
- ]:
94
- logger.debug(f"无需推送的动态 {dynamic.card_type},已跳过:{url}")
95
- offset[uid] = dynamic_id
96
- return
97
-
98
- type_msg = {
99
- 0: "发布了新动态",
100
- DynamicType.forward: "转发了一条动态",
101
- DynamicType.word: "发布了新文字动态",
102
- DynamicType.draw: "发布了新图文动态",
103
- DynamicType.av: "发布了新投稿",
104
- DynamicType.article: "发布了新专栏",
105
- DynamicType.music: "发布了新音频",
106
- }
107
- message = (
108
- f"{name} {type_msg.get(dynamic.card_type, type_msg[0])}:\n"
109
- + str(f"动态图片可能截图异常:{err}\n" if err else "")
110
- + MessageSegment.image(image)
111
- + f"\n{url}"
112
- )
113
-
114
- push_list = await db.get_push_list(uid, "dynamic")
115
- for sets in push_list:
116
- await safe_send(
117
- bot_id=sets.bot_id,
118
- send_type=sets.type,
119
- type_id=sets.type_id,
120
- message=message,
121
- at=bool(sets.at) and plugin_config.bililive_dynamic_at,
122
- )
123
-
124
- offset[uid] = dynamic_id
125
-
126
- if dynamic:
127
- await db.update_user(uid, name)
128
-
129
-
130
- def dynamic_lisener(event):
131
- if hasattr(event, "job_id") and event.job_id != "dynamic_sched":
132
- return
133
- job = scheduler.get_job("dynamic_sched")
134
- if not job:
135
- scheduler.add_job(
136
- dy_sched, id="dynamic_sched", next_run_time=datetime.now(scheduler.timezone)
137
- )
138
-
139
-
140
- if plugin_config.bililive_dynamic_interval == 0:
141
- scheduler.add_listener(
142
- dynamic_lisener,
143
- EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_SCHEDULER_STARTED,
144
- )
145
- else:
146
- scheduler.add_job(
147
- dy_sched,
148
- "interval",
149
- seconds=plugin_config.bililive_dynamic_interval,
150
- id="dynamic_sched",
151
- )