nonebot-plugin-parsehub 0.1.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_parsehub/__init__.py +1298 -0
- nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
- nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
- nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
- nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
- nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
- nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
- nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
- nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
- nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
- nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
- nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
- nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
- nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
- nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
- nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
- nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
- nonebot_plugin_parsehub/assets/logos/x.png +0 -0
- nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
- nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
- nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
- nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
- nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
- nonebot_plugin_parsehub/card.py +836 -0
- nonebot_plugin_parsehub/config.py +95 -0
- nonebot_plugin_parsehub/core.py +485 -0
- nonebot_plugin_parsehub/mediacache.py +418 -0
- nonebot_plugin_parsehub/server.py +241 -0
- nonebot_plugin_parsehub/switches.py +156 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/METADATA +275 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/RECORD +33 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
# Copyright 2026 ByteColtX
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""把解析结果画成一张卡片图。
|
|
5
|
+
|
|
6
|
+
版式是 App Store「Today」那种:图片铺满整张卡片,卡片底部一段很长的渐变把画面
|
|
7
|
+
压暗,标题等信息压在渐变上。没有外框、没有页面底色,所以不存在"图片结束、背景
|
|
8
|
+
开始"那条边界。
|
|
9
|
+
|
|
10
|
+
渲染全部是同步阻塞的 CPU 活(Pillow),调用方必须放进 ``asyncio.to_thread``,
|
|
11
|
+
否则事件循环会被卡住几百毫秒。
|
|
12
|
+
|
|
13
|
+
字体需要一份中文字体:优先用配置指定的路径,其次找插件数据目录里已下载的,
|
|
14
|
+
再其次找系统字体,都没有就尝试下载;全都失败时 :func:`render` 返回 ``False``,
|
|
15
|
+
由调用方退回纯文字。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import logging
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("nonebot_plugin_parsehub.card")
|
|
29
|
+
|
|
30
|
+
ASSET_DIR = Path(__file__).resolve().parent / "assets" / "logos"
|
|
31
|
+
|
|
32
|
+
# duration 单位上游各平台不统一:抖音 / TikTok / 小红书给的是毫秒,其它是秒。
|
|
33
|
+
# 新平台若时长明显偏大(8 秒视频显示 2 小时),下载媒体用 cv2 量真实时长核对。
|
|
34
|
+
_MS_PLATFORMS = frozenset({"douyin", "tiktok", "xhs"})
|
|
35
|
+
|
|
36
|
+
# 中文字体候选下载地址(按顺序尝试)。
|
|
37
|
+
FONT_URLS: tuple[str, ...] = (
|
|
38
|
+
"https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/notosanssc/"
|
|
39
|
+
"NotoSansSC%5Bwght%5D.ttf",
|
|
40
|
+
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosanssc/"
|
|
41
|
+
"NotoSansSC%5Bwght%5D.ttf",
|
|
42
|
+
"https://github.com/google/fonts/raw/main/ofl/notosanssc/"
|
|
43
|
+
"NotoSansSC%5Bwght%5D.ttf",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 系统里可能已经有这些中文字体,有就直接用,不必下载。
|
|
47
|
+
SYSTEM_FONTS: tuple[str, ...] = (
|
|
48
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
49
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
50
|
+
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
|
51
|
+
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
|
52
|
+
"/usr/share/fonts/truetype/arphic/uming.ttc",
|
|
53
|
+
"/System/Library/Fonts/PingFang.ttc",
|
|
54
|
+
"C:/Windows/Fonts/msyh.ttc",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# 平台 → (展示名, 品牌色, logo 文件名)
|
|
58
|
+
# key 用 parsehub 的 platform.id(xhs/coolapk/… 都是它的原始 id)。
|
|
59
|
+
PLATFORMS: dict[str, tuple[str, tuple[int, int, int], str]] = {
|
|
60
|
+
"bilibili": ("bilibili", (251, 114, 153), "bilibili"),
|
|
61
|
+
"douyin": ("抖音", (254, 44, 85), "douyin"),
|
|
62
|
+
"xiaohongshu": ("小红书", (255, 36, 66), "xiaohongshu"),
|
|
63
|
+
"xhs": ("小红书", (255, 36, 66), "xiaohongshu"),
|
|
64
|
+
"weibo": ("微博", (230, 22, 45), "weibo"),
|
|
65
|
+
"zhihu": ("知乎", (0, 132, 255), "zhihu"),
|
|
66
|
+
"kuaishou": ("快手", (255, 73, 6), "kuaishou"),
|
|
67
|
+
"pipix": ("皮皮虾", (255, 90, 45), "pipix"),
|
|
68
|
+
"tieba": ("贴吧", (41, 50, 225), "baidu"),
|
|
69
|
+
"baidu": ("百度", (41, 50, 225), "baidu"),
|
|
70
|
+
"weixin": ("微信", (7, 193, 96), "wechat"),
|
|
71
|
+
"wechat": ("微信", (7, 193, 96), "wechat"),
|
|
72
|
+
"weixin_channels": ("微信视频号", (250, 157, 59), "wechat"),
|
|
73
|
+
"toutiao": ("今日头条", (255, 55, 60), "toutiao"),
|
|
74
|
+
"coolapk": ("酷安", (17, 178, 101), "coolapk"),
|
|
75
|
+
"xiaoheihe": ("小黑盒", (26, 26, 32), "xiaoheihe"),
|
|
76
|
+
"zuiyou": ("最右", (255, 196, 0), "zuiyou"),
|
|
77
|
+
"x": ("X", (255, 255, 255), "x"),
|
|
78
|
+
"twitter": ("X", (255, 255, 255), "x"),
|
|
79
|
+
"youtube": ("YouTube", (255, 0, 0), "youtube"),
|
|
80
|
+
"instagram": ("Instagram", (255, 0, 105), "instagram"),
|
|
81
|
+
"tiktok": ("TikTok", (255, 255, 255), "tiktok"),
|
|
82
|
+
"threads": ("Threads", (255, 255, 255), "threads"),
|
|
83
|
+
"facebook": ("Facebook", (8, 102, 255), "facebook"),
|
|
84
|
+
"snapchat": ("Snapchat", (255, 252, 0), "snapchat"),
|
|
85
|
+
"medium": ("Medium", (255, 255, 255), "medium"),
|
|
86
|
+
"douban": ("豆瓣", (45, 150, 61), "douban"),
|
|
87
|
+
"bangumi": ("Bangumi", (245, 145, 180), "bilibili"),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# 结果类型 → 中文标签
|
|
91
|
+
TYPE_LABELS: dict[str, str] = {
|
|
92
|
+
"video": "视频",
|
|
93
|
+
"image": "图集",
|
|
94
|
+
"multimedia": "图文",
|
|
95
|
+
"richtext": "图文",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# 版面参数
|
|
99
|
+
WIDTH = 1080
|
|
100
|
+
INSET = 48
|
|
101
|
+
TITLE_SIZE = 58
|
|
102
|
+
META_SIZE = 29
|
|
103
|
+
KICKER_SIZE = 27
|
|
104
|
+
LOGO_HEIGHT = 54
|
|
105
|
+
MIN_RATIO = 0.62
|
|
106
|
+
MAX_RATIO = 1.25
|
|
107
|
+
FADE_START = 0.34
|
|
108
|
+
FADE_PEAK = 200
|
|
109
|
+
FADE_BOTTOM = 252
|
|
110
|
+
FADE_COLOR = (6, 6, 10)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _pil() -> Any: # noqa: ANN401 - 返回 PIL.Image 模块,避免顶层 import 失败
|
|
114
|
+
"""延迟导入 Pillow,缺失时由调用方处理。
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
``PIL.Image`` 模块。
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ImportError: 没装 Pillow。
|
|
121
|
+
"""
|
|
122
|
+
from PIL import Image
|
|
123
|
+
|
|
124
|
+
return Image
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# 配置指定的字体(``set_font`` 注入),优先于系统字体。
|
|
128
|
+
_CUSTOM_FONT: Path | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@lru_cache(maxsize=1)
|
|
132
|
+
def _font_file() -> Path | None:
|
|
133
|
+
"""找一份可用的中文字体文件(不下载)。
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
字体文件路径;都没找到时为 ``None``。
|
|
137
|
+
"""
|
|
138
|
+
if _CUSTOM_FONT is not None and _CUSTOM_FONT.exists():
|
|
139
|
+
return _CUSTOM_FONT
|
|
140
|
+
for path in SYSTEM_FONTS:
|
|
141
|
+
if Path(path).exists():
|
|
142
|
+
logger.info(f"[parsehub] 使用系统字体 {path}")
|
|
143
|
+
return Path(path)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@lru_cache(maxsize=32)
|
|
148
|
+
def _font(size: int, weight: int = 400) -> Any: # noqa: ANN401 - PIL FreeTypeFont
|
|
149
|
+
"""按字号和字重取字体对象(带缓存)。
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
size: 字号(像素)。
|
|
153
|
+
weight: 字重,可变字体支持 100-900。
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Pillow 的字体对象。
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
RuntimeError: 没有任何可用字体。
|
|
160
|
+
"""
|
|
161
|
+
from PIL import ImageFont
|
|
162
|
+
|
|
163
|
+
path = _font_file()
|
|
164
|
+
if path is None:
|
|
165
|
+
raise RuntimeError("没有可用的中文字体")
|
|
166
|
+
font = ImageFont.truetype(str(path), size)
|
|
167
|
+
try:
|
|
168
|
+
font.set_variation_by_axes([weight])
|
|
169
|
+
except Exception: # noqa: BLE001 - 非可变字体不支持设置字重
|
|
170
|
+
pass
|
|
171
|
+
return font
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def set_font(path: Path) -> None:
|
|
175
|
+
"""指定字体文件并清掉字体缓存。
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
path: 字体文件路径。
|
|
179
|
+
"""
|
|
180
|
+
global _CUSTOM_FONT # noqa: PLW0603 - 单例式配置
|
|
181
|
+
_CUSTOM_FONT = path
|
|
182
|
+
_font_file.cache_clear()
|
|
183
|
+
_font.cache_clear()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def ensure_font(cache_dir: Path, url: str = "") -> Path | None:
|
|
187
|
+
"""确保有一份可用字体,必要时下载到缓存目录。
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
cache_dir: 插件缓存目录,下载的字体放在这里。
|
|
191
|
+
url: 自定义字体地址,留空则依次尝试 :data:`FONT_URLS`。
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
字体文件路径;不可用时为 ``None``。
|
|
195
|
+
"""
|
|
196
|
+
global _CUSTOM_FONT # noqa: PLW0603 - 单例式配置
|
|
197
|
+
|
|
198
|
+
if _CUSTOM_FONT is not None and _CUSTOM_FONT.exists():
|
|
199
|
+
return _CUSTOM_FONT
|
|
200
|
+
|
|
201
|
+
local = cache_dir / "NotoSansSC.ttf"
|
|
202
|
+
if local.exists() and local.stat().st_size > 1_000_000:
|
|
203
|
+
_CUSTOM_FONT = local
|
|
204
|
+
_font_file.cache_clear()
|
|
205
|
+
_font.cache_clear()
|
|
206
|
+
return local
|
|
207
|
+
|
|
208
|
+
system = _font_file()
|
|
209
|
+
if system is not None:
|
|
210
|
+
return system
|
|
211
|
+
|
|
212
|
+
for candidate in ([url] if url else []) + list(FONT_URLS):
|
|
213
|
+
if not candidate:
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
with httpx.stream(
|
|
218
|
+
"GET", candidate, timeout=120, follow_redirects=True
|
|
219
|
+
) as resp:
|
|
220
|
+
if resp.status_code != 200:
|
|
221
|
+
continue
|
|
222
|
+
tmp = local.with_suffix(".part")
|
|
223
|
+
with tmp.open("wb") as handle:
|
|
224
|
+
for chunk in resp.iter_bytes(1 << 16):
|
|
225
|
+
handle.write(chunk)
|
|
226
|
+
if tmp.stat().st_size < 1_000_000:
|
|
227
|
+
tmp.unlink(missing_ok=True)
|
|
228
|
+
continue
|
|
229
|
+
tmp.replace(local)
|
|
230
|
+
except Exception as exc: # noqa: BLE001 - 网络问题都算下载失败
|
|
231
|
+
logger.warning(f"[parsehub] 字体下载失败 {candidate}: {exc}")
|
|
232
|
+
continue
|
|
233
|
+
logger.info(f"[parsehub] 字体已下载 {local}")
|
|
234
|
+
_CUSTOM_FONT = local
|
|
235
|
+
_font_file.cache_clear()
|
|
236
|
+
_font.cache_clear()
|
|
237
|
+
return local
|
|
238
|
+
logger.warning("[parsehub] 没有可用字体,卡片功能关闭")
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _flat(text: str) -> str:
|
|
243
|
+
"""去掉所有空白,用来比较有没有漏字。
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
text: 原始文本。
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
去掉空白后的文本。
|
|
250
|
+
"""
|
|
251
|
+
return "".join(text.split())
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def wrap(
|
|
255
|
+
text: str,
|
|
256
|
+
font: Any, # noqa: ANN401 - PIL FreeTypeFont
|
|
257
|
+
max_w: int,
|
|
258
|
+
max_lines: int,
|
|
259
|
+
) -> list[str]:
|
|
260
|
+
"""按"词"折行,话题标签和网址不会被从中间劈开。
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
text: 原始文本。
|
|
264
|
+
font: 字体对象。
|
|
265
|
+
max_w: 每行最大像素宽度。
|
|
266
|
+
max_lines: 最多行数,超出时最后一行加省略号。
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
折行后的文本列表。
|
|
270
|
+
"""
|
|
271
|
+
import re
|
|
272
|
+
|
|
273
|
+
lines: list[str] = []
|
|
274
|
+
for para in text.split("\n"):
|
|
275
|
+
if not para:
|
|
276
|
+
continue
|
|
277
|
+
cur = ""
|
|
278
|
+
for token in re.findall(r"\S+\s*", para):
|
|
279
|
+
if font.getlength(cur + token) <= max_w:
|
|
280
|
+
cur += token
|
|
281
|
+
continue
|
|
282
|
+
if cur.strip():
|
|
283
|
+
lines.append(cur.rstrip())
|
|
284
|
+
cur = ""
|
|
285
|
+
if len(lines) == max_lines:
|
|
286
|
+
break
|
|
287
|
+
if font.getlength(token) > max_w: # 单个词就超宽,只能按字符切
|
|
288
|
+
piece = ""
|
|
289
|
+
for char in token:
|
|
290
|
+
if font.getlength(piece + char) <= max_w:
|
|
291
|
+
piece += char
|
|
292
|
+
else:
|
|
293
|
+
lines.append(piece)
|
|
294
|
+
piece = char
|
|
295
|
+
if len(lines) == max_lines:
|
|
296
|
+
break
|
|
297
|
+
cur = piece
|
|
298
|
+
else:
|
|
299
|
+
cur = token
|
|
300
|
+
if len(lines) == max_lines:
|
|
301
|
+
break
|
|
302
|
+
if len(lines) == max_lines:
|
|
303
|
+
break
|
|
304
|
+
if cur.strip():
|
|
305
|
+
lines.append(cur.rstrip())
|
|
306
|
+
lines = lines[:max_lines]
|
|
307
|
+
# 行尾空格会被 rstrip 掉,比较时先去空白,否则会误判成"被截断了"
|
|
308
|
+
if lines and len(_flat("".join(lines))) < len(_flat(text)):
|
|
309
|
+
lines[-1] = lines[-1][: max(1, len(lines[-1]) - 1)] + "…"
|
|
310
|
+
return lines
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def fit_title(
|
|
314
|
+
title: str,
|
|
315
|
+
font: Any, # noqa: ANN401 - PIL FreeTypeFont
|
|
316
|
+
max_w: int,
|
|
317
|
+
max_lines: int,
|
|
318
|
+
) -> list[str]:
|
|
319
|
+
"""标题折行;末尾的话题标签放不下就整条丢掉,不从中间截断。
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
title: 标题。
|
|
323
|
+
font: 字体对象。
|
|
324
|
+
max_w: 每行最大像素宽度。
|
|
325
|
+
max_lines: 最多行数。
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
折行后的标题。
|
|
329
|
+
"""
|
|
330
|
+
lines = wrap(title, font, max_w, max_lines)
|
|
331
|
+
if len(_flat("".join(lines).rstrip("…"))) >= len(_flat(title)):
|
|
332
|
+
return lines
|
|
333
|
+
tokens = title.split(" ")
|
|
334
|
+
while len(tokens) > 1 and tokens[-1].startswith("#"):
|
|
335
|
+
tokens.pop()
|
|
336
|
+
trial = " ".join(tokens)
|
|
337
|
+
trial_lines = wrap(trial, font, max_w, max_lines)
|
|
338
|
+
if len(_flat("".join(trial_lines).rstrip("…"))) >= len(_flat(trial)):
|
|
339
|
+
return trial_lines
|
|
340
|
+
return lines
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def format_duration(seconds: int, *, is_ms: bool = False) -> str:
|
|
344
|
+
"""把时长格式化成 ``mm:ss`` / ``h:mm:ss``。
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
seconds: 时长。
|
|
348
|
+
is_ms: 传进来的值是不是毫秒(抖音、TikTok 是毫秒)。
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
格式化后的时长;无法解析时返回空串。
|
|
352
|
+
"""
|
|
353
|
+
if seconds <= 0:
|
|
354
|
+
return ""
|
|
355
|
+
if is_ms or seconds > 10800:
|
|
356
|
+
seconds //= 1000
|
|
357
|
+
minutes, sec = divmod(int(seconds), 60)
|
|
358
|
+
hours, minutes = divmod(minutes, 60)
|
|
359
|
+
if hours:
|
|
360
|
+
return f"{hours}:{minutes:02d}:{sec:02d}"
|
|
361
|
+
return f"{minutes:02d}:{sec:02d}"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def platform_uses_milliseconds(result: Any) -> bool: # noqa: ANN401
|
|
365
|
+
"""该平台的 ``duration`` 是不是毫秒。
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
result: ParseHub 解析结果。
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
抖音 / TikTok / 小红书返回 True。
|
|
372
|
+
"""
|
|
373
|
+
platform_id = str(getattr(getattr(result, "platform", None), "id", ""))
|
|
374
|
+
return platform_id.lower() in _MS_PLATFORMS
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def video_seconds(result: Any) -> int: # noqa: ANN401
|
|
378
|
+
"""取结果里最长媒体的时长(秒)。
|
|
379
|
+
|
|
380
|
+
上游单位不统一:毫秒(抖音 / TikTok / 小红书)要除 1000;另外把明显过大的
|
|
381
|
+
值(> 3 小时)也当毫秒处理,与 :func:`format_duration` 的显示规则一致。
|
|
382
|
+
拿不到时长时返回 0,调用方据此跳过时长判断。
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
result: ParseHub 解析结果。
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
最长媒体的时长(秒);无法判断时返回 0。
|
|
389
|
+
"""
|
|
390
|
+
media = getattr(result, "media", None)
|
|
391
|
+
items = [
|
|
392
|
+
item
|
|
393
|
+
for item in (
|
|
394
|
+
list(media) if isinstance(media, (list, tuple)) else [media]
|
|
395
|
+
)
|
|
396
|
+
if item
|
|
397
|
+
]
|
|
398
|
+
longest = 0
|
|
399
|
+
for item in items:
|
|
400
|
+
try:
|
|
401
|
+
value = int(getattr(item, "duration", 0) or 0)
|
|
402
|
+
except (TypeError, ValueError):
|
|
403
|
+
continue
|
|
404
|
+
longest = max(longest, value)
|
|
405
|
+
if longest <= 0:
|
|
406
|
+
return 0
|
|
407
|
+
if platform_uses_milliseconds(result) or longest > 10800:
|
|
408
|
+
return longest // 1000
|
|
409
|
+
return longest
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def platform_of(result: Any) -> tuple[str, tuple[int, int, int], str]: # noqa: ANN401
|
|
413
|
+
"""取平台展示名、品牌色和 logo 名。
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
result: ParseHub 解析结果。
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
``(展示名, 品牌色, logo 文件名)``。
|
|
420
|
+
"""
|
|
421
|
+
platform = getattr(result, "platform", None)
|
|
422
|
+
key = str(getattr(platform, "id", "") or "").lower()
|
|
423
|
+
if key in PLATFORMS:
|
|
424
|
+
return PLATFORMS[key]
|
|
425
|
+
name = str(getattr(platform, "display_name", "") or key or "未知平台")
|
|
426
|
+
return name, (150, 150, 150), "x"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def meta_line(result: Any, media_count: int, is_ms: bool) -> str: # noqa: ANN401
|
|
430
|
+
"""拼装元信息行(类型 · 时长 · 媒体数)。
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
result: ParseHub 解析结果。
|
|
434
|
+
media_count: 媒体数量。
|
|
435
|
+
is_ms: 时长单位是不是毫秒。
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
形如 ``视频 · 00:33`` 的一行文本。
|
|
439
|
+
"""
|
|
440
|
+
post_type = getattr(getattr(result, "type", None), "value", "")
|
|
441
|
+
parts = [TYPE_LABELS.get(str(post_type), "媒体")]
|
|
442
|
+
media = getattr(result, "media", None)
|
|
443
|
+
items = list(media) if isinstance(media, (list, tuple)) else [media]
|
|
444
|
+
first = next((item for item in items if item), None)
|
|
445
|
+
duration = format_duration(
|
|
446
|
+
int(getattr(first, "duration", 0) or 0), is_ms=is_ms
|
|
447
|
+
)
|
|
448
|
+
if duration:
|
|
449
|
+
parts.append(duration)
|
|
450
|
+
if media_count > 1:
|
|
451
|
+
parts.append(f"{media_count} 个媒体")
|
|
452
|
+
return " · ".join(parts)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _is_placeholder(data: bytes, image: Any) -> bool: # noqa: ANN401 - PIL Image
|
|
456
|
+
"""判断封面是不是纯色占位图。
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
data: 原始图片字节。
|
|
460
|
+
image: 已解码的 RGB 图像。
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
像占位图时为 ``True``。
|
|
464
|
+
"""
|
|
465
|
+
if len(data) < 24_000:
|
|
466
|
+
return True
|
|
467
|
+
colors = image.resize((16, 16)).getcolors(16 * 16) or []
|
|
468
|
+
return bool(colors) and max(count for count, _ in colors) > 16 * 16 * 0.9
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _frame_from_video(path: Path) -> bytes | None:
|
|
472
|
+
"""从本地视频里抽第一帧当封面。
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
path: 本地视频路径。
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
JPEG 字节;抽不出来时为 ``None``。
|
|
479
|
+
"""
|
|
480
|
+
try:
|
|
481
|
+
import cv2
|
|
482
|
+
|
|
483
|
+
Image = _pil() # noqa: N806 - 与 PIL 模块名保持一致
|
|
484
|
+
except ImportError:
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
cap = cv2.VideoCapture(str(path))
|
|
488
|
+
try:
|
|
489
|
+
ok, frame = cap.read()
|
|
490
|
+
finally:
|
|
491
|
+
cap.release()
|
|
492
|
+
if not ok:
|
|
493
|
+
return None
|
|
494
|
+
buffer = io.BytesIO()
|
|
495
|
+
Image.fromarray(frame[:, :, ::-1]).save(buffer, "JPEG", quality=92)
|
|
496
|
+
return buffer.getvalue()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def fetch_cover(result: Any, local_files: list[Path]) -> bytes | None: # noqa: ANN401
|
|
500
|
+
"""取封面图,三级兜底。
|
|
501
|
+
|
|
502
|
+
顺序:``thumb_url`` → 本身就是图片的媒体 → 本地视频抽首帧。
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
result: ParseHub 解析结果。
|
|
506
|
+
local_files: 已下载到本地的媒体文件。
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
JPEG/PNG 字节;都拿不到时为 ``None``。
|
|
510
|
+
"""
|
|
511
|
+
Image = _pil() # noqa: N806 - 与 PIL 模块名保持一致
|
|
512
|
+
media = getattr(result, "media", None)
|
|
513
|
+
items = [
|
|
514
|
+
item
|
|
515
|
+
for item in (
|
|
516
|
+
list(media) if isinstance(media, (list, tuple)) else [media]
|
|
517
|
+
)
|
|
518
|
+
if item
|
|
519
|
+
]
|
|
520
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
521
|
+
try:
|
|
522
|
+
with httpx.Client(
|
|
523
|
+
timeout=30, follow_redirects=True, headers=headers
|
|
524
|
+
) as client:
|
|
525
|
+
for item in items:
|
|
526
|
+
url = getattr(item, "thumb_url", None)
|
|
527
|
+
if not url:
|
|
528
|
+
continue
|
|
529
|
+
resp = client.get(url)
|
|
530
|
+
if resp.status_code != 200 or len(resp.content) < 1024:
|
|
531
|
+
continue
|
|
532
|
+
try:
|
|
533
|
+
image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
|
534
|
+
except Exception: # noqa: BLE001 - 不是图片就换下一个来源
|
|
535
|
+
continue
|
|
536
|
+
if not _is_placeholder(resp.content, image):
|
|
537
|
+
return resp.content
|
|
538
|
+
for item in items:
|
|
539
|
+
if getattr(item, "ext", "") not in (
|
|
540
|
+
"jpg",
|
|
541
|
+
"jpeg",
|
|
542
|
+
"png",
|
|
543
|
+
"webp",
|
|
544
|
+
):
|
|
545
|
+
continue
|
|
546
|
+
resp = client.get(getattr(item, "url", ""))
|
|
547
|
+
if resp.status_code == 200 and len(resp.content) > 24_000:
|
|
548
|
+
return resp.content
|
|
549
|
+
except Exception as exc: # noqa: BLE001 - 网络问题都算取封面失败
|
|
550
|
+
logger.warning(f"[parsehub] 取封面失败: {type(exc).__name__}: {exc}")
|
|
551
|
+
|
|
552
|
+
for path in local_files:
|
|
553
|
+
if path.suffix.lower() in (".mp4", ".mov", ".mkv", ".webm"):
|
|
554
|
+
frame = _frame_from_video(path)
|
|
555
|
+
if frame:
|
|
556
|
+
return frame
|
|
557
|
+
elif path.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp"):
|
|
558
|
+
try:
|
|
559
|
+
return path.read_bytes()
|
|
560
|
+
except OSError:
|
|
561
|
+
continue
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _vertical_mask(
|
|
566
|
+
size: tuple[int, int],
|
|
567
|
+
start: int,
|
|
568
|
+
end: int,
|
|
569
|
+
*,
|
|
570
|
+
peak: int = 255,
|
|
571
|
+
floor: int = 0,
|
|
572
|
+
curve: float = 1.0,
|
|
573
|
+
) -> Any: # noqa: ANN401 - PIL Image
|
|
574
|
+
"""用 numpy 一次算出竖直渐变遮罩。
|
|
575
|
+
|
|
576
|
+
逐行画线在 armv7 上会长时间占着 GIL,放线程里也会把事件循环卡住。
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
size: 遮罩尺寸 ``(宽, 高)``。
|
|
580
|
+
start: 开始变深的位置。
|
|
581
|
+
end: 达到 ``peak`` 的位置。
|
|
582
|
+
peak: 最大不透明度。
|
|
583
|
+
floor: 起始不透明度。
|
|
584
|
+
curve: 曲线指数,越大起步越缓。
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
``L`` 模式的遮罩图。
|
|
588
|
+
"""
|
|
589
|
+
import numpy as np
|
|
590
|
+
from PIL import Image
|
|
591
|
+
|
|
592
|
+
width, height = size
|
|
593
|
+
ys = np.arange(height, dtype=np.float32)
|
|
594
|
+
span = max(1, end - start)
|
|
595
|
+
ramp = np.clip((ys - start) / span, 0.0, 1.0)
|
|
596
|
+
alpha = np.where(
|
|
597
|
+
ys < start, 0, floor + (peak - floor) * ramp**curve
|
|
598
|
+
).astype(np.uint8)
|
|
599
|
+
return Image.fromarray(alpha.reshape(-1, 1), mode="L").resize(
|
|
600
|
+
(width, height), Image.BILINEAR
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@lru_cache(maxsize=32)
|
|
605
|
+
def _logo(slug: str, height: int) -> Any: # noqa: ANN401 - PIL Image
|
|
606
|
+
"""读 logo 并缩放(带缓存)。
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
slug: logo 文件名(不含扩展名)。
|
|
610
|
+
height: 目标高度。
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
RGBA 图像;文件不存在时用通用占位。
|
|
614
|
+
"""
|
|
615
|
+
from PIL import Image
|
|
616
|
+
|
|
617
|
+
path = ASSET_DIR / f"{slug}.png"
|
|
618
|
+
if not path.exists():
|
|
619
|
+
path = ASSET_DIR / "x.png"
|
|
620
|
+
logo = Image.open(path).convert("RGBA")
|
|
621
|
+
if logo.getchannel("A").getextrema() == (255, 255):
|
|
622
|
+
# 抖音的 favicon 是黑底不透明 PNG,不抠掉黑底就是个白方块
|
|
623
|
+
pixels = logo.load()
|
|
624
|
+
for y in range(logo.height):
|
|
625
|
+
for x in range(logo.width):
|
|
626
|
+
red, green, blue, _ = pixels[x, y]
|
|
627
|
+
pixels[x, y] = (
|
|
628
|
+
red,
|
|
629
|
+
green,
|
|
630
|
+
blue,
|
|
631
|
+
0 if max(red, green, blue) < 40 else 255,
|
|
632
|
+
)
|
|
633
|
+
box = logo.getbbox()
|
|
634
|
+
if box:
|
|
635
|
+
logo = logo.crop(box)
|
|
636
|
+
width = max(1, int(logo.width * height / logo.height))
|
|
637
|
+
return logo.resize((width, height), Image.LANCZOS)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _is_dark(logo: Any) -> bool: # noqa: ANN401 - PIL Image
|
|
641
|
+
"""判断 logo 是否要转成白色剪影。
|
|
642
|
+
|
|
643
|
+
只有「偏暗且基本没有颜色」的标才转白:X、Threads、Medium 这类纯黑标在深色
|
|
644
|
+
封面上会消失,必须转白;而抖音的青红、微博的红、YouTube 的红这些带品牌色的
|
|
645
|
+
标,转白会把品牌色抹掉,所以只要颜色够多就保留原色。
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
logo: logo 图像。
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
需要转白时为 ``True``。
|
|
652
|
+
"""
|
|
653
|
+
pixels = logo.load()
|
|
654
|
+
total = count = colorful = 0
|
|
655
|
+
for y in range(0, logo.height, max(1, logo.height // 24)):
|
|
656
|
+
for x in range(0, logo.width, max(1, logo.width // 24)):
|
|
657
|
+
red, green, blue, alpha = pixels[x, y]
|
|
658
|
+
if alpha > 128:
|
|
659
|
+
total += 0.299 * red + 0.587 * green + 0.114 * blue
|
|
660
|
+
if max(red, green, blue) - min(red, green, blue) > 40:
|
|
661
|
+
colorful += 1
|
|
662
|
+
count += 1
|
|
663
|
+
if count == 0:
|
|
664
|
+
return False
|
|
665
|
+
return total / count < 110 and colorful / count < 0.2
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _white(logo: Any) -> Any: # noqa: ANN401 - PIL Image
|
|
669
|
+
"""把 logo 的 alpha 通道填成白色。
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
logo: logo 图像。
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
白色剪影。
|
|
676
|
+
"""
|
|
677
|
+
from PIL import Image
|
|
678
|
+
|
|
679
|
+
white = Image.new("RGBA", logo.size, (255, 255, 255, 0))
|
|
680
|
+
white.putalpha(logo.getchannel("A"))
|
|
681
|
+
return white
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@lru_cache(maxsize=32)
|
|
685
|
+
def _shadowed_logo(slug: str, height: int) -> Any: # noqa: ANN401 - PIL Image
|
|
686
|
+
"""Logo 转白(必要时)并加投影,结果缓存。
|
|
687
|
+
|
|
688
|
+
PIL 的 Image 不可哈希,所以按 ``slug + 高度`` 缓存。
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
slug: logo 文件名。
|
|
692
|
+
height: 目标高度。
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
带投影的 RGBA 图像。
|
|
696
|
+
"""
|
|
697
|
+
from PIL import Image, ImageFilter
|
|
698
|
+
|
|
699
|
+
logo = _logo(slug, height)
|
|
700
|
+
if _is_dark(logo):
|
|
701
|
+
logo = _white(logo)
|
|
702
|
+
blur, offset, alpha = 7, 2, 205
|
|
703
|
+
pad = blur * 2
|
|
704
|
+
canvas = Image.new(
|
|
705
|
+
"RGBA", (logo.width + pad * 2, logo.height + pad * 2), (0, 0, 0, 0)
|
|
706
|
+
)
|
|
707
|
+
shadow = Image.new("RGBA", logo.size, (0, 0, 0, 0))
|
|
708
|
+
shadow.putalpha(logo.getchannel("A").point(lambda v: int(v * alpha / 255)))
|
|
709
|
+
canvas.alpha_composite(shadow, (pad + offset, pad + offset))
|
|
710
|
+
canvas = canvas.filter(ImageFilter.GaussianBlur(blur))
|
|
711
|
+
canvas.alpha_composite(logo, (pad, pad))
|
|
712
|
+
return canvas
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def render(
|
|
716
|
+
result: Any, # noqa: ANN401 - ParseHub 解析结果
|
|
717
|
+
cover: bytes,
|
|
718
|
+
out_path: Path,
|
|
719
|
+
) -> bool:
|
|
720
|
+
"""把解析结果渲染成卡片图。
|
|
721
|
+
|
|
722
|
+
同步阻塞,调用方必须放进线程池。
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
result: ParseHub 解析结果。
|
|
726
|
+
cover: 封面图字节。
|
|
727
|
+
out_path: 输出文件路径(JPEG)。
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
渲染成功为 ``True``。
|
|
731
|
+
"""
|
|
732
|
+
from PIL import Image, ImageDraw
|
|
733
|
+
|
|
734
|
+
name, _color, slug = platform_of(result)
|
|
735
|
+
media = getattr(result, "media", None)
|
|
736
|
+
items = [
|
|
737
|
+
item
|
|
738
|
+
for item in (
|
|
739
|
+
list(media) if isinstance(media, (list, tuple)) else [media]
|
|
740
|
+
)
|
|
741
|
+
if item
|
|
742
|
+
]
|
|
743
|
+
is_ms = platform_uses_milliseconds(result)
|
|
744
|
+
|
|
745
|
+
cover_image = Image.open(io.BytesIO(cover)).convert("RGB")
|
|
746
|
+
|
|
747
|
+
ratio = min(
|
|
748
|
+
max(cover_image.height / cover_image.width, MIN_RATIO), MAX_RATIO
|
|
749
|
+
)
|
|
750
|
+
card_w = WIDTH
|
|
751
|
+
card_h = int(card_w * ratio)
|
|
752
|
+
|
|
753
|
+
scale = max(card_w / cover_image.width, card_h / cover_image.height)
|
|
754
|
+
big = cover_image.resize(
|
|
755
|
+
(int(cover_image.width * scale), int(cover_image.height * scale)),
|
|
756
|
+
Image.BICUBIC, # LANCZOS 在 armv7 上要 150ms+
|
|
757
|
+
)
|
|
758
|
+
left = (big.width - card_w) // 2
|
|
759
|
+
top = int((big.height - card_h) * 0.28)
|
|
760
|
+
pic = big.crop((left, top, left + card_w, top + card_h))
|
|
761
|
+
|
|
762
|
+
title_font = _font(TITLE_SIZE, 700)
|
|
763
|
+
meta_font = _font(META_SIZE, 500)
|
|
764
|
+
kicker_font = _font(KICKER_SIZE, 600)
|
|
765
|
+
title_lines = fit_title(
|
|
766
|
+
getattr(result, "title", "") or "(无标题)",
|
|
767
|
+
title_font,
|
|
768
|
+
card_w - INSET * 2,
|
|
769
|
+
3,
|
|
770
|
+
)
|
|
771
|
+
meta = meta_line(result, len(items), is_ms)
|
|
772
|
+
|
|
773
|
+
# 渐变要保证文字所在的位置已经够暗:文字上方到 200/255,再往下到 252/255
|
|
774
|
+
text_top = card_h - INSET - len(title_lines) * 70 - 8 - 34 - 50
|
|
775
|
+
start = max(0, int(card_h * FADE_START))
|
|
776
|
+
ramp_end = max(start + 1, text_top)
|
|
777
|
+
upper = _vertical_mask(
|
|
778
|
+
(card_w, ramp_end), start, ramp_end, peak=FADE_PEAK, curve=1.25
|
|
779
|
+
)
|
|
780
|
+
lower = _vertical_mask(
|
|
781
|
+
(card_w, card_h - ramp_end),
|
|
782
|
+
0,
|
|
783
|
+
card_h - ramp_end,
|
|
784
|
+
peak=FADE_BOTTOM,
|
|
785
|
+
floor=FADE_PEAK,
|
|
786
|
+
)
|
|
787
|
+
layer = Image.new("RGB", (card_w, ramp_end), FADE_COLOR)
|
|
788
|
+
pic.paste(layer, (0, 0), upper)
|
|
789
|
+
layer = Image.new("RGB", (card_w, card_h - ramp_end), FADE_COLOR)
|
|
790
|
+
pic.paste(layer, (0, ramp_end), lower)
|
|
791
|
+
|
|
792
|
+
canvas = pic.convert("RGBA")
|
|
793
|
+
logo = _shadowed_logo(slug, LOGO_HEIGHT)
|
|
794
|
+
pad = logo.width - _logo(slug, LOGO_HEIGHT).width
|
|
795
|
+
mark_w = logo.width - pad
|
|
796
|
+
mark_h = logo.height - pad
|
|
797
|
+
|
|
798
|
+
y = card_h - INSET - len(title_lines) * 70 - 8 - 34
|
|
799
|
+
kicker_y = y - 44
|
|
800
|
+
# 图标与「平台名」那一行垂直居中,左边缘贴 INSET
|
|
801
|
+
canvas.alpha_composite(
|
|
802
|
+
logo,
|
|
803
|
+
(
|
|
804
|
+
INSET - pad // 2,
|
|
805
|
+
kicker_y + KICKER_SIZE // 2 - mark_h // 2 - pad // 2,
|
|
806
|
+
),
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
draw = ImageDraw.Draw(canvas)
|
|
810
|
+
draw.text(
|
|
811
|
+
(INSET + mark_w + 18, kicker_y),
|
|
812
|
+
name.upper(),
|
|
813
|
+
font=kicker_font,
|
|
814
|
+
fill=(226, 226, 232),
|
|
815
|
+
)
|
|
816
|
+
for line in title_lines:
|
|
817
|
+
draw.text((INSET, y), line, font=title_font, fill=(255, 255, 255))
|
|
818
|
+
y += 70
|
|
819
|
+
draw.text((INSET, y + 8), meta, font=meta_font, fill=(214, 214, 222))
|
|
820
|
+
|
|
821
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
822
|
+
canvas.convert("RGB").save(out_path, "JPEG", quality=92)
|
|
823
|
+
return True
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def available() -> bool:
|
|
827
|
+
"""卡片功能是否可用(有字体、有 Pillow)。
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
可用时为 ``True``。
|
|
831
|
+
"""
|
|
832
|
+
try:
|
|
833
|
+
_font(TITLE_SIZE, 700)
|
|
834
|
+
except Exception: # noqa: BLE001 - 缺 Pillow 或缺字体都算不可用
|
|
835
|
+
return False
|
|
836
|
+
return True
|