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.
Files changed (33) hide show
  1. nonebot_plugin_parsehub/__init__.py +1298 -0
  2. nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
  3. nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
  4. nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
  5. nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
  6. nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
  7. nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
  8. nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
  9. nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
  10. nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
  11. nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
  12. nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
  13. nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
  14. nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
  15. nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
  16. nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
  17. nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
  18. nonebot_plugin_parsehub/assets/logos/x.png +0 -0
  19. nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
  20. nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
  21. nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
  22. nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
  23. nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
  24. nonebot_plugin_parsehub/card.py +836 -0
  25. nonebot_plugin_parsehub/config.py +95 -0
  26. nonebot_plugin_parsehub/core.py +485 -0
  27. nonebot_plugin_parsehub/mediacache.py +418 -0
  28. nonebot_plugin_parsehub/server.py +241 -0
  29. nonebot_plugin_parsehub/switches.py +156 -0
  30. nonebot_plugin_parsehub-0.1.0.dist-info/METADATA +275 -0
  31. nonebot_plugin_parsehub-0.1.0.dist-info/RECORD +33 -0
  32. nonebot_plugin_parsehub-0.1.0.dist-info/WHEEL +4 -0
  33. 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