parsehub 2.3.12__tar.gz → 2.3.13__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.
- {parsehub-2.3.12/src/parsehub.egg-info → parsehub-2.3.13}/PKG-INFO +2 -2
- {parsehub-2.3.12 → parsehub-2.3.13}/pyproject.toml +2 -2
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/errors.py +15 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/base/base.py +16 -34
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/base/ytdlp.py +2 -2
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/douyin.py +11 -13
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/__init__.py +2 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/media_ref.py +3 -1
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/result.py +46 -17
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/utils/downloader.py +10 -4
- parsehub-2.3.13/src/parsehub/utils/fallback.py +51 -0
- {parsehub-2.3.12 → parsehub-2.3.13/src/parsehub.egg-info}/PKG-INFO +2 -2
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/SOURCES.txt +1 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/requires.txt +1 -1
- {parsehub-2.3.12 → parsehub-2.3.13}/test/test_core_offline.py +3 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/LICENSE +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/README.md +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/setup.cfg +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/__init__.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/__init__.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/cli.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/cli_config.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/config/__init__.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/config/config.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/__init__.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/base/__init__.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/__init__.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/bangumi.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/bilibili.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/coolapk.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/douban.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/facebook.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/instagram.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/kuaishou.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/medium.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/pipix.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/snapchat.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/threads.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/tieba.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/tiktok.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/toutiao.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/twitter.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/weibo.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/weixin.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/wxchannels.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/xhs.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/youtube.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/zhihu.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/zuiyou.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/__init__.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/bangumi.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/bilibili.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/coolapk.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/douban.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/douyin.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/instagram.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/kuaishou.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/medium.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/pipix.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/threads.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/tieba.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/tiktok.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/toutiao.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/twitter.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/weibo.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/weixin.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/wxchannels.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/xhs.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/xiaoheihe.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/zhihu.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/zuiyou.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/callback.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/media_file.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/platform.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/post.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/utils/helpers.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/utils/media_info.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/dependency_links.txt +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/entry_points.txt +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/top_level.txt +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/test/test_cli.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/test/test_cli_config.py +0 -0
- {parsehub-2.3.12 → parsehub-2.3.13}/test/test_downloader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parsehub
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.13
|
|
4
4
|
Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
|
|
5
5
|
Author-email: 梓澪 <zilingmio@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Requires-Dist: pydub>=0.25.1
|
|
|
22
22
|
Requires-Dist: python-dotenv>=1.0.1
|
|
23
23
|
Requires-Dist: tenacity>=8.5.0
|
|
24
24
|
Requires-Dist: urlextract>=1.9.0
|
|
25
|
-
Requires-Dist: yt-dlp[default]>=2026.
|
|
25
|
+
Requires-Dist: yt-dlp[default]>=2026.10.10.233141.dev0
|
|
26
26
|
Requires-Dist: lxml>=5.3.0
|
|
27
27
|
Requires-Dist: pydantic>=1.10.19
|
|
28
28
|
Requires-Dist: markdownify>=1.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "parsehub"
|
|
3
|
-
version = "2.3.
|
|
3
|
+
version = "2.3.13"
|
|
4
4
|
description = "轻量、异步、开箱即用的社交媒体聚合解析库"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12.0"
|
|
@@ -25,7 +25,7 @@ dependencies = [
|
|
|
25
25
|
"python-dotenv>=1.0.1",
|
|
26
26
|
"tenacity>=8.5.0",
|
|
27
27
|
"urlextract>=1.9.0",
|
|
28
|
-
"yt-dlp[default]>=2026.
|
|
28
|
+
"yt-dlp[default]>=2026.10.10.233141.dev0",
|
|
29
29
|
"lxml>=5.3.0",
|
|
30
30
|
"pydantic>=1.10.19",
|
|
31
31
|
"markdownify>=1.1.0",
|
|
@@ -76,6 +76,21 @@ class DownloadError(ParseHubError):
|
|
|
76
76
|
"""下载错误"""
|
|
77
77
|
|
|
78
78
|
MSG = __doc__
|
|
79
|
+
status_code: int | None = None
|
|
80
|
+
|
|
81
|
+
def __init__(self, msg: str = "", *, status_code: int | None = None, cookie: "SecretCookie | None" = None):
|
|
82
|
+
super().__init__(msg, cookie=cookie)
|
|
83
|
+
self.status_code = status_code
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DownloadFallbackError(DownloadError):
|
|
87
|
+
"""所有下载地址均失败"""
|
|
88
|
+
|
|
89
|
+
MSG = __doc__
|
|
90
|
+
|
|
91
|
+
def __init__(self, attempts: list[FallbackAttempt]):
|
|
92
|
+
self.attempts = attempts
|
|
93
|
+
Exception.__init__(self, f"{self.MSG}:\n{format_attempts(attempts)}")
|
|
79
94
|
|
|
80
95
|
|
|
81
96
|
class DeleteError(ParseHubError):
|
|
@@ -2,13 +2,11 @@ import importlib
|
|
|
2
2
|
import pkgutil
|
|
3
3
|
import re
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
|
-
from collections.abc import
|
|
6
|
-
from
|
|
7
|
-
from typing import Any, Generic, TypeVar
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
8
7
|
from urllib.parse import parse_qs, urlencode, urlparse
|
|
9
8
|
|
|
10
9
|
import httpx
|
|
11
|
-
from loguru import logger
|
|
12
10
|
|
|
13
11
|
from ... import parsers
|
|
14
12
|
from ...errors import ParseHubError
|
|
@@ -21,18 +19,12 @@ from ...types import (
|
|
|
21
19
|
ParseError,
|
|
22
20
|
)
|
|
23
21
|
from ...types.platform import Platform
|
|
22
|
+
from ...utils.fallback import FallbackStage, run_fallback
|
|
24
23
|
from ...utils.helpers import UA, SecretCookie, match_url
|
|
25
24
|
|
|
26
25
|
T = TypeVar("T")
|
|
27
26
|
|
|
28
27
|
|
|
29
|
-
@dataclass(frozen=True)
|
|
30
|
-
class FallbackStage(Generic[T]): # noqa: UP046
|
|
31
|
-
name: str
|
|
32
|
-
func: Callable[[], Awaitable[T]]
|
|
33
|
-
cookie: bool = False
|
|
34
|
-
|
|
35
|
-
|
|
36
28
|
def _attach_cookie(e: Exception, cookie: SecretCookie) -> Exception:
|
|
37
29
|
if isinstance(e, (ContentUnavailableError, FallbackError)):
|
|
38
30
|
return e
|
|
@@ -118,30 +110,20 @@ class BaseParser(ABC):
|
|
|
118
110
|
*stages: FallbackStage[T],
|
|
119
111
|
stop: Callable[[Exception], bool] = is_content_unavailable,
|
|
120
112
|
) -> T:
|
|
121
|
-
attempts: list[FallbackAttempt]
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
raise err from e
|
|
133
|
-
attempts.append(FallbackAttempt(stage.name, err, stage.cookie))
|
|
134
|
-
else:
|
|
135
|
-
self._fallback_attempts.extend(attempts)
|
|
136
|
-
return result
|
|
137
|
-
|
|
138
|
-
if len(attempts) == 1:
|
|
139
|
-
raise attempts[0].error
|
|
140
|
-
last_error = attempts[-1].error
|
|
141
|
-
error_type = (
|
|
142
|
-
ContentUnavailableFallbackError if isinstance(last_error, ContentUnavailableError) else FallbackError
|
|
113
|
+
def exhausted(attempts: list[FallbackAttempt]) -> FallbackError:
|
|
114
|
+
if isinstance(attempts[-1].error, ContentUnavailableError):
|
|
115
|
+
return ContentUnavailableFallbackError(attempts)
|
|
116
|
+
return FallbackError(attempts)
|
|
117
|
+
|
|
118
|
+
result, attempts = await run_fallback(
|
|
119
|
+
stages,
|
|
120
|
+
stop=stop,
|
|
121
|
+
wrap=lambda e, stage: _attach_cookie(e, self.cookie if stage.cookie else SecretCookie()),
|
|
122
|
+
exhausted=exhausted,
|
|
123
|
+
label=f"{self.__platform__} 解析",
|
|
143
124
|
)
|
|
144
|
-
|
|
125
|
+
self._fallback_attempts.extend(attempts)
|
|
126
|
+
return result
|
|
145
127
|
|
|
146
128
|
@abstractmethod
|
|
147
129
|
async def _do_parse(self, raw_url: str) -> AnyParseResult:
|
|
@@ -499,7 +499,7 @@ class YtVideoParseResult(VideoParseResult):
|
|
|
499
499
|
|
|
500
500
|
v = [p for p in output_dir_path.glob(f"{self.name}.*") if p.is_file()]
|
|
501
501
|
if not v:
|
|
502
|
-
raise DownloadError("
|
|
502
|
+
raise DownloadError("未找到下载后的视频文件")
|
|
503
503
|
|
|
504
504
|
if callback:
|
|
505
505
|
await callback(100, 100, "bytes", *callback_args, **callback_kwargs)
|
|
@@ -540,7 +540,7 @@ class YtVideoParseResult(VideoParseResult):
|
|
|
540
540
|
callback_kwargs=callback_kwargs,
|
|
541
541
|
)
|
|
542
542
|
except Exception as e:
|
|
543
|
-
raise DownloadError(
|
|
543
|
+
raise DownloadError(str(e)) from e
|
|
544
544
|
|
|
545
545
|
|
|
546
546
|
@dataclass
|
|
@@ -116,15 +116,12 @@ def remove_video_watermark(url: str) -> str:
|
|
|
116
116
|
return url.replace("playwm", "play")
|
|
117
117
|
|
|
118
118
|
|
|
119
|
-
def _p_play_addr(video_data: dict) -> tuple[str
|
|
119
|
+
def _p_play_addr(video_data: dict) -> tuple[list[str], int, int]:
|
|
120
120
|
play_addr = video_data["play_addr"]
|
|
121
|
-
|
|
122
|
-
if not url_list:
|
|
123
|
-
return None, 0, 0
|
|
124
|
-
return url_list[0], play_addr.get("width", 0), play_addr.get("height", 0)
|
|
121
|
+
return play_addr["url_list"], play_addr.get("width", 0), play_addr.get("height", 0)
|
|
125
122
|
|
|
126
123
|
|
|
127
|
-
def _p_bit_rates(video_data: dict, filter_bytevc1: bool = False) -> tuple[str, int, int]:
|
|
124
|
+
def _p_bit_rates(video_data: dict, filter_bytevc1: bool = False) -> tuple[list[str], int, int]:
|
|
128
125
|
bit_rates: list = video_data.get("bit_rate", [])
|
|
129
126
|
if filter_bytevc1:
|
|
130
127
|
bit_rates = list(filter(lambda i: i.get("is_bytevc1", 0) == 0, bit_rates))
|
|
@@ -139,20 +136,19 @@ def _p_bit_rates(video_data: dict, filter_bytevc1: bool = False) -> tuple[str, i
|
|
|
139
136
|
best_quality = bit_rates[0]
|
|
140
137
|
|
|
141
138
|
play_addr = best_quality.get("play_addr", {})
|
|
142
|
-
|
|
143
|
-
return url_list[0], play_addr.get("width", 0), play_addr.get("height", 0)
|
|
139
|
+
return play_addr.get("url_list", []), play_addr.get("width", 0), play_addr.get("height", 0)
|
|
144
140
|
|
|
145
141
|
|
|
146
142
|
def parse_video_info(video_data: dict) -> dict:
|
|
147
143
|
is_app = video_data.get("is_bytevc1", None) is not None # 只有 app 接口带 is_bytevc1 参数
|
|
148
144
|
if is_app:
|
|
149
|
-
|
|
150
|
-
if not
|
|
151
|
-
|
|
145
|
+
urls, w, h = _p_play_addr(video_data)
|
|
146
|
+
if not urls:
|
|
147
|
+
urls, w, h = _p_bit_rates(video_data, True)
|
|
152
148
|
else:
|
|
153
|
-
|
|
149
|
+
urls, w, h = _p_bit_rates(video_data)
|
|
154
150
|
|
|
155
|
-
video_url = remove_video_watermark(u)
|
|
151
|
+
video_url, *backup_urls = dict.fromkeys(remove_video_watermark(u) for u in urls)
|
|
156
152
|
|
|
157
153
|
cover = video_data.get("cover", {})
|
|
158
154
|
thumb_url_list = cover.get("url_list", [])
|
|
@@ -160,6 +156,7 @@ def parse_video_info(video_data: dict) -> dict:
|
|
|
160
156
|
|
|
161
157
|
return {
|
|
162
158
|
"video_url": video_url,
|
|
159
|
+
"backup_urls": backup_urls,
|
|
163
160
|
"thumb_url": thumb_url,
|
|
164
161
|
"duration": video_data.get("duration", 0),
|
|
165
162
|
"width": w,
|
|
@@ -288,6 +285,7 @@ class DouyinApiResult:
|
|
|
288
285
|
type=DouyinMediaType.VIDEO,
|
|
289
286
|
video=VideoRef(
|
|
290
287
|
url=video_info["video_url"],
|
|
288
|
+
backup_urls=video_info["backup_urls"],
|
|
291
289
|
thumb_url=video_info["thumb_url"],
|
|
292
290
|
width=video_info["width"],
|
|
293
291
|
height=video_info["height"],
|
|
@@ -2,6 +2,7 @@ from ..errors import (
|
|
|
2
2
|
ContentUnavailableError,
|
|
3
3
|
ContentUnavailableFallbackError,
|
|
4
4
|
DownloadError,
|
|
5
|
+
DownloadFallbackError,
|
|
5
6
|
FallbackAttempt,
|
|
6
7
|
FallbackError,
|
|
7
8
|
ParseError,
|
|
@@ -23,6 +24,7 @@ from .result import (
|
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"DownloadError",
|
|
27
|
+
"DownloadFallbackError",
|
|
26
28
|
"ParseError",
|
|
27
29
|
"ContentUnavailableError",
|
|
28
30
|
"FallbackAttempt",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
@dataclass(kw_only=True)
|
|
@@ -10,6 +10,7 @@ class MediaRef:
|
|
|
10
10
|
thumb_url: 缩略图 URL
|
|
11
11
|
width: 宽度
|
|
12
12
|
height: 高度
|
|
13
|
+
backup_urls: 备用下载链接, 与 url 是同一个文件, url 下载失败时按顺序尝试
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
16
|
url: str
|
|
@@ -17,6 +18,7 @@ class MediaRef:
|
|
|
17
18
|
thumb_url: str | None = None
|
|
18
19
|
width: int = 0
|
|
19
20
|
height: int = 0
|
|
21
|
+
backup_urls: list[str] = field(default_factory=list)
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
@dataclass(kw_only=True)
|
|
@@ -7,6 +7,7 @@ from collections.abc import Sequence
|
|
|
7
7
|
from dataclasses import asdict
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any, ClassVar
|
|
10
|
+
from urllib.parse import urlparse
|
|
10
11
|
|
|
11
12
|
import aiofiles
|
|
12
13
|
from bs4 import BeautifulSoup
|
|
@@ -14,8 +15,9 @@ from markdown import markdown as md_to_html
|
|
|
14
15
|
from slugify import slugify
|
|
15
16
|
|
|
16
17
|
from ..config import GlobalConfig
|
|
17
|
-
from ..errors import DeleteError, DownloadError, FallbackAttempt
|
|
18
|
+
from ..errors import DeleteError, DownloadError, DownloadFallbackError, FallbackAttempt
|
|
18
19
|
from ..utils.downloader import download
|
|
20
|
+
from ..utils.fallback import FallbackStage, run_fallback
|
|
19
21
|
from ..utils.helpers import run_sync
|
|
20
22
|
from .callback import ProgressCallback
|
|
21
23
|
from .media_file import AniFile, AnyMediaFile, ImageFile, LivePhotoFile, VideoFile
|
|
@@ -80,6 +82,10 @@ class ParseResult(ABC): # noqa: B024
|
|
|
80
82
|
"media": media,
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
def _download_stop(self, e: Exception) -> bool:
|
|
86
|
+
"""下载失败时是否不再尝试后面的备用链接, 平台可覆盖"""
|
|
87
|
+
return False
|
|
88
|
+
|
|
83
89
|
async def _do_download(
|
|
84
90
|
self,
|
|
85
91
|
*,
|
|
@@ -109,6 +115,7 @@ class ParseResult(ABC): # noqa: B024
|
|
|
109
115
|
media_list = list(self.media) if isinstance(self.media, Sequence) else [self.media]
|
|
110
116
|
is_single = not isinstance(self.media, Sequence)
|
|
111
117
|
concurrency = max(1, concurrency)
|
|
118
|
+
fallback_errors: list[FallbackAttempt] = []
|
|
112
119
|
|
|
113
120
|
async def _download_media(
|
|
114
121
|
media: AnyMediaRef,
|
|
@@ -117,19 +124,30 @@ class ParseResult(ABC): # noqa: B024
|
|
|
117
124
|
progress_args: tuple = (),
|
|
118
125
|
progress_kwargs: dict | None = None,
|
|
119
126
|
) -> AnyMediaFile:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
def _stage(i: int, url: str) -> FallbackStage[str]:
|
|
128
|
+
return FallbackStage(
|
|
129
|
+
f"#{i + 1} {urlparse(url).hostname}",
|
|
130
|
+
lambda: download(
|
|
131
|
+
url,
|
|
132
|
+
output_dir.joinpath(f"{stem}.{media.ext}"),
|
|
133
|
+
headers=headers,
|
|
134
|
+
proxy=proxy,
|
|
135
|
+
progress=progress,
|
|
136
|
+
progress_args=progress_args,
|
|
137
|
+
progress_kwargs=progress_kwargs,
|
|
138
|
+
connections=connections,
|
|
139
|
+
),
|
|
130
140
|
)
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
|
|
142
|
+
urls = list(dict.fromkeys([media.url, *media.backup_urls]))
|
|
143
|
+
f, attempts = await run_fallback(
|
|
144
|
+
[_stage(i, url) for i, url in enumerate(urls)],
|
|
145
|
+
stop=self._download_stop,
|
|
146
|
+
wrap=lambda e, _: e if isinstance(e, DownloadError) else DownloadError(f"{type(e).__name__}: {e}"),
|
|
147
|
+
exhausted=DownloadFallbackError,
|
|
148
|
+
label=f"{self.platform.display_name if self.platform else ''} 下载",
|
|
149
|
+
)
|
|
150
|
+
fallback_errors.extend(attempts)
|
|
133
151
|
|
|
134
152
|
mf: AnyMediaFile
|
|
135
153
|
match media:
|
|
@@ -150,8 +168,12 @@ class ParseResult(ABC): # noqa: B024
|
|
|
150
168
|
proxy=proxy,
|
|
151
169
|
connections=connections,
|
|
152
170
|
)
|
|
171
|
+
except DownloadError as e:
|
|
172
|
+
raise DownloadError(
|
|
173
|
+
f"LivePhoto 视频{e.message.removeprefix(DownloadError.MSG)}", status_code=e.status_code
|
|
174
|
+
) from e
|
|
153
175
|
except Exception as e:
|
|
154
|
-
raise DownloadError(f"LivePhoto
|
|
176
|
+
raise DownloadError(f"LivePhoto 视频: {type(e).__name__}: {e}") from e
|
|
155
177
|
else:
|
|
156
178
|
mf.video_path = vf
|
|
157
179
|
|
|
@@ -182,7 +204,7 @@ class ParseResult(ABC): # noqa: B024
|
|
|
182
204
|
except DownloadError:
|
|
183
205
|
shutil.rmtree(output_dir, ignore_errors=True)
|
|
184
206
|
raise
|
|
185
|
-
return DownloadResult(mf, output_dir)
|
|
207
|
+
return DownloadResult(mf, output_dir, fallback_errors)
|
|
186
208
|
|
|
187
209
|
n = len(media_list)
|
|
188
210
|
results: list[AnyMediaFile | None] = [None] * n
|
|
@@ -210,7 +232,7 @@ class ParseResult(ABC): # noqa: B024
|
|
|
210
232
|
raise
|
|
211
233
|
|
|
212
234
|
result_list = [media for media in results if media is not None]
|
|
213
|
-
return DownloadResult(result_list, output_dir)
|
|
235
|
+
return DownloadResult(result_list, output_dir, fallback_errors)
|
|
214
236
|
|
|
215
237
|
async def download(
|
|
216
238
|
self,
|
|
@@ -413,14 +435,21 @@ class RichTextParseResult(ParseResult):
|
|
|
413
435
|
|
|
414
436
|
|
|
415
437
|
class DownloadResult:
|
|
416
|
-
def __init__(
|
|
438
|
+
def __init__(
|
|
439
|
+
self,
|
|
440
|
+
media: AnyMediaFile | Sequence[AnyMediaFile],
|
|
441
|
+
output_dir: str | Path,
|
|
442
|
+
fallback_errors: list[FallbackAttempt] | None = None,
|
|
443
|
+
):
|
|
417
444
|
"""
|
|
418
445
|
下载结果
|
|
419
446
|
:param media: 本地媒体路径
|
|
420
447
|
:param output_dir: 输出目录
|
|
448
|
+
:param fallback_errors: 下载成功前失败的链接
|
|
421
449
|
"""
|
|
422
450
|
self.media = media
|
|
423
451
|
self.output_dir = Path(output_dir).resolve()
|
|
452
|
+
self.fallback_errors = fallback_errors or []
|
|
424
453
|
|
|
425
454
|
def delete(self) -> None:
|
|
426
455
|
try:
|
|
@@ -96,7 +96,9 @@ class SegmentDownloader:
|
|
|
96
96
|
except httpx.HTTPStatusError as e:
|
|
97
97
|
last_error = e
|
|
98
98
|
if attempt == self.max_retries or not _is_retryable_status(e.response.status_code):
|
|
99
|
-
raise DownloadError(
|
|
99
|
+
raise DownloadError(
|
|
100
|
+
f"HTTP错误: {e.response.status_code}", status_code=e.response.status_code
|
|
101
|
+
) from e
|
|
100
102
|
except (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError, httpx.ReadError) as e:
|
|
101
103
|
last_error = e
|
|
102
104
|
if attempt == self.max_retries:
|
|
@@ -104,7 +106,7 @@ class SegmentDownloader:
|
|
|
104
106
|
except Exception as e:
|
|
105
107
|
last_error = e
|
|
106
108
|
if attempt == self.max_retries:
|
|
107
|
-
raise DownloadError(f"
|
|
109
|
+
raise DownloadError(f"{type(e).__name__}: {e}" if str(e) else type(e).__name__) from e
|
|
108
110
|
|
|
109
111
|
await asyncio.sleep(2**attempt)
|
|
110
112
|
|
|
@@ -273,7 +275,9 @@ class SegmentDownloader:
|
|
|
273
275
|
raise FallbackToSingle
|
|
274
276
|
response.raise_for_status()
|
|
275
277
|
if response.status_code != 206:
|
|
276
|
-
raise DownloadError(
|
|
278
|
+
raise DownloadError(
|
|
279
|
+
f"分片下载失败: HTTP {response.status_code}", status_code=response.status_code
|
|
280
|
+
)
|
|
277
281
|
if _has_non_identity_encoding(response.headers.get("Content-Encoding")):
|
|
278
282
|
raise FallbackToSingle
|
|
279
283
|
self._validate_part_response(part, response.headers, total_size)
|
|
@@ -293,7 +297,9 @@ class SegmentDownloader:
|
|
|
293
297
|
raise
|
|
294
298
|
except httpx.HTTPStatusError as e:
|
|
295
299
|
if attempt == self.max_retries or not _is_retryable_status(e.response.status_code):
|
|
296
|
-
raise DownloadError(
|
|
300
|
+
raise DownloadError(
|
|
301
|
+
f"分片下载失败: HTTP {e.response.status_code}", status_code=e.response.status_code
|
|
302
|
+
) from e
|
|
297
303
|
except (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError, httpx.ReadError) as e:
|
|
298
304
|
if attempt == self.max_retries:
|
|
299
305
|
raise DownloadError(f"分片网络错误: {e}") from e
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from ..errors import FallbackAttempt
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FallbackStage(Generic[T]): # noqa: UP046
|
|
14
|
+
name: str
|
|
15
|
+
func: Callable[[], Awaitable[T]]
|
|
16
|
+
cookie: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def run_fallback( # noqa: UP047
|
|
20
|
+
stages: tuple[FallbackStage[T], ...] | list[FallbackStage[T]],
|
|
21
|
+
*,
|
|
22
|
+
stop: Callable[[Exception], bool],
|
|
23
|
+
wrap: Callable[[Exception, FallbackStage[T]], Exception],
|
|
24
|
+
exhausted: Callable[[list[FallbackAttempt]], Exception],
|
|
25
|
+
label: str = "",
|
|
26
|
+
) -> tuple[T, list[FallbackAttempt]]:
|
|
27
|
+
"""依次尝试各层, 返回首个成功的结果和之前失败的记录
|
|
28
|
+
|
|
29
|
+
:param stop: 返回 True 则中止回退, 直接抛出该层错误
|
|
30
|
+
:param wrap: 包装各层的原始错误
|
|
31
|
+
:param exhausted: 多层全部失败时构造的错误, 只试了一层则透传该层错误
|
|
32
|
+
:param label: 日志前缀
|
|
33
|
+
"""
|
|
34
|
+
attempts: list[FallbackAttempt] = []
|
|
35
|
+
for stage in stages:
|
|
36
|
+
try:
|
|
37
|
+
result = await stage.func()
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.opt(exception=e).debug(f"{label} {stage.name} 失败")
|
|
40
|
+
err = wrap(e, stage)
|
|
41
|
+
if stop(e):
|
|
42
|
+
if err is e:
|
|
43
|
+
raise
|
|
44
|
+
raise err from e
|
|
45
|
+
attempts.append(FallbackAttempt(stage.name, err, stage.cookie))
|
|
46
|
+
else:
|
|
47
|
+
return result, attempts
|
|
48
|
+
|
|
49
|
+
if len(attempts) == 1:
|
|
50
|
+
raise attempts[0].error
|
|
51
|
+
raise exhausted(attempts) from attempts[-1].error
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parsehub
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.13
|
|
4
4
|
Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
|
|
5
5
|
Author-email: 梓澪 <zilingmio@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Requires-Dist: pydub>=0.25.1
|
|
|
22
22
|
Requires-Dist: python-dotenv>=1.0.1
|
|
23
23
|
Requires-Dist: tenacity>=8.5.0
|
|
24
24
|
Requires-Dist: urlextract>=1.9.0
|
|
25
|
-
Requires-Dist: yt-dlp[default]>=2026.
|
|
25
|
+
Requires-Dist: yt-dlp[default]>=2026.10.10.233141.dev0
|
|
26
26
|
Requires-Dist: lxml>=5.3.0
|
|
27
27
|
Requires-Dist: pydantic>=1.10.19
|
|
28
28
|
Requires-Dist: markdownify>=1.1.0
|
|
@@ -188,6 +188,7 @@ class TestParseResultToDict(unittest.TestCase):
|
|
|
188
188
|
"thumb_url": "https://cdn.example/thumb.jpg",
|
|
189
189
|
"width": 1920,
|
|
190
190
|
"height": 1080,
|
|
191
|
+
"backup_urls": [],
|
|
191
192
|
"duration": 90,
|
|
192
193
|
},
|
|
193
194
|
},
|
|
@@ -209,6 +210,7 @@ class TestParseResultToDict(unittest.TestCase):
|
|
|
209
210
|
"thumb_url": None,
|
|
210
211
|
"width": 100,
|
|
211
212
|
"height": 0,
|
|
213
|
+
"backup_urls": [],
|
|
212
214
|
},
|
|
213
215
|
{
|
|
214
216
|
"url": "https://cdn.example/two.jpg",
|
|
@@ -216,6 +218,7 @@ class TestParseResultToDict(unittest.TestCase):
|
|
|
216
218
|
"thumb_url": None,
|
|
217
219
|
"width": 0,
|
|
218
220
|
"height": 0,
|
|
221
|
+
"backup_urls": [],
|
|
219
222
|
},
|
|
220
223
|
],
|
|
221
224
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|