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.
- nonebot_plugin_ifollow/__init__.py +27 -0
- nonebot_plugin_ifollow/bilibili_parser.py +20 -0
- nonebot_plugin_ifollow/bilibili_platform.py +343 -0
- nonebot_plugin_ifollow/config.py +23 -0
- nonebot_plugin_ifollow/douyin_platform.py +348 -0
- nonebot_plugin_ifollow/models.py +403 -0
- nonebot_plugin_ifollow/parser.py +153 -0
- nonebot_plugin_ifollow/platforms.py +40 -0
- nonebot_plugin_ifollow/plugin.py +607 -0
- nonebot_plugin_ifollow-1.0.0.dist-info/METADATA +177 -0
- nonebot_plugin_ifollow-1.0.0.dist-info/RECORD +12 -0
- nonebot_plugin_ifollow-1.0.0.dist-info/WHEEL +4 -0
|
@@ -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)
|