nonebot-plugin-ifollow 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,27 @@
1
+ """爱播作者更新订阅插件。"""
2
+
3
+ from nonebot import require
4
+ from nonebot.plugin import PluginMetadata
5
+
6
+ require("nonebot_plugin_parser")
7
+ require("nonebot_plugin_apscheduler")
8
+
9
+ from .config import Config
10
+
11
+ __plugin_meta__ = PluginMetadata(
12
+ name="爱播作者订阅",
13
+ description="订阅抖音和 B 站作者的新作品及动态并发送",
14
+ usage=(
15
+ "爱播帮助\n爱播订阅 <作者主页或作品链接>\n爱播订阅列表\n"
16
+ "爱播取消订阅 <序号或作者主页或作品链接>"
17
+ ),
18
+ type="application",
19
+ homepage="https://github.com/ByteColtX/nonebot-plugin-ifollow",
20
+ config=Config,
21
+ supported_adapters={"~onebot.v11"},
22
+ extra={"author": "ByteColtX <umk@live.com>"},
23
+ )
24
+
25
+ from . import plugin as plugin
26
+
27
+ __all__ = ["plugin"]
@@ -0,0 +1,20 @@
1
+ """爱播使用的 B 站 parser 适配。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bilibili_api import Credential
6
+ from nonebot_plugin_parser.parsers.bilibili import BilibiliParser
7
+
8
+ from .bilibili_platform import _parse_cookie
9
+ from .config import config
10
+
11
+
12
+ class IFollowBilibiliParser(BilibiliParser):
13
+ """为 B 站 parser 注入爱播的可选 Cookie。"""
14
+
15
+ def __init__(self) -> None:
16
+ super().__init__()
17
+ if config.ifollow_bilibili_cookie:
18
+ self._credential = Credential.from_cookies(
19
+ _parse_cookie(config.ifollow_bilibili_cookie)
20
+ )
@@ -0,0 +1,343 @@
1
+ """B 站作者投稿和动态接口适配。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from bilibili_api import Credential
10
+ from bilibili_api.user import User
11
+ from bilibili_api.video import Video
12
+
13
+ from .config import config
14
+ from .platforms import AuthorInfo, PlatformWork
15
+
16
+ PROFILE_RE = re.compile(
17
+ r"(?:https?://)?(?:(?:www\.)?space\.bilibili\.com/|m\.bilibili\.com/space/)"
18
+ r"(?P<mid>\d+)"
19
+ )
20
+ BV_RE = re.compile(r"(?P<bvid>BV[0-9A-Za-z]{10})")
21
+ AV_RE = re.compile(r"(?:^|[/\s])av(?P<avid>\d+)(?:$|[/\s?&#])", re.IGNORECASE)
22
+ SHORT_LINK_RE = re.compile(r"(?:https?://)?b23\.tv/[A-Za-z0-9_-]+")
23
+ BILIBILI_BROWSER_HEADERS = {
24
+ "User-Agent": (
25
+ "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 "
26
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
27
+ ),
28
+ "Referer": "https://www.bilibili.com/",
29
+ }
30
+ MAX_CATCHUP_PAGES = 5
31
+
32
+
33
+ class BilibiliRequestError(RuntimeError):
34
+ """B 站接口不可用或返回了无法识别的数据。"""
35
+
36
+ def __init__(self, message: str, *, retryable: bool = False) -> None:
37
+ super().__init__(message)
38
+ self.retryable = retryable
39
+
40
+
41
+ def extract_bilibili_mid(value: str) -> str | None:
42
+ """从 B 站作者主页中提取 UID。"""
43
+
44
+ value = value.strip().strip("<>[]()")
45
+ matched = PROFILE_RE.search(value)
46
+ return matched.group("mid") if matched else None
47
+
48
+
49
+ def extract_bilibili_video(value: str) -> tuple[str, str] | None:
50
+ """从 BV/AV 链接中提取视频标识。"""
51
+
52
+ if matched := BV_RE.search(value):
53
+ return "bvid", matched.group("bvid")
54
+ if matched := AV_RE.search(value):
55
+ return "avid", matched.group("avid")
56
+ return None
57
+
58
+
59
+ def is_bilibili_value(value: str) -> bool:
60
+ """判断输入是否明显属于 B 站。"""
61
+
62
+ return bool(
63
+ extract_bilibili_mid(value)
64
+ or extract_bilibili_video(value)
65
+ or SHORT_LINK_RE.search(value)
66
+ or "bilibili.com" in value
67
+ or "b23.tv" in value
68
+ )
69
+
70
+
71
+ async def resolve_bilibili_author(value: str) -> AuthorInfo:
72
+ """解析 B 站作者主页或视频链接,返回作者资料。"""
73
+
74
+ mid = extract_bilibili_mid(value)
75
+ if mid:
76
+ return await fetch_author_profile(mid)
77
+
78
+ video = extract_bilibili_video(value)
79
+ if video is None and SHORT_LINK_RE.search(value):
80
+ value = await _resolve_short_link(value)
81
+ mid = extract_bilibili_mid(value)
82
+ if mid:
83
+ return await fetch_author_profile(mid)
84
+ video = extract_bilibili_video(value)
85
+ if video is None:
86
+ raise ValueError("请输入 B 站作者主页、BV/AV 视频链接或 b23.tv 短链")
87
+
88
+ try:
89
+ kind, identifier = video
90
+ bili_video = Video(
91
+ bvid=identifier if kind == "bvid" else None,
92
+ aid=int(identifier) if kind == "avid" else None,
93
+ credential=_credential(),
94
+ )
95
+ info = await bili_video.get_info()
96
+ except Exception as exc:
97
+ raise _wrap_error("B 站作品详情请求失败", exc) from exc
98
+ owner = info.get("owner")
99
+ if not isinstance(owner, dict) or not owner.get("mid"):
100
+ raise BilibiliRequestError("B 站作品详情缺少作者信息")
101
+ return AuthorInfo(
102
+ author_id=str(owner["mid"]),
103
+ name=str(owner.get("name") or owner["mid"]),
104
+ handle=str(owner["mid"]),
105
+ avatar=str(owner.get("face") or ""),
106
+ )
107
+
108
+
109
+ async def _resolve_short_link(value: str) -> str:
110
+ """解析 b23.tv 短链。"""
111
+
112
+ matched = SHORT_LINK_RE.search(value)
113
+ if matched is None:
114
+ return value
115
+ short_url = matched.group(0)
116
+ if not short_url.startswith(("http://", "https://")):
117
+ short_url = f"https://{short_url}"
118
+ try:
119
+ async with httpx.AsyncClient(
120
+ headers=BILIBILI_BROWSER_HEADERS,
121
+ follow_redirects=True,
122
+ timeout=20,
123
+ verify=False,
124
+ ) as client:
125
+ response = await client.get(short_url)
126
+ response.raise_for_status()
127
+ return str(response.url)
128
+ except Exception as exc:
129
+ raise _wrap_error("B 站短链解析失败", exc) from exc
130
+
131
+
132
+ async def fetch_author_profile(mid: str) -> AuthorInfo:
133
+ """获取 B 站作者资料。"""
134
+
135
+ try:
136
+ info = await User(int(mid), credential=_credential()).get_user_info()
137
+ except Exception as exc:
138
+ raise _wrap_error("B 站作者资料请求失败", exc) from exc
139
+ if not isinstance(info, dict) or not info.get("mid"):
140
+ raise BilibiliRequestError("B 站作者资料返回格式异常")
141
+ return AuthorInfo(
142
+ author_id=str(info["mid"]),
143
+ name=str(info.get("name") or info["mid"]),
144
+ handle=str(info.get("mid") or ""),
145
+ avatar=str(info.get("face") or ""),
146
+ description=str(info.get("sign") or ""),
147
+ )
148
+
149
+
150
+ async def fetch_video_works(mid: str, cursor: str | None = None) -> list[PlatformWork]:
151
+ """读取 B 站作者视频投稿,返回从新到旧的列表。"""
152
+
153
+ user = User(int(mid), credential=_credential())
154
+ works: list[PlatformWork] = []
155
+ oid: int | None = None
156
+ author: AuthorInfo | None = None
157
+ for _ in range(MAX_CATCHUP_PAGES if cursor else 1):
158
+ try:
159
+ result = await user.get_media_list(
160
+ oid=oid,
161
+ ps=config.ifollow_subscribe_count,
162
+ direction=False,
163
+ desc=True,
164
+ with_current=False,
165
+ )
166
+ except Exception as exc:
167
+ raise _wrap_error("B 站投稿列表请求失败", exc) from exc
168
+ if not isinstance(result, dict):
169
+ raise BilibiliRequestError("B 站投稿列表返回格式异常")
170
+ items = result.get("media_list") or []
171
+ if not isinstance(items, list):
172
+ raise BilibiliRequestError("B 站投稿列表缺少 media_list")
173
+ for item in items:
174
+ work = _parse_video_item(item, mid, author)
175
+ if work is None:
176
+ continue
177
+ author = work.author
178
+ works.append(work)
179
+ if not items or not result.get("has_more"):
180
+ break
181
+ if cursor and any(str(item.get("bv_id") or "") == cursor for item in items):
182
+ break
183
+ last_aid = items[-1].get("id")
184
+ if not last_aid:
185
+ break
186
+ oid = int(last_aid)
187
+ works.sort(key=lambda work: (work.create_time, work.work_id), reverse=True)
188
+ return works
189
+
190
+
191
+ def _parse_video_item(
192
+ item: Any, mid: str, author: AuthorInfo | None
193
+ ) -> PlatformWork | None:
194
+ if not isinstance(item, dict):
195
+ return None
196
+ bvid = str(item.get("bv_id") or "")
197
+ if not bvid:
198
+ return None
199
+ item_author = item.get("upper")
200
+ if isinstance(item_author, dict):
201
+ author = AuthorInfo(
202
+ author_id=str(item_author.get("mid") or mid),
203
+ name=str(item_author.get("name") or mid),
204
+ handle=str(item_author.get("mid") or mid),
205
+ avatar=str(item_author.get("face") or ""),
206
+ )
207
+ if author is None:
208
+ author = AuthorInfo(author_id=mid, name=mid, handle=mid)
209
+ return PlatformWork(
210
+ platform="bilibili",
211
+ work_id=bvid,
212
+ bvid=bvid,
213
+ url=f"https://www.bilibili.com/video/{bvid}",
214
+ title=str(item.get("title") or ""),
215
+ description=str(item.get("intro") or ""),
216
+ create_time=int(item.get("pubtime") or 0),
217
+ author=author,
218
+ )
219
+
220
+
221
+ async def fetch_dynamic_works(
222
+ mid: str, cursor: str | None = None
223
+ ) -> list[PlatformWork]:
224
+ """读取 B 站作者动态,返回从新到旧的列表。"""
225
+
226
+ user = User(int(mid), credential=_credential())
227
+ works: list[PlatformWork] = []
228
+ offset = ""
229
+ for _ in range(MAX_CATCHUP_PAGES if cursor else 1):
230
+ try:
231
+ result = await user.get_dynamics_new(offset=offset)
232
+ except Exception as exc:
233
+ raise _wrap_error("B 站动态列表请求失败", exc) from exc
234
+ if not isinstance(result, dict):
235
+ raise BilibiliRequestError("B 站动态列表返回格式异常")
236
+ items = result.get("items") or []
237
+ if not isinstance(items, list):
238
+ raise BilibiliRequestError("B 站动态列表缺少 items")
239
+ for item in items:
240
+ work = _parse_dynamic_item(item, mid)
241
+ if work is not None:
242
+ works.append(work)
243
+ if not items or not result.get("has_more"):
244
+ break
245
+ if cursor and any(str(item.get("id_str") or "") == cursor for item in items):
246
+ break
247
+ next_offset = str(result.get("offset") or "")
248
+ if not next_offset or next_offset == offset:
249
+ break
250
+ offset = next_offset
251
+ works.sort(key=lambda work: (work.create_time, work.work_id), reverse=True)
252
+ return works
253
+
254
+
255
+ def _parse_dynamic_item(item: Any, mid: str) -> PlatformWork | None:
256
+ if not isinstance(item, dict):
257
+ return None
258
+ dynamic_id = str(item.get("id_str") or "")
259
+ if not dynamic_id:
260
+ return None
261
+ modules = item.get("modules") or {}
262
+ if not isinstance(modules, dict):
263
+ modules = {}
264
+ author_data = modules.get("module_author") or {}
265
+ if not isinstance(author_data, dict):
266
+ author_data = {}
267
+ author = AuthorInfo(
268
+ author_id=str(author_data.get("mid") or mid),
269
+ name=str(author_data.get("name") or mid),
270
+ handle=str(author_data.get("mid") or mid),
271
+ avatar=str(author_data.get("face") or ""),
272
+ )
273
+ published_at = int(author_data.get("pub_ts") or 0)
274
+ dynamic = modules.get("module_dynamic") or {}
275
+ if not isinstance(dynamic, dict):
276
+ dynamic = {}
277
+ major = dynamic.get("major") or {}
278
+ if not isinstance(major, dict):
279
+ major = {}
280
+ archive = major.get("archive")
281
+ if isinstance(archive, dict) and archive.get("bvid"):
282
+ bvid = str(archive["bvid"])
283
+ return PlatformWork(
284
+ platform="bilibili",
285
+ work_id=dynamic_id,
286
+ bvid=bvid,
287
+ url=f"https://www.bilibili.com/video/{bvid}",
288
+ title=str(archive.get("title") or ""),
289
+ description=str(archive.get("desc") or ""),
290
+ create_time=published_at,
291
+ author=author,
292
+ kind="video",
293
+ )
294
+ opus = major.get("opus")
295
+ url = f"https://www.bilibili.com/dynamic/{dynamic_id}"
296
+ title = ""
297
+ description = ""
298
+ if isinstance(opus, dict):
299
+ jump_url = opus.get("jump_url")
300
+ if isinstance(jump_url, str) and jump_url:
301
+ url = f"https:{jump_url}" if jump_url.startswith("//") else jump_url
302
+ title = str(opus.get("title") or "")
303
+ summary = opus.get("summary")
304
+ if isinstance(summary, dict):
305
+ description = str(summary.get("text") or "")
306
+ dynamic_desc = dynamic.get("desc")
307
+ if not description and isinstance(dynamic_desc, dict):
308
+ description = str(dynamic_desc.get("text") or "")
309
+ return PlatformWork(
310
+ platform="bilibili",
311
+ work_id=dynamic_id,
312
+ url=url,
313
+ title=title,
314
+ description=description,
315
+ create_time=published_at,
316
+ author=author,
317
+ kind="dynamic",
318
+ )
319
+
320
+
321
+ def _credential() -> Credential | None:
322
+ raw_cookie = config.ifollow_bilibili_cookie
323
+ if not raw_cookie:
324
+ return None
325
+ return Credential.from_cookies(_parse_cookie(raw_cookie))
326
+
327
+
328
+ def _parse_cookie(raw_cookie: str) -> dict[str, str]:
329
+ cookies: dict[str, str] = {}
330
+ for item in raw_cookie.split(";"):
331
+ name, separator, value = item.strip().partition("=")
332
+ if separator and name:
333
+ cookies[name] = value
334
+ return cookies
335
+
336
+
337
+ def _wrap_error(prefix: str, exc: Exception) -> BilibiliRequestError:
338
+ message = f"{prefix}:{exc}"
339
+ text = str(exc)
340
+ retryable = any(
341
+ code in text for code in ("HTTP 412", "403", "429", "500", "502", "503", "504")
342
+ )
343
+ return BilibiliRequestError(message, retryable=retryable)
@@ -0,0 +1,23 @@
1
+ """爱播插件配置。"""
2
+
3
+ from nonebot import get_plugin_config
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class Config(BaseModel):
8
+ """爱播订阅配置。"""
9
+
10
+ ifollow_subscribe_interval: int = Field(default=300, ge=30)
11
+ """所有平台共享的轮询间隔,单位为秒。"""
12
+
13
+ ifollow_subscribe_count: int = Field(default=20, ge=1, le=50)
14
+ """每个平台每次拉取的最新内容数量。"""
15
+
16
+ ifollow_douyin_cookie: str | None = None
17
+ """可选的抖音网页 Cookie。"""
18
+
19
+ ifollow_bilibili_cookie: str | None = None
20
+ """可选的 B 站 Cookie。"""
21
+
22
+
23
+ config = get_plugin_config(Config)