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.
Files changed (84) hide show
  1. {parsehub-2.3.12/src/parsehub.egg-info → parsehub-2.3.13}/PKG-INFO +2 -2
  2. {parsehub-2.3.12 → parsehub-2.3.13}/pyproject.toml +2 -2
  3. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/errors.py +15 -0
  4. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/base/base.py +16 -34
  5. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/base/ytdlp.py +2 -2
  6. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/douyin.py +11 -13
  7. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/__init__.py +2 -0
  8. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/media_ref.py +3 -1
  9. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/result.py +46 -17
  10. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/utils/downloader.py +10 -4
  11. parsehub-2.3.13/src/parsehub/utils/fallback.py +51 -0
  12. {parsehub-2.3.12 → parsehub-2.3.13/src/parsehub.egg-info}/PKG-INFO +2 -2
  13. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/SOURCES.txt +1 -0
  14. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/requires.txt +1 -1
  15. {parsehub-2.3.12 → parsehub-2.3.13}/test/test_core_offline.py +3 -0
  16. {parsehub-2.3.12 → parsehub-2.3.13}/LICENSE +0 -0
  17. {parsehub-2.3.12 → parsehub-2.3.13}/README.md +0 -0
  18. {parsehub-2.3.12 → parsehub-2.3.13}/setup.cfg +0 -0
  19. {parsehub-2.3.12 → parsehub-2.3.13}/src/__init__.py +0 -0
  20. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/__init__.py +0 -0
  21. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/cli.py +0 -0
  22. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/cli_config.py +0 -0
  23. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/config/__init__.py +0 -0
  24. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/config/config.py +0 -0
  25. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/__init__.py +0 -0
  26. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/base/__init__.py +0 -0
  27. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/__init__.py +0 -0
  28. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/bangumi.py +0 -0
  29. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/bilibili.py +0 -0
  30. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/coolapk.py +0 -0
  31. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/douban.py +0 -0
  32. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/facebook.py +0 -0
  33. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/instagram.py +0 -0
  34. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  35. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/medium.py +0 -0
  36. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/pipix.py +0 -0
  37. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/snapchat.py +0 -0
  38. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/threads.py +0 -0
  39. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/tieba.py +0 -0
  40. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/tiktok.py +0 -0
  41. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/toutiao.py +0 -0
  42. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/twitter.py +0 -0
  43. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/weibo.py +0 -0
  44. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/weixin.py +0 -0
  45. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/wxchannels.py +0 -0
  46. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/xhs.py +0 -0
  47. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  48. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/youtube.py +0 -0
  49. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/zhihu.py +0 -0
  50. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  51. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/__init__.py +0 -0
  52. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/bangumi.py +0 -0
  53. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/bilibili.py +0 -0
  54. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/coolapk.py +0 -0
  55. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/douban.py +0 -0
  56. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/douyin.py +0 -0
  57. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/instagram.py +0 -0
  58. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/kuaishou.py +0 -0
  59. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/medium.py +0 -0
  60. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/pipix.py +0 -0
  61. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/threads.py +0 -0
  62. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/tieba.py +0 -0
  63. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/tiktok.py +0 -0
  64. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/toutiao.py +0 -0
  65. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/twitter.py +0 -0
  66. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/weibo.py +0 -0
  67. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/weixin.py +0 -0
  68. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/wxchannels.py +0 -0
  69. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/xhs.py +0 -0
  70. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  71. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/zhihu.py +0 -0
  72. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/provider_api/zuiyou.py +0 -0
  73. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/callback.py +0 -0
  74. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/media_file.py +0 -0
  75. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/platform.py +0 -0
  76. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/types/post.py +0 -0
  77. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/utils/helpers.py +0 -0
  78. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub/utils/media_info.py +0 -0
  79. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/dependency_links.txt +0 -0
  80. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/entry_points.txt +0 -0
  81. {parsehub-2.3.12 → parsehub-2.3.13}/src/parsehub.egg-info/top_level.txt +0 -0
  82. {parsehub-2.3.12 → parsehub-2.3.13}/test/test_cli.py +0 -0
  83. {parsehub-2.3.12 → parsehub-2.3.13}/test/test_cli_config.py +0 -0
  84. {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.12
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.8.18.122307.dev0
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.12"
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.8.18.122307.dev0",
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 Awaitable, Callable
6
- from dataclasses import dataclass
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
- for stage in stages:
123
- try:
124
- result = await stage.func()
125
- except Exception as e:
126
- logger.opt(exception=e).debug(f"{self.__platform__} {stage.name} 解析失败")
127
- should_stop = stop(e)
128
- err = _attach_cookie(e, self.cookie if stage.cookie else SecretCookie())
129
- if should_stop:
130
- if err is e:
131
- raise
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
- raise error_type(attempts) from last_error
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(f"下载失败: {str(e)}") from e
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 | None, int, int]:
119
+ def _p_play_addr(video_data: dict) -> tuple[list[str], int, int]:
120
120
  play_addr = video_data["play_addr"]
121
- url_list = play_addr["url_list"]
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
- url_list = play_addr.get("url_list", [])
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
- u, w, h = _p_play_addr(video_data)
150
- if not u:
151
- u, w, h = _p_bit_rates(video_data, True)
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
- u, w, h = _p_bit_rates(video_data)
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
- try:
121
- f = await download(
122
- media.url,
123
- output_dir.joinpath(f"{stem}.{media.ext}"),
124
- headers=headers,
125
- proxy=proxy,
126
- progress=progress,
127
- progress_args=progress_args,
128
- progress_kwargs=progress_kwargs,
129
- connections=connections,
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
- except Exception as e:
132
- raise DownloadError(f"下载失败: {e}") from e
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 视频下载失败: {e}") from e
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__(self, media: AnyMediaFile | Sequence[AnyMediaFile], output_dir: str | Path):
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(f"HTTP错误: {e.response.status_code}") from e
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"下载失败: {e}") from e
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(f"分片下载失败: HTTP {response.status_code}")
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(f"分片下载失败: HTTP {e.response.status_code}") from e
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.12
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.8.18.122307.dev0
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
@@ -73,6 +73,7 @@ src/parsehub/types/platform.py
73
73
  src/parsehub/types/post.py
74
74
  src/parsehub/types/result.py
75
75
  src/parsehub/utils/downloader.py
76
+ src/parsehub/utils/fallback.py
76
77
  src/parsehub/utils/helpers.py
77
78
  src/parsehub/utils/media_info.py
78
79
  test/test_cli.py
@@ -5,7 +5,7 @@ pydub>=0.25.1
5
5
  python-dotenv>=1.0.1
6
6
  tenacity>=8.5.0
7
7
  urlextract>=1.9.0
8
- yt-dlp[default]>=2026.8.18.122307.dev0
8
+ yt-dlp[default]>=2026.10.10.233141.dev0
9
9
  lxml>=5.3.0
10
10
  pydantic>=1.10.19
11
11
  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