nonebot-plugin-bililive 2.0.1__tar.gz → 2.0.3__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.1 → nonebot_plugin_bililive-2.0.3}/PKG-INFO +14 -53
  2. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/README.md +13 -52
  3. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/database/db.py +1 -1
  4. nonebot_plugin_bililive-2.0.3/bililive/libs/dynamic/web.py +79 -0
  5. nonebot_plugin_bililive-2.0.3/bililive/plugins/pusher/dynamic_pusher.py +278 -0
  6. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/pusher/live_pusher.py +6 -1
  7. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/utils/__init__.py +1 -1
  8. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/utils/browser.py +9 -0
  9. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/version.py +1 -1
  10. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/pyproject.toml +5 -1
  11. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/tests/test_maintenance.py +61 -1
  12. nonebot_plugin_bililive-2.0.1/bililive/plugins/pusher/dynamic_pusher.py +0 -151
  13. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/LICENSE +0 -0
  14. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/__init__.py +0 -0
  15. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/__main__.py +0 -0
  16. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/cli/__init__.py +0 -0
  17. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/cli/bot.py +0 -0
  18. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/cli/utils.py +0 -0
  19. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/compat.py +0 -0
  20. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/config.py +0 -0
  21. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/database/__init__.py +0 -0
  22. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/database/models.py +0 -0
  23. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/libs/__init__.py +0 -0
  24. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/libs/dynamic/__init__.py +0 -0
  25. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/libs/dynamic/card.py +0 -0
  26. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/libs/dynamic/desc.py +0 -0
  27. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/libs/dynamic/display.py +0 -0
  28. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/libs/dynamic/user_profile.py +0 -0
  29. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/__init__.py +0 -0
  30. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/at/__init__.py +0 -0
  31. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/at/at_off.py +0 -0
  32. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/at/at_on.py +0 -0
  33. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/auto_agree.py +0 -0
  34. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/auto_delete.py +0 -0
  35. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/dynamic/__init__.py +0 -0
  36. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/dynamic/dynamic_off.py +0 -0
  37. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/dynamic/dynamic_on.py +0 -0
  38. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/help.py +0 -0
  39. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/live/__init__.py +0 -0
  40. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/live/live_now.py +0 -0
  41. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/live/live_off.py +0 -0
  42. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/live/live_on.py +0 -0
  43. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/permission/__init__.py +0 -0
  44. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/permission/permission_off.py +0 -0
  45. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/permission/permission_on.py +0 -0
  46. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/pusher/__init__.py +0 -0
  47. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/sub/__init__.py +0 -0
  48. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/sub/add_sub.py +0 -0
  49. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/sub/delete_sub.py +0 -0
  50. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/plugins/sub/sub_list.py +0 -0
  51. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/utils/captcha_solver.py +0 -0
  52. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/utils/fonts_provider.py +0 -0
  53. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/bililive/utils/mobile.js +0 -0
  54. {nonebot_plugin_bililive-2.0.1 → nonebot_plugin_bililive-2.0.3}/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.1
3
+ Version: 2.0.3
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>
@@ -33,7 +33,13 @@ Requires-Dist: msvc-runtime>=14.34.31931; sys_platform == "win32"
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  <div align="center">
36
+ <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
37
+ <br>
38
+ <p><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
39
+ </div>
36
40
 
41
+ <div align="center">
42
+
37
43
  # nonebot-plugin-bililive
38
44
 
39
45
  _✨ 将 B 站 UP 主动态与直播推送到 QQ 的 NoneBot2 插件 ✨_
@@ -45,42 +51,12 @@ _✨ 将 B 站 UP 主动态与直播推送到 QQ 的 NoneBot2 插件 ✨_
45
51
  <img src="https://img.shields.io/pypi/v/nonebot-plugin-bililive.svg" alt="pypi">
46
52
  </a>
47
53
  <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
48
- <a href="https://jq.qq.com/?_wv=1027&k=sHPbCRAd">
49
- <img src="https://img.shields.io/badge/QQ%E7%BE%A4-629574472-orange" alt="qq group">
50
- </a>
51
54
 
52
55
  </div>
53
56
 
54
- > 当前仓库已按 NoneBot 插件模板整理,可直接作为插件包发布到 PyPI 并在 NoneBot2 项目中安装使用。
55
-
56
- <details>
57
- <summary>配置发布工作流</summary>
58
-
59
- 1. 前往 https://pypi.org/manage/account/#api-tokens 创建新的 PyPI API Token。
60
- 2. 打开当前 GitHub 仓库的 Settings - Secrets and variables - Actions。
61
- 3. 新建名为 PYPI_API_TOKEN 的 Repository Secret,并填入刚刚创建的 Token。
62
-
63
- </details>
64
-
65
- > [!IMPORTANT]
66
- > 当前项目使用符合 PEP 621 的 pyproject.toml,并已补充基于 tag 触发的 PyPI 发布工作流。
67
-
68
- <details>
69
- <summary>触发发布</summary>
70
-
71
- 创建 tag:
72
-
73
- git tag v1.6.0post5
74
-
75
- 推送 tag:
76
-
77
- git push origin --tags
78
-
79
- </details>
80
-
81
57
  ## 📖 介绍
82
58
 
83
- BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。当前项目已将发布名、插件入口、核心实现包和工作流统一整理为 nonebot-plugin-bililive,便于作为独立插件分发。
59
+ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。
84
60
 
85
61
  ### 特性
86
62
 
@@ -103,34 +79,17 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
103
79
 
104
80
  <details>
105
81
  <summary>使用包管理器安装</summary>
82
+ 在 nonebot2 项目的插件目录下, 打开命令行, 进入虚拟环境, 输入相应的安装命令
106
83
 
107
- <details>
108
- <summary>pip</summary>
109
-
110
- pip install nonebot-plugin-bililive
111
-
112
- </details>
113
-
114
- <details>
115
- <summary>pdm</summary>
116
-
117
- pdm add nonebot-plugin-bililive
118
-
119
- </details>
84
+ pip install nonebot-plugin-bililive
120
85
 
121
- <details>
122
- <summary>poetry</summary>
123
86
 
124
- poetry add nonebot-plugin-bililive
87
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot.plugins]` 部分追加写入
125
88
 
126
- </details>
89
+ nonebot-plugin-bililive = ["nonebot-plugin-bililive"]
127
90
 
128
91
  </details>
129
92
 
130
- 安装后,在 NoneBot2 项目的 pyproject.toml 中加入:
131
-
132
- plugins = ["nonebot_plugin_bililive"]
133
-
134
93
  ## ⚙️ 配置
135
94
 
136
95
  在 NoneBot2 项目的 .env 文件中按需添加配置项:
@@ -153,6 +112,8 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
153
112
  | BILILIVE_DYNAMIC_BIG_IMAGE | 否 | false | 是否优先展示大图 |
154
113
  | BILILIVE_COMMAND_PREFIX | 否 | 空字符串 | 命令额外前缀 |
155
114
 
115
+ 动态抓取默认优先使用 gRPC 接口;当部分 UID 命中 B 站风控时,插件会自动回退到 Playwright 持久化浏览器中的 cookies 请求网页动态接口。通常不需要额外配置 Cookie 登录;如果某些 UID 仍持续抓取失败,建议在插件使用的浏览器数据目录中登录一个常用的 B 站账号,以提高动态抓取成功率。
116
+
156
117
  ## 🎉 使用
157
118
 
158
119
  ### 指令表
@@ -1,5 +1,11 @@
1
1
  <div align="center">
2
+ <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
3
+ <br>
4
+ <p><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
5
+ </div>
2
6
 
7
+ <div align="center">
8
+
3
9
  # nonebot-plugin-bililive
4
10
 
5
11
  _✨ 将 B 站 UP 主动态与直播推送到 QQ 的 NoneBot2 插件 ✨_
@@ -11,42 +17,12 @@ _✨ 将 B 站 UP 主动态与直播推送到 QQ 的 NoneBot2 插件 ✨_
11
17
  <img src="https://img.shields.io/pypi/v/nonebot-plugin-bililive.svg" alt="pypi">
12
18
  </a>
13
19
  <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
14
- <a href="https://jq.qq.com/?_wv=1027&k=sHPbCRAd">
15
- <img src="https://img.shields.io/badge/QQ%E7%BE%A4-629574472-orange" alt="qq group">
16
- </a>
17
20
 
18
21
  </div>
19
22
 
20
- > 当前仓库已按 NoneBot 插件模板整理,可直接作为插件包发布到 PyPI 并在 NoneBot2 项目中安装使用。
21
-
22
- <details>
23
- <summary>配置发布工作流</summary>
24
-
25
- 1. 前往 https://pypi.org/manage/account/#api-tokens 创建新的 PyPI API Token。
26
- 2. 打开当前 GitHub 仓库的 Settings - Secrets and variables - Actions。
27
- 3. 新建名为 PYPI_API_TOKEN 的 Repository Secret,并填入刚刚创建的 Token。
28
-
29
- </details>
30
-
31
- > [!IMPORTANT]
32
- > 当前项目使用符合 PEP 621 的 pyproject.toml,并已补充基于 tag 触发的 PyPI 发布工作流。
33
-
34
- <details>
35
- <summary>触发发布</summary>
36
-
37
- 创建 tag:
38
-
39
- git tag v1.6.0post5
40
-
41
- 推送 tag:
42
-
43
- git push origin --tags
44
-
45
- </details>
46
-
47
23
  ## 📖 介绍
48
24
 
49
- BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。当前项目已将发布名、插件入口、核心实现包和工作流统一整理为 nonebot-plugin-bililive,便于作为独立插件分发。
25
+ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直播与动态消息推送到 QQ 群或私聊场景。
50
26
 
51
27
  ### 特性
52
28
 
@@ -69,34 +45,17 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
69
45
 
70
46
  <details>
71
47
  <summary>使用包管理器安装</summary>
48
+ 在 nonebot2 项目的插件目录下, 打开命令行, 进入虚拟环境, 输入相应的安装命令
72
49
 
73
- <details>
74
- <summary>pip</summary>
75
-
76
- pip install nonebot-plugin-bililive
77
-
78
- </details>
79
-
80
- <details>
81
- <summary>pdm</summary>
82
-
83
- pdm add nonebot-plugin-bililive
84
-
85
- </details>
50
+ pip install nonebot-plugin-bililive
86
51
 
87
- <details>
88
- <summary>poetry</summary>
89
52
 
90
- poetry add nonebot-plugin-bililive
53
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot.plugins]` 部分追加写入
91
54
 
92
- </details>
55
+ nonebot-plugin-bililive = ["nonebot-plugin-bililive"]
93
56
 
94
57
  </details>
95
58
 
96
- 安装后,在 NoneBot2 项目的 pyproject.toml 中加入:
97
-
98
- plugins = ["nonebot_plugin_bililive"]
99
-
100
59
  ## ⚙️ 配置
101
60
 
102
61
  在 NoneBot2 项目的 .env 文件中按需添加配置项:
@@ -119,6 +78,8 @@ BiliLive 是一个基于 NoneBot2 的 B 站推送插件,支持将 UP 主的直
119
78
  | BILILIVE_DYNAMIC_BIG_IMAGE | 否 | false | 是否优先展示大图 |
120
79
  | BILILIVE_COMMAND_PREFIX | 否 | 空字符串 | 命令额外前缀 |
121
80
 
81
+ 动态抓取默认优先使用 gRPC 接口;当部分 UID 命中 B 站风控时,插件会自动回退到 Playwright 持久化浏览器中的 cookies 请求网页动态接口。通常不需要额外配置 Cookie 登录;如果某些 UID 仍持续抓取失败,建议在插件使用的浏览器数据目录中登录一个常用的 B 站账号,以提高动态抓取成功率。
82
+
122
83
  ## 🎉 使用
123
84
 
124
85
  ### 指令表
@@ -37,7 +37,7 @@ class DB:
37
37
  },
38
38
  }
39
39
 
40
- await Tortoise.init(config)
40
+ await Tortoise.init(config, _enable_global_fallback=True)
41
41
 
42
42
  await Tortoise.generate_schemas()
43
43
  await cls.migrate()
@@ -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,278 @@
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
+ uid = await db.next_uid("dynamic")
149
+ if not uid:
150
+ # 没有订阅先暂停一秒再跳过,不然会导致 CPU 占用过高
151
+ await throttle_dynamic_loop()
152
+ return
153
+ user = await db.get_user(uid=uid)
154
+ if user is None:
155
+ logger.warning(f"动态推送跳过异常订阅 UID:{uid}")
156
+ await throttle_dynamic_loop()
157
+ return
158
+ name = user.name
159
+
160
+ retry_at = dynamic_risk_control_until.get(uid)
161
+ if retry_at is not None:
162
+ if retry_at > monotonic():
163
+ logger.debug(f"动态接口风控冷却中,跳过 {name}({uid})")
164
+ await throttle_dynamic_loop()
165
+ return
166
+ del dynamic_risk_control_until[uid]
167
+
168
+ logger.debug(f"爬取动态 {name}({uid})")
169
+ use_web_fallback = False
170
+ try:
171
+ dynamics, use_web_fallback = await get_user_dynamics_with_web_fallback(uid)
172
+ except asyncio.CancelledError:
173
+ logger.debug(f"动态轮询任务已取消:{name}({uid})")
174
+ return
175
+ except AioRpcError as e:
176
+ if e.code() == StatusCode.DEADLINE_EXCEEDED:
177
+ logger.error(f"爬取动态超时,将在下个轮询中重试:{e.code()} {e.details()}")
178
+ else:
179
+ logger.error(f"爬取动态失败:{e.code()} {e.details()}")
180
+ await throttle_dynamic_loop()
181
+ return
182
+ except GrpcError as e:
183
+ logger.error(f"爬取动态失败:{e.code} {e.msg}")
184
+ await throttle_dynamic_loop()
185
+ return
186
+ except WebDynamicError as e:
187
+ dynamic_risk_control_until[uid] = monotonic() + RISK_CONTROL_RETRY_SECONDS
188
+ retry_minutes = RISK_CONTROL_RETRY_SECONDS // 60
189
+ logger.warning(
190
+ f"动态 Web 接口获取失败,{name}({uid})将在 "
191
+ f"{retry_minutes} 分钟后重试:{e.code} {e.msg}"
192
+ )
193
+ await throttle_dynamic_loop()
194
+ return
195
+
196
+ dynamic_risk_control_until.pop(uid, None)
197
+
198
+ if not dynamics: # 没发过动态
199
+ if uid in offset and offset[uid] == -1: # 不记录会导致第一次发动态不推送
200
+ offset[uid] = 0
201
+ return
202
+ name = get_dynamic_author_name(dynamics[0], use_web_fallback)
203
+
204
+ if uid not in offset: # 已删除
205
+ return
206
+ elif offset[uid] == -1: # 第一次爬取
207
+ if len(dynamics) == 1: # 只有一条动态
208
+ offset[uid] = get_dynamic_id(dynamics[0], use_web_fallback)
209
+ else: # 第一个可能是置顶动态,但置顶也可能是最新一条,所以取前两条的最大值
210
+ offset[uid] = max(
211
+ get_dynamic_id(dynamics[0], use_web_fallback),
212
+ get_dynamic_id(dynamics[1], use_web_fallback),
213
+ )
214
+ return
215
+
216
+ dynamic = None
217
+ for dynamic in sorted(
218
+ dynamics,
219
+ key=lambda x: get_dynamic_id(x, use_web_fallback), # 动态从旧到新排列
220
+ ):
221
+ dynamic_id = get_dynamic_id(dynamic, use_web_fallback)
222
+ dynamic_type = get_dynamic_type(dynamic, use_web_fallback)
223
+ if dynamic_id > offset[uid]:
224
+ logger.info(f"检测到新动态({dynamic_id}):{name}({uid})")
225
+ image, err = await get_dynamic_screenshot(dynamic_id)
226
+ url = f"https://t.bilibili.com/{dynamic_id}"
227
+ if image is None:
228
+ logger.debug(f"动态不存在,已跳过:{url}")
229
+ return
230
+ elif should_skip_dynamic(dynamic_type, use_web_fallback):
231
+ logger.debug(f"无需推送的动态 {dynamic_type},已跳过:{url}")
232
+ offset[uid] = dynamic_id
233
+ return
234
+ message = (
235
+ f"{name} {get_dynamic_type_message(dynamic_type, use_web_fallback)}:\n"
236
+ + str(f"动态图片可能截图异常:{err}\n" if err else "")
237
+ + MessageSegment.image(image)
238
+ + f"\n{url}"
239
+ )
240
+
241
+ push_list = await db.get_push_list(uid, "dynamic")
242
+ for sets in push_list:
243
+ await safe_send(
244
+ bot_id=sets.bot_id,
245
+ send_type=sets.type,
246
+ type_id=sets.type_id,
247
+ message=message,
248
+ at=bool(sets.at) and plugin_config.bililive_dynamic_at,
249
+ )
250
+
251
+ offset[uid] = dynamic_id
252
+
253
+ if dynamic:
254
+ await db.update_user(uid, name)
255
+
256
+
257
+ def dynamic_lisener(event):
258
+ if hasattr(event, "job_id") and event.job_id != "dynamic_sched":
259
+ return
260
+ job = scheduler.get_job("dynamic_sched")
261
+ if not job:
262
+ scheduler.add_job(
263
+ dy_sched, id="dynamic_sched", next_run_time=datetime.now(scheduler.timezone)
264
+ )
265
+
266
+
267
+ if plugin_config.bililive_dynamic_interval == 0:
268
+ scheduler.add_listener(
269
+ dynamic_lisener,
270
+ EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_SCHEDULER_STARTED,
271
+ )
272
+ else:
273
+ scheduler.add_job(
274
+ dy_sched,
275
+ "interval",
276
+ seconds=plugin_config.bililive_dynamic_interval,
277
+ id="dynamic_sched",
278
+ )
@@ -13,7 +13,12 @@ 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
@@ -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.1"
3
+ __version__ = "2.0.3"
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.1"
41
+ version = "2.0.3"
42
42
 
43
43
  [project.license]
44
44
  text = "AGPL-3.0-or-later"
@@ -49,6 +49,10 @@ Repository = "https://github.com/Akiyy-dev/nonebot-plugin-bililive"
49
49
  Documentation = "https://github.com/Akiyy-dev/nonebot-plugin-bililive#readme"
50
50
  Issues = "https://github.com/Akiyy-dev/nonebot-plugin-bililive/issues"
51
51
 
52
+ [project.entry-points."nonebot.plugin"]
53
+ nonebot-plugin-bililive = "nonebot_plugin_bililive"
54
+ nonebot_plugin_bililive = "nonebot_plugin_bililive"
55
+
52
56
  [project.scripts]
53
57
  bililive = "bililive.__main__:main"
54
58
 
@@ -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,65 @@ 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
+ generate_schemas.assert_awaited_once()
149
+ migrate.assert_awaited_once()
150
+ update_uid_list.assert_awaited_once()
151
+
92
152
  async def test_set_permission_creates_group_when_missing(self):
93
153
  with (
94
154
  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
- )