parsehub 2.3.2__tar.gz → 2.3.4__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.2/src/parsehub.egg-info → parsehub-2.3.4}/PKG-INFO +2 -1
  2. {parsehub-2.3.2 → parsehub-2.3.4}/pyproject.toml +2 -1
  3. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/__init__.py +5 -1
  4. parsehub-2.3.4/src/parsehub/errors.py +75 -0
  5. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/base/base.py +54 -2
  6. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/base/ytdlp.py +2 -2
  7. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/bilibili.py +6 -11
  8. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/douyin.py +13 -21
  9. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/facebook.py +1 -0
  10. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/instagram.py +1 -1
  11. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/kuaishou.py +11 -13
  12. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/twitter.py +21 -22
  13. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/wxchannels.py +1 -0
  14. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/xhs.py +28 -12
  15. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/douyin.py +8 -6
  16. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/xhs.py +78 -24
  17. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/types/__init__.py +11 -1
  18. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/types/result.py +2 -1
  19. {parsehub-2.3.2 → parsehub-2.3.4/src/parsehub.egg-info}/PKG-INFO +2 -1
  20. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub.egg-info/requires.txt +1 -0
  21. parsehub-2.3.2/src/parsehub/errors.py +0 -35
  22. {parsehub-2.3.2 → parsehub-2.3.4}/LICENSE +0 -0
  23. {parsehub-2.3.2 → parsehub-2.3.4}/README.md +0 -0
  24. {parsehub-2.3.2 → parsehub-2.3.4}/setup.cfg +0 -0
  25. {parsehub-2.3.2 → parsehub-2.3.4}/src/__init__.py +0 -0
  26. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/cli.py +0 -0
  27. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/cli_config.py +0 -0
  28. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/config/__init__.py +0 -0
  29. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/config/config.py +0 -0
  30. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/__init__.py +0 -0
  31. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/base/__init__.py +0 -0
  32. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/__init__.py +0 -0
  33. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/bangumi.py +0 -0
  34. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/coolapk.py +0 -0
  35. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/douban.py +0 -0
  36. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/medium.py +0 -0
  37. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/pipix.py +0 -0
  38. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/snapchat.py +0 -0
  39. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/threads.py +0 -0
  40. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/tieba.py +0 -0
  41. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/tiktok.py +0 -0
  42. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/toutiao.py +0 -0
  43. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/weibo.py +0 -0
  44. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/weixin.py +0 -0
  45. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  46. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/youtube.py +0 -0
  47. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/zhihu.py +0 -0
  48. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  49. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/__init__.py +0 -0
  50. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/bangumi.py +0 -0
  51. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/bilibili.py +0 -0
  52. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/coolapk.py +0 -0
  53. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/douban.py +0 -0
  54. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/instagram.py +0 -0
  55. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/kuaishou.py +0 -0
  56. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/medium.py +0 -0
  57. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/pipix.py +0 -0
  58. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/threads.py +0 -0
  59. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/tieba.py +0 -0
  60. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/tiktok.py +0 -0
  61. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/toutiao.py +0 -0
  62. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/twitter.py +0 -0
  63. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/weibo.py +0 -0
  64. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/weixin.py +0 -0
  65. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/wxchannels.py +0 -0
  66. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  67. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/zhihu.py +0 -0
  68. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/provider_api/zuiyou.py +0 -0
  69. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/types/callback.py +0 -0
  70. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/types/media_file.py +0 -0
  71. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/types/media_ref.py +0 -0
  72. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/types/platform.py +0 -0
  73. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/types/post.py +0 -0
  74. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/utils/downloader.py +0 -0
  75. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/utils/helpers.py +0 -0
  76. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub/utils/media_info.py +0 -0
  77. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub.egg-info/SOURCES.txt +0 -0
  78. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub.egg-info/dependency_links.txt +0 -0
  79. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub.egg-info/entry_points.txt +0 -0
  80. {parsehub-2.3.2 → parsehub-2.3.4}/src/parsehub.egg-info/top_level.txt +0 -0
  81. {parsehub-2.3.2 → parsehub-2.3.4}/test/test_cli.py +0 -0
  82. {parsehub-2.3.2 → parsehub-2.3.4}/test/test_cli_config.py +0 -0
  83. {parsehub-2.3.2 → parsehub-2.3.4}/test/test_core_offline.py +0 -0
  84. {parsehub-2.3.2 → parsehub-2.3.4}/test/test_downloader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -37,6 +37,7 @@ Requires-Dist: cryptography>=49.0.0
37
37
  Requires-Dist: gmssl>=3.2.2
38
38
  Requires-Dist: signerpy>=0.12.0
39
39
  Requires-Dist: curl-cffi>=0.13
40
+ Requires-Dist: xhshow>=0.2.0
40
41
  Provides-Extra: cli
41
42
  Requires-Dist: argcomplete>=3.6.3; extra == "cli"
42
43
  Requires-Dist: platformdirs>=4.5.1; extra == "cli"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "parsehub"
3
- version = "2.3.2"
3
+ version = "2.3.4"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -40,6 +40,7 @@ dependencies = [
40
40
  "gmssl>=3.2.2",
41
41
  "signerpy>=0.12.0",
42
42
  "curl-cffi>=0.13",
43
+ "xhshow>=0.2.0",
43
44
  ]
44
45
 
45
46
  [project.scripts]
@@ -2,6 +2,10 @@ from pathlib import Path
2
2
 
3
3
  from loguru import logger
4
4
 
5
+ from .errors import ContentUnavailableError as ContentUnavailableError
6
+ from .errors import ContentUnavailableFallbackError as ContentUnavailableFallbackError
7
+ from .errors import FallbackAttempt as FallbackAttempt
8
+ from .errors import FallbackError as FallbackError
5
9
  from .errors import ParseError, UnknownPlatform
6
10
  from .parsers.base import BaseParser
7
11
  from .types import Platform
@@ -182,7 +186,7 @@ class ParseHub:
182
186
  except ParseError:
183
187
  raise
184
188
  except Exception as e:
185
- raise ParseError from e
189
+ raise ParseError(str(e)) from e
186
190
 
187
191
  def _select_parser(self, url: str) -> type[BaseParser] | None:
188
192
  """选择解析器
@@ -0,0 +1,75 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ class ParseHubError(Exception):
5
+ MSG = __doc__
6
+
7
+ def __init__(self, msg: str = ""):
8
+ super().__init__(self.MSG if not msg else f"{self.MSG}: {msg}")
9
+
10
+
11
+ class ParseError(ParseHubError):
12
+ """解析错误"""
13
+
14
+ MSG = __doc__
15
+
16
+
17
+ class ContentUnavailableError(ParseError):
18
+ """作品不可用"""
19
+
20
+ MSG = __doc__
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FallbackAttempt:
25
+ name: str
26
+ error: Exception
27
+ cookie: bool = False
28
+
29
+
30
+ def format_attempts(attempts: list[FallbackAttempt], indent: int = 0) -> str:
31
+ lines = []
32
+ for attempt in attempts:
33
+ prefix = " " * indent + f"- {attempt.name}"
34
+ if isinstance(attempt.error, FallbackError):
35
+ lines.append(f"{prefix}:")
36
+ lines.append(format_attempts(attempt.error.attempts, indent + 1))
37
+ else:
38
+ error = attempt.error
39
+ text = str(error) if isinstance(error, ParseHubError) else f"{type(error).__name__}: {error}"
40
+ lines.append(f"{prefix}: {text}")
41
+ return "\n".join(lines)
42
+
43
+
44
+ class FallbackError(ParseError):
45
+ """所有解析方式均失败"""
46
+
47
+ MSG = __doc__
48
+
49
+ def __init__(self, attempts: list[FallbackAttempt]):
50
+ self.attempts = attempts
51
+ Exception.__init__(self, f"{self.MSG}:\n{format_attempts(attempts)}")
52
+
53
+
54
+ class ContentUnavailableFallbackError(FallbackError, ContentUnavailableError):
55
+ """作品不可用"""
56
+
57
+ MSG = __doc__
58
+
59
+
60
+ class DownloadError(ParseHubError):
61
+ """下载错误"""
62
+
63
+ MSG = __doc__
64
+
65
+
66
+ class DeleteError(ParseHubError):
67
+ """删除文件错误"""
68
+
69
+ MSG = __doc__
70
+
71
+
72
+ class UnknownPlatform(ParseHubError):
73
+ """不支持的平台"""
74
+
75
+ MSG = __doc__
@@ -2,16 +2,39 @@ import importlib
2
2
  import pkgutil
3
3
  import re
4
4
  from abc import ABC, abstractmethod
5
- from typing import Any
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any, Generic, TypeVar
6
8
  from urllib.parse import parse_qs, urlencode, urlparse
7
9
 
8
10
  import httpx
11
+ from loguru import logger
9
12
 
10
13
  from ... import parsers
11
- from ...types import AnyParseResult, ParseError
14
+ from ...types import (
15
+ AnyParseResult,
16
+ ContentUnavailableError,
17
+ ContentUnavailableFallbackError,
18
+ FallbackAttempt,
19
+ FallbackError,
20
+ ParseError,
21
+ )
12
22
  from ...types.platform import Platform
13
23
  from ...utils.helpers import UA, SecretCookie, match_url
14
24
 
25
+ T = TypeVar("T")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FallbackStage(Generic[T]): # noqa: UP046
30
+ name: str
31
+ func: Callable[[], Awaitable[T]]
32
+ cookie: bool = False
33
+
34
+
35
+ def is_content_unavailable(e: Exception) -> bool:
36
+ return isinstance(e, ContentUnavailableError)
37
+
15
38
 
16
39
  class BaseParser(ABC):
17
40
  _registry: list[type["BaseParser"]] = []
@@ -33,6 +56,7 @@ class BaseParser(ABC):
33
56
  def __init__(self, *, proxy: str | None = None, cookie: SecretCookie = SecretCookie()):
34
57
  self.proxy = proxy
35
58
  self.cookie = cookie
59
+ self._fallback_attempts: list[FallbackAttempt] = []
36
60
 
37
61
  def __init_subclass__(cls, /, register: bool = True, **kwargs: Any) -> None:
38
62
  super().__init_subclass__(**kwargs)
@@ -63,13 +87,41 @@ class BaseParser(ABC):
63
87
  :param url: 分享文案 / 分享链接
64
88
  :return: 解析结果
65
89
  """
90
+ self._fallback_attempts = []
66
91
  raw_url = await self.get_raw_url(url, clean_all=False)
67
92
  result = await self._do_parse(raw_url)
93
+ result.fallback_errors = list(self._fallback_attempts)
68
94
  result.platform = self.__platform__
69
95
  raw_url_clean = self._clean_params(raw_url, self.__after_clean_parameters__)
70
96
  result.raw_url = raw_url_clean
71
97
  return result
72
98
 
99
+ async def _fallback(
100
+ self,
101
+ *stages: FallbackStage[T],
102
+ stop: Callable[[Exception], bool] = is_content_unavailable,
103
+ ) -> T:
104
+ attempts: list[FallbackAttempt] = []
105
+ for stage in stages:
106
+ try:
107
+ result = await stage.func()
108
+ except Exception as e:
109
+ logger.opt(exception=e).debug(f"{self.__platform__} {stage.name} 解析失败")
110
+ if stop(e):
111
+ raise
112
+ attempts.append(FallbackAttempt(stage.name, e, stage.cookie))
113
+ else:
114
+ self._fallback_attempts.extend(attempts)
115
+ return result
116
+
117
+ if len(attempts) == 1:
118
+ raise attempts[0].error
119
+ last_error = attempts[-1].error
120
+ error_type = (
121
+ ContentUnavailableFallbackError if isinstance(last_error, ContentUnavailableError) else FallbackError
122
+ )
123
+ raise error_type(attempts) from last_error
124
+
73
125
  @abstractmethod
74
126
  async def _do_parse(self, raw_url: str) -> AnyParseResult:
75
127
  """解析
@@ -410,7 +410,7 @@ class YtParser(BaseParser, register=False):
410
410
  url = dl.get("webpage_url") or url
411
411
  title = dl["title"]
412
412
  duration = dl.get("duration", 0)
413
- thumbnail = dl["thumbnail"]
413
+ thumbnail = dl.get("thumbnail")
414
414
  description = dl["description"]
415
415
  width = dl.get("width", 0)
416
416
  height = dl.get("height", 0)
@@ -548,7 +548,7 @@ class YtVideoInfo:
548
548
 
549
549
  title: str
550
550
  description: str
551
- thumbnail: str
551
+ thumbnail: str | None
552
552
  url: str
553
553
  info_json: dict[str, Any]
554
554
  duration: int = 0
@@ -5,8 +5,6 @@ from pathlib import Path
5
5
  from typing import Any, cast
6
6
  from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse
7
7
 
8
- from loguru import logger
9
-
10
8
  from ...provider_api.bilibili import BiliAPI, BiliDynamic
11
9
  from ...types import (
12
10
  ContentUnavailableError,
@@ -21,7 +19,7 @@ from ...types import (
21
19
  VideoRef,
22
20
  )
23
21
  from ...utils.helpers import UA
24
- from ..base.base import BaseParser
22
+ from ..base.base import BaseParser, FallbackStage
25
23
  from ..base.ytdlp import YtParser, YtVideoParseResult
26
24
 
27
25
 
@@ -49,14 +47,11 @@ class BiliParse(BaseParser):
49
47
  photo=photos,
50
48
  )
51
49
  else:
52
- try:
53
- return await self.bili_api_parse(raw_url)
54
- except Exception as e:
55
- logger.opt(exception=e).warning("Bilibili API 解析失败, 尝试 yt-dlp 解析")
56
- try:
57
- return await self.ytp_parse(raw_url)
58
- except Exception as e:
59
- raise ParseError("Bilibili 解析失败") from e
50
+ return await self._fallback(
51
+ FallbackStage("API", lambda: self.bili_api_parse(raw_url)),
52
+ FallbackStage("yt-dlp", lambda: self.ytp_parse(raw_url)),
53
+ stop=lambda e: False,
54
+ )
60
55
 
61
56
  @staticmethod
62
57
  def _is_bvid(url: str) -> bool:
@@ -18,7 +18,7 @@ from ...types import (
18
18
  VideoParseResult,
19
19
  VideoRef,
20
20
  )
21
- from ..base.base import BaseParser
21
+ from ..base.base import BaseParser, FallbackStage
22
22
 
23
23
 
24
24
  class DouyinParser(BaseParser):
@@ -41,30 +41,22 @@ class DouyinParser(BaseParser):
41
41
  """获取并解析抖音 API 结果"""
42
42
  web_cookie = self.cookie.get_value() or {}
43
43
 
44
- web_error: ParseError | None = None
45
- if web_cookie:
46
- try:
47
- web_crawler = DouyinWebCrawler(proxy=self.proxy, cookie=web_cookie)
48
- response = await web_crawler.parse(raw_url)
49
- return DouyinApiResult.parse(response)
50
- except ContentUnavailableError:
51
- raise
52
- except ParseError as e:
53
- web_error = e
54
-
55
- try:
44
+ async def fetch_web() -> "DouyinApiResult":
45
+ web_crawler = DouyinWebCrawler(proxy=self.proxy, cookie=web_cookie)
46
+ response = await web_crawler.parse(raw_url)
47
+ return DouyinApiResult.parse(response)
48
+
49
+ async def fetch_mobile() -> "DouyinApiResult":
56
50
  mobile_device = DouyinMobileDevice.resolve()
57
51
  app_crawler = DouyinMobileCrawler(proxy=self.proxy, device=mobile_device)
58
52
  response = await app_crawler.parse(raw_url)
59
53
  return DouyinApiResult.parse(response)
60
- except ParseError as e:
61
- mobile_error = e
62
-
63
- if isinstance(mobile_error, ContentUnavailableError):
64
- raise mobile_error
65
- if web_error:
66
- raise ParseError(f"{web_error};移动端解析也失败: {mobile_error}") from mobile_error
67
- raise mobile_error
54
+
55
+ stages: list[FallbackStage[DouyinApiResult]] = []
56
+ if web_cookie:
57
+ stages.append(FallbackStage("网页端", fetch_web, cookie=True))
58
+ stages.append(FallbackStage("移动端", fetch_mobile))
59
+ return await self._fallback(*stages)
68
60
 
69
61
  @staticmethod
70
62
  def _build_video_result(result: "DouyinApiResult") -> VideoParseResult:
@@ -6,6 +6,7 @@ class FacebookParse(YtParser):
6
6
  __platform__ = Platform.FACEBOOK
7
7
  __supported_type__ = ["视频"]
8
8
  __match__ = r"^(http(s)?://)?.+facebook.com/(watch\?v|share/[v,r]|.+/videos/|reel/).*"
9
+ __reserved_parameters__ = ["v"]
9
10
 
10
11
 
11
12
  __all__ = ["FacebookParse"]
@@ -61,7 +61,7 @@ class InstagramParser(BaseParser):
61
61
  else:
62
62
  raise ParseError("受限视频无法解析: 你必须年满 18 周岁才能观看这个视频") from e
63
63
  case _:
64
- raise ParseError("无法获取帖子内容") from e
64
+ raise ParseError(f"无法获取帖子内容: {e}") from e
65
65
  except Exception as e:
66
66
  if cookie:
67
67
  text = f"Instagram 账号可能已被封禁\n\n使用的Cookie: {cookie}"
@@ -1,9 +1,7 @@
1
- from loguru import logger
2
-
3
1
  from ...provider_api.kuaishou import KuaiShouAPI, KuaishouParser
4
2
  from ...types import ImageParseResult, ImageRef, ParseError, Platform, VideoParseResult, VideoRef
5
3
  from ...utils.helpers import SecretCookie
6
- from ..base.base import BaseParser
4
+ from ..base.base import BaseParser, FallbackStage
7
5
 
8
6
  COOKIE = SecretCookie(
9
7
  "kpf=PC_WEB; clientid=3; did=web_bfbcdb2f5b3dc663a745deabafcf61e6; kwpsecproductname=kuaishou-"
@@ -35,18 +33,15 @@ class KuaiShouParser(BaseParser):
35
33
  ksp = await KuaishouParser.create(
36
34
  raw_url, proxy=self.proxy, cookie=self.cookie.get_value() or COOKIE.get_value()
37
35
  )
38
- cover = ksp.get_cover_photo_url()
39
36
  if (
40
37
  ksp.page_type == "VIDEO" or "/short-video/" in raw_url
41
38
  ): # 地区风控导致获取到的 page_type 可能为 ATLAS 但实际为视频
42
- try:
39
+ async def parse_html() -> VideoParseResult:
43
40
  content = ksp.get_title_content()
44
41
  video = ksp.get_real_video_url()
45
- except Exception as e:
46
- logger.error(f"快手 HTML 解析失败, 尝试 API 解析: {e}")
47
- else:
48
42
  if not video:
49
43
  raise ParseError("-1 快手解析失败")
44
+ cover = ksp.get_cover_photo_url()
50
45
 
51
46
  return VideoParseResult(
52
47
  title=content,
@@ -59,12 +54,9 @@ class KuaiShouParser(BaseParser):
59
54
  ),
60
55
  )
61
56
 
62
- ks = KuaiShouAPI(self.cookie.get_value() or COOKIE.get_value(), self.proxy)
63
- try:
57
+ async def parse_api() -> VideoParseResult:
58
+ ks = KuaiShouAPI(self.cookie.get_value() or COOKIE.get_value(), self.proxy)
64
59
  result = await ks.get_video_info(raw_url)
65
- except Exception as e:
66
- raise ParseError(f"-2 快手解析失败: {e}") from e
67
- else:
68
60
  return VideoParseResult(
69
61
  title=result.title,
70
62
  video=VideoRef(
@@ -75,7 +67,13 @@ class KuaiShouParser(BaseParser):
75
67
  width=result.width,
76
68
  ),
77
69
  )
70
+
71
+ return await self._fallback(
72
+ FallbackStage("HTML", parse_html),
73
+ FallbackStage("API", parse_api),
74
+ )
78
75
  else:
76
+ cover = ksp.get_cover_photo_url()
79
77
  content = ksp.get_title_content()
80
78
  img = ksp.get_image_list()
81
79
  if img:
@@ -11,7 +11,6 @@ from ...provider_api.twitter import (
11
11
  from ...types import (
12
12
  AniRef,
13
13
  AnyMediaRef,
14
- ContentUnavailableError,
15
14
  ImageRef,
16
15
  MultimediaParseResult,
17
16
  ParseError,
@@ -19,7 +18,7 @@ from ...types import (
19
18
  RichTextParseResult,
20
19
  VideoRef,
21
20
  )
22
- from ..base.base import BaseParser
21
+ from ..base.base import BaseParser, FallbackStage, is_content_unavailable
23
22
 
24
23
 
25
24
  class TwitterParser(BaseParser):
@@ -36,26 +35,26 @@ class TwitterParser(BaseParser):
36
35
  return str(urlunparse(urlparse(url)._replace(netloc="x.com")))
37
36
 
38
37
  async def _parse(self, url: str) -> TwitterTweet:
39
- x = Twitter(self.proxy, cookie=None)
40
- try:
41
- tweet = await x.fetch_tweet(url)
42
- except ContentUnavailableError:
43
- raise
44
- except Exception as e:
45
- if any(s in str(e) for s in ("error -2", "error -3")):
46
- if cookie := self.cookie.get_value():
47
- x2 = Twitter(self.proxy, cookie=cookie)
48
- try:
49
- tweet = await x2.fetch_tweet(url)
50
- except ContentUnavailableError:
51
- raise
52
- except Exception as e2:
53
- raise ParseError(f"Twitter 账号无权限或已被封禁\n\n使用的 Cookie: {self.cookie}") from e2
54
- else:
55
- raise ParseError(str(e)) from e
56
- else:
57
- raise ParseError(str(e)) from e
58
- return tweet
38
+ stages: list[FallbackStage[TwitterTweet]] = [
39
+ FallbackStage("匿名", lambda: Twitter(self.proxy, cookie=None).fetch_tweet(url))
40
+ ]
41
+ if cookie := self.cookie.get_value():
42
+
43
+ async def fetch_cookie() -> TwitterTweet:
44
+ try:
45
+ return await Twitter(self.proxy, cookie=cookie).fetch_tweet(url)
46
+ except Exception as e2:
47
+ raise ParseError(
48
+ f"Twitter 账号无权限或已被封禁: {e2}\n\n使用的 Cookie: {self.cookie}"
49
+ ) from e2
50
+
51
+ stages.append(FallbackStage("cookie", fetch_cookie, cookie=True))
52
+
53
+ return await self._fallback(
54
+ *stages,
55
+ stop=lambda e: is_content_unavailable(e)
56
+ or not any(s in str(e) for s in ("error -2", "error -3")),
57
+ )
59
58
 
60
59
  @staticmethod
61
60
  async def media_parse(tweet: TwitterTweet) -> MultimediaParseResult | RichTextParseResult:
@@ -10,6 +10,7 @@ class WXChannelsParser(BaseParser):
10
10
  r"^(http(s)?://)?(weixin\.qq\.com/sph/\w+|"
11
11
  r"channels\.weixin\.qq\.com/finder-preview/pages/sph\?.*\bid=\w+)"
12
12
  )
13
+ __redirect_keywords__ = ["weixin.qq.com/sph/"]
13
14
  __reserved_parameters__ = ["id"]
14
15
 
15
16
  async def _do_parse(self, raw_url: str) -> VideoParseResult:
@@ -3,7 +3,7 @@ from typing import Union
3
3
 
4
4
  import httpx
5
5
 
6
- from ...provider_api.xhs import XHSAPI, XHSMedia, XHSMediaType, XHSPostType
6
+ from ...provider_api.xhs import MOBILE_HEADERS, PC_HEADERS, XHSAPI, XHSMedia, XHSMediaType, XHSPost, XHSPostType
7
7
  from ...types import (
8
8
  ContentUnavailableError,
9
9
  ImageParseResult,
@@ -15,8 +15,7 @@ from ...types import (
15
15
  VideoParseResult,
16
16
  VideoRef,
17
17
  )
18
- from ...utils.helpers import UA
19
- from ..base import BaseParser
18
+ from ..base.base import BaseParser, FallbackStage
20
19
 
21
20
 
22
21
  class XHSParser(BaseParser):
@@ -24,24 +23,41 @@ class XHSParser(BaseParser):
24
23
  __supported_type__ = ["视频", "图文"]
25
24
  __match__ = r"^(http(s)?://)?.+(xiaohongshu|xhslink).(com|cn)/.+"
26
25
  __redirect_keywords__ = ["xhslink"]
27
- __after_clean_parameters__ = ["xsec_token"]
26
+ __after_clean_parameters__ = ["xsec_token", "xsec_source"]
28
27
 
29
28
  async def get_raw_url(self, url: str, *, clean_all: bool = False, headers: dict | None = None) -> str:
30
- headers = {
31
- "User-Agent": UA,
32
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,"
33
- "*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
34
- }
35
- url = await super().get_raw_url(url, clean_all=clean_all, headers=headers)
29
+ url = await super().get_raw_url(url, clean_all=clean_all, headers=MOBILE_HEADERS)
36
30
  if "/login" in url:
37
31
  raise ParseError("该帖子需要登录后查看")
32
+ elif "/404/sec" in url:
33
+ raise ParseError("被小红书风控拦截")
38
34
  elif "/404" in url:
39
35
  raise ContentUnavailableError("帖子不存在")
40
36
  return url
41
37
 
42
38
  async def _do_parse(self, raw_url: str) -> Union["VideoParseResult", "ImageParseResult", "MultimediaParseResult"]:
43
- xhs = XHSAPI(proxy=self.proxy, cookie=self.cookie.get_value())
44
- result = await xhs.extract(raw_url)
39
+ xhs = XHSAPI(proxy=self.proxy)
40
+ stages: list[FallbackStage[XHSPost]] = []
41
+ if cookie := self.cookie.get_value():
42
+
43
+ async def parse_cookie() -> XHSPost:
44
+ return await xhs.extract(raw_url, PC_HEADERS, cookie)
45
+
46
+ stages.append(FallbackStage("cookie", parse_cookie, cookie=True))
47
+
48
+ async def parse_session() -> XHSPost:
49
+ return await xhs.extract(raw_url, PC_HEADERS, await xhs.new_session())
50
+
51
+ async def parse_mobile() -> XHSPost:
52
+ return await xhs.extract(raw_url, MOBILE_HEADERS)
53
+
54
+ stages.extend(
55
+ [
56
+ FallbackStage("游客 session", parse_session),
57
+ FallbackStage("移动端", parse_mobile),
58
+ ]
59
+ )
60
+ result = await self._fallback(*stages, stop=lambda e: False)
45
61
 
46
62
  desc = self.hashtag_handler(result.desc)
47
63
  match result.type:
@@ -10,6 +10,7 @@ import random
10
10
  import re
11
11
  import time
12
12
  import uuid
13
+ from collections import Counter
13
14
  from dataclasses import dataclass
14
15
  from random import choice, randint
15
16
  from typing import Any, ClassVar, cast
@@ -1156,7 +1157,7 @@ class DouyinMobileCrawler:
1156
1157
  return self.device
1157
1158
 
1158
1159
  async def fetch_one_video(self, aweme_id: str) -> dict:
1159
- last_error = "unknown"
1160
+ errors: Counter[str] = Counter()
1160
1161
  async with httpx.AsyncClient(proxy=self.proxy, timeout=20, follow_redirects=True) as client:
1161
1162
  for _ in range(8):
1162
1163
  await self._select_device(client)
@@ -1171,10 +1172,10 @@ class DouyinMobileCrawler:
1171
1172
  response = await client.get(url, headers=headers)
1172
1173
  content = response.content
1173
1174
  except Exception as e:
1174
- last_error = str(e)
1175
+ errors[str(e)] += 1
1175
1176
  continue
1176
1177
  if not content:
1177
- last_error = f"{host} returned empty body"
1178
+ errors[f"{host} returned empty body"] += 1
1178
1179
  if not self._fixed_device:
1179
1180
  if self.device is not None:
1180
1181
  self.__class__._evict_device(self.device)
@@ -1184,18 +1185,19 @@ class DouyinMobileCrawler:
1184
1185
  try:
1185
1186
  payload = response.json()
1186
1187
  except Exception:
1187
- last_error = f"{host} returned non-json body"
1188
+ errors[f"{host} returned non-json body"] += 1
1188
1189
  continue
1189
1190
  if payload.get("aweme_detail"):
1190
1191
  await self._attach_story_default_play(payload["aweme_detail"])
1191
1192
  return cast(dict[str, Any], payload)
1192
1193
  if payload.get("filter_detail"):
1193
1194
  return cast(dict[str, Any], payload)
1194
- last_error = f"{host} missing aweme_detail: {payload.get('status_msg') or payload}"
1195
+ errors[f"{host} missing aweme_detail: {payload.get('status_msg') or payload}"] += 1
1195
1196
  if replace_device:
1196
1197
  break
1197
1198
  await asyncio.sleep(0.15)
1198
- raise ParseError(f"获取抖音作品失败: {last_error}")
1199
+ error_summary = "; ".join(f"{msg}×{count}" for msg, count in errors.most_common()) or "unknown"
1200
+ raise ParseError(f"获取抖音作品失败: {error_summary}")
1199
1201
 
1200
1202
  async def parse(self, raw_url: str) -> dict:
1201
1203
  aweme_id = await self.get_aweme_id(raw_url)
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import base64
4
5
  import json
6
+ import os
5
7
  import re
6
8
  from dataclasses import dataclass
7
9
  from enum import Enum
@@ -9,33 +11,71 @@ from typing import Any, cast
9
11
 
10
12
  import httpx
11
13
  from bs4 import BeautifulSoup
14
+ from xhshow import Xhshow
12
15
 
13
16
  from ..errors import ContentUnavailableError, ParseError
14
17
  from ..utils.helpers import UA
15
18
 
19
+ PC_HEADERS = {
20
+ "User-Agent": UA,
21
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp"
22
+ ",image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
23
+ }
24
+ # 手机 UA 配 Chrome 的 accept 会被跳到验证码页
25
+ MOBILE_HEADERS = {
26
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 "
27
+ "(KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1",
28
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
29
+ }
30
+ ACTIVATE_URI = "/api/sns/web/v1/login/activate"
31
+
16
32
 
17
33
  class XHSAPI:
18
- def __init__(self, proxy: str | None = None, cookie: dict | None = None):
34
+ def __init__(self, proxy: str | None = None):
19
35
  self.proxy = proxy
20
- self.cookie = cookie
21
-
22
- async def __fetch_html(self, url: str) -> str:
23
- async with httpx.AsyncClient(proxy=self.proxy, cookies=self.cookie, follow_redirects=True) as client:
24
- result = await client.get(
25
- url,
26
- headers={
27
- "User-Agent": UA,
28
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp"
29
- ",image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
30
- },
31
- timeout=30,
32
- )
36
+ self.xhshow = Xhshow()
37
+
38
+ async def __fetch_html(self, url: str, headers: dict[str, str], cookie: dict | None = None) -> str:
39
+ async with httpx.AsyncClient(proxy=self.proxy, cookies=cookie, follow_redirects=True) as client:
40
+ try:
41
+ result = await client.get(url, headers=headers, timeout=30)
42
+ except httpx.HTTPError as e:
43
+ raise ParseError(f"请求失败: {e!r}") from e
33
44
  if "/login" in str(result.url):
34
45
  raise ParseError("该帖子需要登录后查看")
46
+ elif "/404/sec" in str(result.url):
47
+ raise ParseError("被小红书风控拦截")
35
48
  elif "/404" in str(result.url):
36
49
  raise ContentUnavailableError("帖子不存在")
37
50
  return result.text
38
51
 
52
+ async def new_session(self) -> dict[str, str]:
53
+ """获取游客 web_session"""
54
+ a1 = self.xhshow.generate_a1()
55
+ cookies = {"a1": a1, "webId": self.xhshow.generate_web_id(a1), "xsecappid": "xhs-pc-web"}
56
+ payload = {"client_public_key_base64": base64.b64encode(os.urandom(32)).decode()}
57
+ headers = self.xhshow.sign_headers_post(ACTIVATE_URI, cookies, payload=payload)
58
+ headers.update(
59
+ {
60
+ "User-Agent": UA,
61
+ "Content-Type": "application/json;charset=UTF-8",
62
+ "Origin": "https://www.xiaohongshu.com",
63
+ "Referer": "https://www.xiaohongshu.com/",
64
+ }
65
+ )
66
+ async with httpx.AsyncClient(proxy=self.proxy, cookies=cookies, timeout=30) as client:
67
+ try:
68
+ result = await client.post(
69
+ f"https://edith.xiaohongshu.com{ACTIVATE_URI}",
70
+ headers=headers,
71
+ content=self.xhshow.build_json_body(payload),
72
+ )
73
+ except httpx.HTTPError as e:
74
+ raise ParseError(f"获取游客 session 失败: {e!r}") from e
75
+ if not (web_session := result.cookies.get("web_session")):
76
+ raise ParseError(f"获取游客 session 失败: {result.status_code} {result.text}")
77
+ return {"web_session": web_session}
78
+
39
79
  @staticmethod
40
80
  async def __extract_data(html: str) -> dict[str, Any]:
41
81
  soup = BeautifulSoup(html, "lxml")
@@ -51,10 +91,12 @@ class XHSAPI:
51
91
  return cast(dict[str, Any], data)
52
92
 
53
93
  def __parse(self, data: dict[str, Any]) -> XHSPost:
54
- if not data.get("note"):
94
+ if h5_data := data.get("noteData"):
95
+ note = (h5_data.get("data") or {}).get("noteData")
96
+ elif pc_data := data.get("note"):
97
+ note = (pc_data["noteDetailMap"].get(pc_data["firstNoteId"]) or {}).get("note")
98
+ else:
55
99
  raise ParseError("该帖子需要登录后查看")
56
- first_note_id = data["note"]["firstNoteId"]
57
- note = data["note"]["noteDetailMap"][first_note_id]["note"]
58
100
  if not note:
59
101
  raise ParseError("未获取到内容, 该帖子可能需要登录后查看")
60
102
 
@@ -86,6 +128,18 @@ class XHSAPI:
86
128
  return cast(list[dict[str, Any]], stream["h266"])
87
129
  return None
88
130
 
131
+ @staticmethod
132
+ def __image_url(image: dict[str, Any]) -> str:
133
+ return cast(str, image.get("urlDefault") or image["url"])
134
+
135
+ def __image_thumb_url(self, image: dict[str, Any]) -> str:
136
+ if url := image.get("urlPre"):
137
+ return cast(str, url)
138
+ for info in image.get("infoList") or []:
139
+ if info.get("imageScene") == "H5_PRV":
140
+ return cast(str, info["url"])
141
+ return self.__image_url(image)
142
+
89
143
  def __parse_media(self, note: dict[str, Any]) -> list[XHSMedia]:
90
144
  media_list = []
91
145
  il = note.get("imageList") or []
@@ -104,7 +158,7 @@ class XHSAPI:
104
158
  duration=stream["duration"],
105
159
  height=stream["height"],
106
160
  width=stream["width"],
107
- thumb_url=il[0]["urlDefault"],
161
+ thumb_url=self.__image_url(il[0]),
108
162
  )
109
163
  )
110
164
  else:
@@ -116,7 +170,7 @@ class XHSAPI:
116
170
  stream = selected_stream[0]
117
171
  image = XHSMedia(
118
172
  XHSMediaType.LIVE_PHOTO,
119
- thumb_url=self.get_raw_image_url(i["urlDefault"]),
173
+ thumb_url=self.get_raw_image_url(self.__image_url(i)),
120
174
  url=stream["masterUrl"],
121
175
  width=i["width"],
122
176
  height=i["height"],
@@ -124,16 +178,16 @@ class XHSAPI:
124
178
  else:
125
179
  image = XHSMedia(
126
180
  XHSMediaType.IMAGE,
127
- url=self.get_raw_image_url(i["urlDefault"]),
128
- thumb_url=i["urlPre"],
181
+ url=self.get_raw_image_url(self.__image_url(i)),
182
+ thumb_url=self.__image_thumb_url(i),
129
183
  width=i["width"],
130
184
  height=i["height"],
131
185
  )
132
186
  media_list.append(image)
133
187
  return media_list
134
188
 
135
- async def extract(self, url: str) -> XHSPost:
136
- html = await self.__fetch_html(url)
189
+ async def extract(self, url: str, headers: dict[str, str], cookie: dict | None = None) -> XHSPost:
190
+ html = await self.__fetch_html(url, headers, cookie)
137
191
  return self.__parse(await self.__extract_data(html))
138
192
 
139
193
  @staticmethod
@@ -187,6 +241,6 @@ if __name__ == "__main__":
187
241
  async def main() -> None:
188
242
  url = ""
189
243
  xhs = XHSAPI()
190
- print(await xhs.extract(url))
244
+ print(await xhs.extract(url, PC_HEADERS))
191
245
 
192
246
  asyncio.run(main())
@@ -1,4 +1,11 @@
1
- from ..errors import ContentUnavailableError, DownloadError, ParseError
1
+ from ..errors import (
2
+ ContentUnavailableError,
3
+ ContentUnavailableFallbackError,
4
+ DownloadError,
5
+ FallbackAttempt,
6
+ FallbackError,
7
+ ParseError,
8
+ )
2
9
  from .callback import ProgressCallback, ProgressUnit
3
10
  from .media_file import AniFile, AnyMediaFile, ImageFile, LivePhotoFile, MediaFile, VideoFile
4
11
  from .media_ref import AniRef, AnyMediaRef, ImageRef, LivePhotoRef, MediaRef, VideoRef
@@ -18,6 +25,9 @@ __all__ = [
18
25
  "DownloadError",
19
26
  "ParseError",
20
27
  "ContentUnavailableError",
28
+ "FallbackAttempt",
29
+ "FallbackError",
30
+ "ContentUnavailableFallbackError",
21
31
  "MediaRef",
22
32
  "AniRef",
23
33
  "VideoRef",
@@ -13,7 +13,7 @@ from markdown import markdown as md_to_html
13
13
  from slugify import slugify
14
14
 
15
15
  from ..config import GlobalConfig
16
- from ..errors import DeleteError, DownloadError
16
+ from ..errors import DeleteError, DownloadError, FallbackAttempt
17
17
  from ..utils.downloader import download
18
18
  from ..utils.helpers import run_sync
19
19
  from .callback import ProgressCallback
@@ -42,6 +42,7 @@ class ParseResult(ABC): # noqa: B024
42
42
  :param platform: 平台
43
43
  """
44
44
  self.raw_url: str = ""
45
+ self.fallback_errors: list[FallbackAttempt] = []
45
46
  self.title = title.strip()
46
47
  self.content = content.strip()
47
48
  self.media = media
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -37,6 +37,7 @@ Requires-Dist: cryptography>=49.0.0
37
37
  Requires-Dist: gmssl>=3.2.2
38
38
  Requires-Dist: signerpy>=0.12.0
39
39
  Requires-Dist: curl-cffi>=0.13
40
+ Requires-Dist: xhshow>=0.2.0
40
41
  Provides-Extra: cli
41
42
  Requires-Dist: argcomplete>=3.6.3; extra == "cli"
42
43
  Requires-Dist: platformdirs>=4.5.1; extra == "cli"
@@ -20,6 +20,7 @@ cryptography>=49.0.0
20
20
  gmssl>=3.2.2
21
21
  signerpy>=0.12.0
22
22
  curl-cffi>=0.13
23
+ xhshow>=0.2.0
23
24
 
24
25
  [cli]
25
26
  argcomplete>=3.6.3
@@ -1,35 +0,0 @@
1
- class ParseHubError(Exception):
2
- MSG = __doc__
3
-
4
- def __init__(self, msg: str = ""):
5
- super().__init__(self.MSG if not msg else f"{self.MSG}: {msg}")
6
-
7
-
8
- class ParseError(ParseHubError):
9
- """解析错误"""
10
-
11
- MSG = __doc__
12
-
13
-
14
- class ContentUnavailableError(ParseError):
15
- """作品不可用"""
16
-
17
- MSG = __doc__
18
-
19
-
20
- class DownloadError(ParseHubError):
21
- """下载错误"""
22
-
23
- MSG = __doc__
24
-
25
-
26
- class DeleteError(ParseHubError):
27
- """删除文件错误"""
28
-
29
- MSG = __doc__
30
-
31
-
32
- class UnknownPlatform(ParseHubError):
33
- """不支持的平台"""
34
-
35
- MSG = __doc__
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes