nonebot-plugin-parser 2.6.0__tar.gz → 2.6.1__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 (76) hide show
  1. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/PKG-INFO +7 -7
  2. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/README.md +3 -3
  3. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/pyproject.toml +6 -6
  4. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/download/__init__.py +114 -58
  5. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/helper.py +10 -5
  6. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/matchers/__init__.py +3 -3
  7. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/__init__.py +2 -2
  8. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/base.py +10 -10
  9. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/__init__.py +1 -1
  10. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/data.py +2 -0
  11. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/kuaishou/__init__.py +5 -2
  12. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/kuaishou/states.py +4 -0
  13. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/tiktok.py +3 -3
  14. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/youtube/__init__.py +3 -3
  15. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/__init__.py +2 -9
  16. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/utils.py +7 -0
  17. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/__init__.py +0 -0
  18. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/config.py +0 -0
  19. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/constants.py +0 -0
  20. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/download/task.py +0 -0
  21. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/download/ytdlp.py +0 -0
  22. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/exception.py +0 -0
  23. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/matchers/filter.py +0 -0
  24. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/matchers/rule.py +0 -0
  25. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/acfun/__init__.py +0 -0
  26. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/acfun/video.py +0 -0
  27. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/article.py +0 -0
  28. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/common.py +0 -0
  29. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/dynamic.py +0 -0
  30. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/favlist.py +0 -0
  31. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/live.py +0 -0
  32. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/opus.py +0 -0
  33. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/bilibili/video.py +0 -0
  34. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/cookie.py +0 -0
  35. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/douyin/__init__.py +0 -0
  36. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/douyin/slides.py +0 -0
  37. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/douyin/video.py +0 -0
  38. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/nga.py +0 -0
  39. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/task.py +0 -0
  40. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/twitter.py +0 -0
  41. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/utils.py +0 -0
  42. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/weibo/__init__.py +0 -0
  43. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/weibo/article.py +0 -0
  44. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/weibo/common.py +0 -0
  45. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/weibo/show.py +0 -0
  46. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/xiaohongshu/__init__.py +0 -0
  47. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/xiaohongshu/common.py +0 -0
  48. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/xiaohongshu/discovery.py +0 -0
  49. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/xiaohongshu/explore.py +0 -0
  50. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/parsers/youtube/meta.py +0 -0
  51. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/base.py +0 -0
  52. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/common.py +0 -0
  53. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/default.py +0 -0
  54. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/htmlrender.py +0 -0
  55. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/HYSongYunLangHeiW.ttf +0 -0
  56. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/__init__.py +0 -0
  57. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/avatar.png +0 -0
  58. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/bilibili.png +0 -0
  59. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/douyin.png +0 -0
  60. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/1.jpg +0 -0
  61. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/2.jpg +0 -0
  62. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/3.jpg +0 -0
  63. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/4.jpg +0 -0
  64. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/5.jpg +0 -0
  65. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/6.jpg +0 -0
  66. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/7.jpg +0 -0
  67. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/8.jpg +0 -0
  68. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/fallback_pic/9.jpg +0 -0
  69. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/kuaishou.png +0 -0
  70. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/play.png +0 -0
  71. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/tiktok.png +0 -0
  72. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/twitter.png +0 -0
  73. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/weibo.png +0 -0
  74. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/xiaohongshu.png +0 -0
  75. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/resources/youtube.png +0 -0
  76. {nonebot_plugin_parser-2.6.0 → nonebot_plugin_parser-2.6.1}/src/nonebot_plugin_parser/renders/templates/card.html.jinja2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nonebot-plugin-parser
3
- Version: 2.6.0
3
+ Version: 2.6.1
4
4
  Summary: NoneBot2 链接分享解析 Alconna 版, 现支持B站|抖音|快手|微博|小红书|YouTube|TikTok|Twitter|AcFun|NGA
5
5
  Keywords: acfun,bilibili,douyin,kuaishou,nga,nonebot,nonebot2,tiktok,twitter,video,weibo,xiaohongshu,youtube
6
6
  Author: fllesser
@@ -19,20 +19,20 @@ Classifier: Programming Language :: Python :: 3.14
19
19
  Classifier: Topic :: Communications :: Chat
20
20
  Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Multimedia :: Video
22
- Requires-Dist: nonebot2>=2.4.3,<3.0.0
23
22
  Requires-Dist: rich>=13.0.0
24
23
  Requires-Dist: pillow>=11.0.0
25
24
  Requires-Dist: aiofiles>=25.1.0
26
25
  Requires-Dist: httpx>=0.27.2,<1.0.0
27
26
  Requires-Dist: msgspec>=0.20.0,<1.0.0
27
+ Requires-Dist: nonebot2>=2.4.3,<3.0.0
28
28
  Requires-Dist: apilmoji[rich]>=0.3.1,<1.0.0
29
29
  Requires-Dist: beautifulsoup4>=4.12.0,<5.0.0
30
30
  Requires-Dist: curl-cffi>=0.13.0,!=0.14.0,<1.0.0
31
31
  Requires-Dist: bilibili-api-python>=17.4.1,<18.0.0
32
+ Requires-Dist: nonebot-plugin-uninfo>=0.10.1,<1.0.0
32
33
  Requires-Dist: nonebot-plugin-alconna>=0.60.4,<1.0.0
33
- Requires-Dist: nonebot-plugin-apscheduler>=0.5.0,<1.0.0
34
34
  Requires-Dist: nonebot-plugin-localstore>=0.7.4,<1.0.0
35
- Requires-Dist: nonebot-plugin-uninfo>=0.10.1,<1.0.0
35
+ Requires-Dist: nonebot-plugin-apscheduler>=0.5.0,<1.0.0
36
36
  Requires-Dist: nonebot-plugin-htmlkit>=0.1.0rc4 ; extra == 'all'
37
37
  Requires-Dist: nonebot-plugin-htmlrender>=0.6.7 ; extra == 'all'
38
38
  Requires-Dist: yt-dlp[default]>=2026.3.13 ; extra == 'all'
@@ -416,7 +416,7 @@ class ExampleParser(BaseParser):
416
416
  <details>
417
417
  <summary>辅助函数</summary>
418
418
 
419
- > 构建作者信息
419
+ > 构建作者
420
420
 
421
421
  ```python
422
422
  author = self.create_author(
@@ -473,5 +473,5 @@ real_url = await self.get_redirect_url(
473
473
 
474
474
  ## 🎉 致谢
475
475
 
476
- - [nonebot-plugin-resolver](https://github.com/zhiyu1998/nonebot-plugin-resolver) | 初代解析插件
477
- - [parse-video-py](https://github.com/wujunwei928/parse-video-py) | 借鉴了抖音解析
476
+ - [nonebot-plugin-resolver](https://github.com/zhiyu1998/nonebot-plugin-resolver) - 本项目最初基于此插件进行开发,在此表示感谢。尽管当前版本代码已完全重构,但仍感谢原项目提供的初始思路和参考。
477
+ - [parse-video-py](https://github.com/wujunwei928/parse-video-py) - 在抖音解析功能实现方面提供了技术参考和借鉴。
@@ -362,7 +362,7 @@ class ExampleParser(BaseParser):
362
362
  <details>
363
363
  <summary>辅助函数</summary>
364
364
 
365
- > 构建作者信息
365
+ > 构建作者
366
366
 
367
367
  ```python
368
368
  author = self.create_author(
@@ -419,5 +419,5 @@ real_url = await self.get_redirect_url(
419
419
 
420
420
  ## 🎉 致谢
421
421
 
422
- - [nonebot-plugin-resolver](https://github.com/zhiyu1998/nonebot-plugin-resolver) | 初代解析插件
423
- - [parse-video-py](https://github.com/wujunwei928/parse-video-py) | 借鉴了抖音解析
422
+ - [nonebot-plugin-resolver](https://github.com/zhiyu1998/nonebot-plugin-resolver) - 本项目最初基于此插件进行开发,在此表示感谢。尽管当前版本代码已完全重构,但仍感谢原项目提供的初始思路和参考。
423
+ - [parse-video-py](https://github.com/wujunwei928/parse-video-py) - 在抖音解析功能实现方面提供了技术参考和借鉴。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-parser"
3
- version = "2.6.0"
3
+ version = "2.6.1"
4
4
  description = "NoneBot2 链接分享解析 Alconna 版, 现支持B站|抖音|快手|微博|小红书|YouTube|TikTok|Twitter|AcFun|NGA"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -39,20 +39,20 @@ classifiers = [
39
39
  ]
40
40
 
41
41
  dependencies = [
42
- "nonebot2>=2.4.3,<3.0.0",
43
42
  "rich>=13.0.0",
44
43
  "pillow>=11.0.0",
45
44
  "aiofiles>=25.1.0",
46
45
  "httpx>=0.27.2,<1.0.0",
47
46
  "msgspec>=0.20.0,<1.0.0",
47
+ "nonebot2>=2.4.3,<3.0.0",
48
48
  "apilmoji[rich]>=0.3.1,<1.0.0",
49
49
  "beautifulsoup4>=4.12.0,<5.0.0",
50
50
  "curl_cffi>=0.13.0,<1.0.0,!=0.14.0",
51
51
  "bilibili-api-python>=17.4.1,<18.0.0",
52
+ "nonebot-plugin-uninfo>=0.10.1,<1.0.0",
52
53
  "nonebot-plugin-alconna>=0.60.4,<1.0.0",
53
- "nonebot-plugin-apscheduler>=0.5.0,<1.0.0",
54
54
  "nonebot-plugin-localstore>=0.7.4,<1.0.0",
55
- "nonebot-plugin-uninfo>=0.10.1,<1.0.0",
55
+ "nonebot-plugin-apscheduler>=0.5.0,<1.0.0",
56
56
  ]
57
57
 
58
58
  [project.urls]
@@ -118,7 +118,7 @@ nonebug = { git = "https://github.com/nonebot/nonebug", rev = "master" }
118
118
  [tool.bumpversion]
119
119
  tag = true
120
120
  commit = true
121
- current_version = "2.6.0"
121
+ current_version = "2.6.1"
122
122
  message = "release: bump vesion from {current_version} to {new_version}"
123
123
 
124
124
  [[tool.bumpversion.files]]
@@ -150,7 +150,7 @@ test-render = "pytest tests/renders --cov=src --cov-report=xml --junitxml=junit.
150
150
  bump = "bump-my-version bump"
151
151
  show-bump = "bump-my-version show-bump"
152
152
 
153
- [tool.pyright]
153
+ [tool.basedpyright]
154
154
  pythonVersion = "3.10"
155
155
  pythonPlatform = "All"
156
156
  defineConstant = { PYDANTIC_V2 = true }
@@ -4,9 +4,10 @@ from functools import partial
4
4
  from contextlib import contextmanager
5
5
  from urllib.parse import urljoin
6
6
 
7
+ import httpx
7
8
  import aiofiles
8
- from httpx import HTTPError, AsyncClient
9
- from nonebot import logger
9
+ import curl_cffi
10
+ from nonebot import logger, get_driver
10
11
  from rich.progress import (
11
12
  Progress,
12
13
  BarColumn,
@@ -15,22 +16,27 @@ from rich.progress import (
15
16
  )
16
17
 
17
18
  from .task import auto_task
18
- from ..utils import merge_av, safe_unlink, generate_file_name
19
+ from ..utils import merge_av, safe_unlink, generate_file_name, is_module_available
19
20
  from ..config import pconfig
20
21
  from ..constants import COMMON_HEADER, DOWNLOAD_TIMEOUT
21
22
  from ..exception import IgnoreException, DownloadException
22
23
 
23
24
 
24
25
  class StreamDownloader:
25
- """Downloader class for downloading files with stream"""
26
-
27
26
  def __init__(self):
28
27
  self.headers: dict[str, str] = COMMON_HEADER.copy()
29
28
  self.cache_dir: Path = pconfig.cache_dir
30
- self.client: AsyncClient = AsyncClient(timeout=DOWNLOAD_TIMEOUT, verify=False)
29
+ self.client: httpx.AsyncClient = httpx.AsyncClient(timeout=DOWNLOAD_TIMEOUT, verify=False)
30
+
31
+ async def aclose(self):
32
+ await self.client.aclose()
31
33
 
34
+ @staticmethod
32
35
  @contextmanager
33
- def rich_progress(self, desc: str, total: int | None = None):
36
+ def rich_progress(
37
+ desc: str,
38
+ total: int | None = None,
39
+ ):
34
40
  with Progress(
35
41
  TextColumn("[bold blue]{task.description}", justify="right"),
36
42
  BarColumn(bar_width=None),
@@ -41,53 +47,83 @@ class StreamDownloader:
41
47
  task_id = progress.add_task(description=desc, total=total)
42
48
  yield partial(progress.update, task_id)
43
49
 
44
- async def _download_file(
50
+ @staticmethod
51
+ def _validate_content_length(
52
+ response: httpx.Response | curl_cffi.Response,
53
+ ) -> int:
54
+ """获取文件长度"""
55
+ content_length = response.headers.get("Content-Length")
56
+ content_length = int(content_length) if content_length else 0
57
+
58
+ if content_length == 0:
59
+ logger.warning(f"媒体 url: {response.url}, 大小为 0, 取消下载")
60
+ raise IgnoreException
61
+
62
+ if (file_size := content_length / 1024 / 1024) > pconfig.max_size:
63
+ logger.warning(f"媒体 url: {response.url} 大小 {file_size:.2f} MB, 超过 {pconfig.max_size} MB, 取消下载")
64
+ raise IgnoreException
65
+
66
+ return content_length
67
+
68
+ async def _download_file_with_httpx(
45
69
  self,
46
70
  url: str,
47
71
  *,
48
- file_name: str | None = None,
49
- ext_headers: dict[str, str] | None = None,
72
+ file_path: Path,
73
+ headers: dict[str, str],
50
74
  chunk_size: int = 64 * 1024,
51
75
  ) -> Path:
52
76
  """download file by url with stream"""
53
- if not file_name:
54
- file_name = generate_file_name(url)
55
- file_path = self.cache_dir / file_name
56
- # 如果文件存在,则直接返回
57
- if file_path.exists():
58
- return file_path
59
77
 
60
- headers = {**self.headers, **(ext_headers or {})}
78
+ async with self.client.stream(
79
+ "GET",
80
+ url,
81
+ headers=headers,
82
+ follow_redirects=True,
83
+ ) as response:
84
+ response.raise_for_status()
85
+ content_length = self._validate_content_length(response)
86
+
87
+ with self.rich_progress(
88
+ f"httpx | {file_path.name}",
89
+ content_length,
90
+ ) as update_progress:
91
+ async with aiofiles.open(file_path, "wb") as file:
92
+ async for chunk in response.aiter_bytes(chunk_size):
93
+ await file.write(chunk)
94
+ update_progress(advance=len(chunk))
61
95
 
62
- try:
63
- async with self.client.stream("GET", url, headers=headers, follow_redirects=True) as response:
64
- response.raise_for_status()
65
- content_length = response.headers.get("Content-Length")
66
- content_length = int(content_length) if content_length else 0
67
-
68
- if content_length == 0:
69
- logger.warning(f"媒体 url: {url}, 大小为 0, 取消下载")
70
- raise IgnoreException
71
-
72
- if (file_size := content_length / 1024 / 1024) > pconfig.max_size:
73
- logger.warning(f"媒体 url: {url} 大小 {file_size:.2f} MB, 超过 {pconfig.max_size} MB, 取消下载")
74
- raise IgnoreException
75
-
76
- with self.rich_progress(file_name, content_length) as update_progress:
77
- async with aiofiles.open(file_path, "wb") as file:
78
- async for chunk in response.aiter_bytes(chunk_size):
79
- await file.write(chunk)
80
- update_progress(advance=len(chunk))
81
-
82
- except HTTPError:
83
- await safe_unlink(file_path)
84
- logger.exception(f"下载失败 | url: {url}, file_path: {file_path}")
85
- raise DownloadException("媒体下载失败")
96
+ return file_path
97
+
98
+ async def _download_file_with_curl_cffi(
99
+ self,
100
+ url: str,
101
+ *,
102
+ file_path: Path,
103
+ headers: dict[str, str],
104
+ ) -> Path:
105
+ async with curl_cffi.AsyncSession(allow_redirects=True) as session:
106
+ response: curl_cffi.Response = await session.get(
107
+ url,
108
+ headers=headers,
109
+ timeout=DOWNLOAD_TIMEOUT,
110
+ stream=True,
111
+ )
112
+ response.raise_for_status()
113
+ content_length = self._validate_content_length(response)
114
+
115
+ with self.rich_progress(
116
+ f"curl_cffi | {file_path.name}",
117
+ content_length,
118
+ ) as update_progress:
119
+ async with aiofiles.open(file_path, "wb") as file:
120
+ async for chunk in response.aiter_content(chunk_size=8192):
121
+ await file.write(chunk)
122
+ update_progress(advance=len(chunk))
86
123
 
87
124
  return file_path
88
125
 
89
- @auto_task
90
- async def download_file(
126
+ async def _download_file(
91
127
  self,
92
128
  url: str,
93
129
  *,
@@ -95,13 +131,28 @@ class StreamDownloader:
95
131
  ext_headers: dict[str, str] | None = None,
96
132
  chunk_size: int = 64 * 1024,
97
133
  ) -> Path:
98
- """download file by url with stream"""
99
- return await self._download_file(
100
- url,
101
- file_name=file_name,
102
- ext_headers=ext_headers,
103
- chunk_size=chunk_size,
104
- )
134
+ """download file by url with fallback"""
135
+ if not file_name:
136
+ file_name = generate_file_name(url)
137
+ file_path = self.cache_dir / file_name
138
+ if file_path.exists():
139
+ return file_path
140
+
141
+ headers = {**self.headers, **(ext_headers or {})}
142
+
143
+ try:
144
+ path = await self._download_file_with_httpx(
145
+ url, file_path=file_path, headers=headers, chunk_size=chunk_size
146
+ )
147
+ except httpx.HTTPError:
148
+ logger.opt(exception=True).warning(f"下载失败(httpx) | url: {url}")
149
+ try:
150
+ path = await self._download_file_with_curl_cffi(url, file_path=file_path, headers=headers)
151
+ except curl_cffi.CurlError:
152
+ logger.opt(exception=True).warning(f"下载失败(curl_cffi) | url: {url}")
153
+ raise DownloadException("媒体下载失败")
154
+
155
+ return path
105
156
 
106
157
  @auto_task
107
158
  async def download_video(
@@ -199,7 +250,7 @@ class StreamDownloader:
199
250
  await f.write(chunk)
200
251
  total_size += len(chunk)
201
252
  update_progress(advance=len(chunk), total=total_size)
202
- except HTTPError:
253
+ except httpx.HTTPError:
203
254
  await safe_unlink(video_path)
204
255
  logger.exception("m3u8 视频下载失败")
205
256
  raise DownloadException("m3u8 视频下载失败")
@@ -224,13 +275,18 @@ class StreamDownloader:
224
275
  return slices
225
276
 
226
277
 
227
- DOWNLOADER: StreamDownloader = StreamDownloader()
228
-
229
- try:
230
- import yt_dlp as yt_dlp
278
+ downloader: StreamDownloader = StreamDownloader()
279
+ """全局下载器实例,提供下载功能"""
280
+ yt_dlp_downloader = None
281
+ """yt-dlp 下载器实例,提供下载视频功能,若 yt-dlp 未安装则为 None"""
231
282
 
283
+ if is_module_available("yt_dlp"):
232
284
  from .ytdlp import YtdlpDownloader
233
285
 
234
- YTDLP_DOWNLOADER = YtdlpDownloader()
235
- except ImportError:
236
- YTDLP_DOWNLOADER = None
286
+ yt_dlp_downloader = YtdlpDownloader()
287
+
288
+
289
+ @get_driver().on_shutdown
290
+ async def close_download_client():
291
+ logger.debug("正在关闭下载器...")
292
+ await downloader.aclose()
@@ -31,6 +31,14 @@ EMOJI_MAP = {
31
31
  "resolving": ("424", "👀"),
32
32
  "done": ("144", "🎉"),
33
33
  }
34
+ """emoji 映射"""
35
+
36
+ ID_ADAPTERS = {
37
+ SupportAdapter.onebot11,
38
+ SupportAdapter.qq,
39
+ SupportAdapter.milky,
40
+ }
41
+ """支持的传入 emoji id 发送 reaction 的适配器"""
34
42
 
35
43
 
36
44
  class UniHelper:
@@ -127,15 +135,12 @@ class UniHelper:
127
135
  message_id = uniseg.get_message_id(event)
128
136
  target = uniseg.get_target(event)
129
137
 
130
- if target.adapter in (SupportAdapter.onebot11, SupportAdapter.qq):
131
- emoji = EMOJI_MAP[status][0]
132
- else:
133
- emoji = EMOJI_MAP[status][1]
138
+ emoji = EMOJI_MAP[status][0 if target.adapter in ID_ADAPTERS else 1]
134
139
 
135
140
  try:
136
141
  await uniseg.message_reaction(emoji, message_id=message_id)
137
142
  except Exception:
138
- logger.warning(f"reaction {emoji} to {message_id} failed, maybe not support")
143
+ logger.opt(exception=True).warning(f"reaction {emoji} to {message_id} failed, maybe not support")
139
144
 
140
145
  @classmethod
141
146
  def with_reaction(cls, func: Callable[..., Awaitable[Any]]):
@@ -112,9 +112,9 @@ async def _(message: Message = CommandArg()):
112
112
  await UniMessage(UniHelper.file_seg(audio_path)).send()
113
113
 
114
114
 
115
- from ..download import YTDLP_DOWNLOADER
115
+ from ..download import yt_dlp_downloader
116
116
 
117
- if YTDLP_DOWNLOADER is not None:
117
+ if yt_dlp_downloader is not None:
118
118
  from ..parsers import YouTubeParser
119
119
 
120
120
  @on_command("ym", priority=3, block=True).handle()
@@ -128,7 +128,7 @@ if YTDLP_DOWNLOADER is not None:
128
128
 
129
129
  url = matched.group(0)
130
130
 
131
- audio_path = await YTDLP_DOWNLOADER.download_audio(url)
131
+ audio_path = await yt_dlp_downloader.download_audio(url)
132
132
  await UniMessage(UniHelper.record_seg(audio_path)).send()
133
133
 
134
134
  if pconfig.need_upload:
@@ -7,10 +7,10 @@ from .douyin import DouyinParser as DouyinParser
7
7
  from .twitter import TwitterParser as TwitterParser
8
8
  from .bilibili import BilibiliParser as BilibiliParser
9
9
  from .kuaishou import KuaiShouParser as KuaiShouParser
10
- from ..download import YTDLP_DOWNLOADER
10
+ from ..download import yt_dlp_downloader as yt_dlp_downloader
11
11
  from .xiaohongshu import XiaoHongShuParser as XiaoHongShuParser
12
12
 
13
- if YTDLP_DOWNLOADER is not None:
13
+ if yt_dlp_downloader is not None:
14
14
  from .tiktok import TikTokParser as TikTokParser
15
15
  from .youtube import YouTubeParser as YouTubeParser
16
16
 
@@ -1,15 +1,15 @@
1
1
  from re import Match, Pattern, compile
2
2
  from abc import ABC
3
- from typing import TYPE_CHECKING, Any, TypeVar, ClassVar, cast
3
+ from typing import TYPE_CHECKING, Any, TypeVar, ClassVar, cast, final
4
4
  from asyncio import Task
5
5
  from pathlib import Path
6
6
  from collections.abc import Callable, Coroutine
7
- from typing_extensions import Unpack, final
7
+ from typing_extensions import Unpack
8
8
 
9
9
  from .data import Platform, ParseResult, ImageContent, ParseResultKwargs
10
10
  from .task import PathTask
11
11
  from ..config import pconfig as pconfig
12
- from ..download import DOWNLOADER
12
+ from ..download import downloader
13
13
  from ..constants import IOS_HEADER, COMMON_HEADER, ANDROID_HEADER, COMMON_TIMEOUT
14
14
  from ..constants import DOWNLOAD_TIMEOUT as DOWNLOAD_TIMEOUT
15
15
  from ..constants import PlatformEnum as PlatformEnum
@@ -170,7 +170,7 @@ class BaseParser:
170
170
  author = Author(name=name, description=description)
171
171
 
172
172
  if avatar_url:
173
- author.avatar = PathTask(DOWNLOADER.download_img(avatar_url, ext_headers=self.headers))
173
+ author.avatar = PathTask(downloader.download_img(avatar_url, ext_headers=self.headers))
174
174
 
175
175
  return author
176
176
 
@@ -185,12 +185,12 @@ class BaseParser:
185
185
  from ..utils import extract_video_cover
186
186
 
187
187
  if isinstance(url_or_task, str):
188
- path_task = DOWNLOADER.download_video(url_or_task, ext_headers=self.headers)
188
+ path_task = downloader.download_video(url_or_task, ext_headers=self.headers)
189
189
  elif isinstance(url_or_task, Task):
190
190
  path_task = url_or_task
191
191
 
192
192
  if cover_url:
193
- cover_task = DOWNLOADER.download_img(cover_url, ext_headers=self.headers)
193
+ cover_task = downloader.download_img(cover_url, ext_headers=self.headers)
194
194
  else:
195
195
  # 如果没有封面 URL,尝试从视频中提取封面
196
196
  async def extract_cover():
@@ -212,7 +212,7 @@ class BaseParser:
212
212
  """创建图片内容列表"""
213
213
  contents: list[ImageContent] = []
214
214
  for url in image_urls:
215
- task = DOWNLOADER.download_img(url, ext_headers=self.headers)
215
+ task = downloader.download_img(url, ext_headers=self.headers)
216
216
  contents.append(ImageContent(PathTask(task)))
217
217
  return contents
218
218
 
@@ -223,7 +223,7 @@ class BaseParser:
223
223
  ):
224
224
  """创建单个图片内容"""
225
225
  if isinstance(url_or_task, str):
226
- path_task = DOWNLOADER.download_img(url_or_task, ext_headers=self.headers)
226
+ path_task = downloader.download_img(url_or_task, ext_headers=self.headers)
227
227
  elif isinstance(url_or_task, Task):
228
228
  path_task = url_or_task
229
229
 
@@ -238,7 +238,7 @@ class BaseParser:
238
238
  from .data import AudioContent
239
239
 
240
240
  if isinstance(url_or_task, str):
241
- path_task = DOWNLOADER.download_audio(url_or_task, ext_headers=self.headers)
241
+ path_task = downloader.download_audio(url_or_task, ext_headers=self.headers)
242
242
  elif isinstance(url_or_task, Task):
243
243
  path_task = url_or_task
244
244
 
@@ -246,4 +246,4 @@ class BaseParser:
246
246
 
247
247
  @property
248
248
  def downloader(self):
249
- return DOWNLOADER
249
+ return downloader
@@ -141,7 +141,7 @@ class BilibiliParser(BaseParser):
141
141
  ext_headers=self.headers,
142
142
  )
143
143
  else:
144
- path = await self.downloader.download_file(
144
+ path = await self.downloader._download_file(
145
145
  v_url,
146
146
  file_name=output_path.name,
147
147
  ext_headers=self.headers,
@@ -50,6 +50,8 @@ class VideoContent(MediaContent):
50
50
  repr = f"VideoContent({self.path_task}"
51
51
  if self.cover is not None:
52
52
  repr += f", cover={self.cover}"
53
+ if self.duration:
54
+ repr += f", duration={self.duration}"
53
55
  return repr + ")"
54
56
 
55
57
 
@@ -54,7 +54,6 @@ class KuaiShouParser(BaseParser):
54
54
  # 构建作者
55
55
  author = self.create_author(photo.name, photo.head_url)
56
56
 
57
- # 先以部分数据构建结果,后续再填充内容,避免使用临时变量
58
57
  result = self.result(
59
58
  title=photo.caption,
60
59
  author=author,
@@ -64,7 +63,11 @@ class KuaiShouParser(BaseParser):
64
63
 
65
64
  # 添加视频内容
66
65
  if video_url := photo.video_url:
67
- result.video = self.create_video(video_url, photo.cover_url, photo.duration)
66
+ result.video = self.create_video(
67
+ video_url,
68
+ photo.cover_url,
69
+ photo.duration_in_seconds,
70
+ )
68
71
 
69
72
  # 添加图片内容
70
73
  if img_urls := photo.img_urls:
@@ -42,6 +42,10 @@ class Photo(Struct):
42
42
  def name(self) -> str:
43
43
  return self.user_name.replace("\u3164", "").strip()
44
44
 
45
+ @property
46
+ def duration_in_seconds(self):
47
+ return self.duration // 1000
48
+
45
49
  @property
46
50
  def cover_url(self):
47
51
  return choice(self.cover_urls).url if len(self.cover_urls) != 0 else None
@@ -3,7 +3,7 @@ from typing import ClassVar
3
3
 
4
4
  from .base import BaseParser, PlatformEnum, handle
5
5
  from .data import Author, Platform
6
- from ..download import YTDLP_DOWNLOADER
6
+ from ..download import yt_dlp_downloader
7
7
 
8
8
 
9
9
  class TikTokParser(BaseParser):
@@ -18,10 +18,10 @@ class TikTokParser(BaseParser):
18
18
  url = await self.get_redirect_url(url)
19
19
 
20
20
  # 获取视频信息
21
- video_info = await YTDLP_DOWNLOADER.extract_video_info(url)
21
+ video_info = await yt_dlp_downloader.extract_video_info(url)
22
22
 
23
23
  # 下载封面和视频
24
- video = YTDLP_DOWNLOADER.download_video(url)
24
+ video = yt_dlp_downloader.download_video(url)
25
25
  video_content = self.create_video(
26
26
  video,
27
27
  video_info.thumbnail,
@@ -5,7 +5,7 @@ from httpx import AsyncClient
5
5
 
6
6
  from ..base import Platform, BaseParser, PlatformEnum, handle, pconfig
7
7
  from ..cookie import save_cookies_with_netscape
8
- from ...download import YTDLP_DOWNLOADER
8
+ from ...download import yt_dlp_downloader
9
9
 
10
10
 
11
11
  class YouTubeParser(BaseParser):
@@ -28,7 +28,7 @@ class YouTubeParser(BaseParser):
28
28
  return await self.parse_video(url)
29
29
 
30
30
  async def parse_video(self, url: str):
31
- video_info = await YTDLP_DOWNLOADER.extract_video_info(url, self.cookies_file)
31
+ video_info = await yt_dlp_downloader.extract_video_info(url, self.cookies_file)
32
32
  author = await self._fetch_author_info(video_info.channel_id)
33
33
 
34
34
  result = self.result(
@@ -38,7 +38,7 @@ class YouTubeParser(BaseParser):
38
38
  )
39
39
 
40
40
  if video_info.duration <= pconfig.duration_maximum:
41
- video = YTDLP_DOWNLOADER.download_video(url, self.cookies_file)
41
+ video = yt_dlp_downloader.download_video(url, self.cookies_file)
42
42
  result.video = self.create_video(
43
43
  video,
44
44
  video_info.thumbnail,
@@ -6,19 +6,12 @@ from .base import BaseRenderer
6
6
  from .common import CommonRenderer
7
7
  from .default import DefaultRenderer
8
8
 
9
+ RENDERER: type[BaseRenderer] | None = None
9
10
 
10
- def is_module_available(module_name: str) -> bool:
11
- """检查模块是否可用"""
12
- import importlib.util
13
-
14
- return importlib.util.find_spec(module_name) is not None
15
-
16
-
11
+ from ..utils import is_module_available
17
12
  from ..config import pconfig
18
13
  from ..constants import RenderType
19
14
 
20
- RENDERER: type[BaseRenderer] | None = None
21
-
22
15
  match pconfig.render_type:
23
16
  case RenderType.common:
24
17
  RENDERER = CommonRenderer
@@ -195,3 +195,10 @@ def write_json_to_data(data: dict[str, Any] | str, file_name: str):
195
195
  with open(path, "w") as f:
196
196
  json.dump(data, f, ensure_ascii=False, indent=4)
197
197
  logger.success(f"数据写入 {path} 成功")
198
+
199
+
200
+ def is_module_available(module_name: str) -> bool:
201
+ """检查模块是否可用"""
202
+ import importlib.util
203
+
204
+ return importlib.util.find_spec(module_name) is not None