parsehub 2.3.3__tar.gz → 2.3.5__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.3/src/parsehub.egg-info → parsehub-2.3.5}/PKG-INFO +2 -1
- {parsehub-2.3.3 → parsehub-2.3.5}/pyproject.toml +2 -1
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/__init__.py +5 -1
- parsehub-2.3.5/src/parsehub/errors.py +75 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/base/base.py +54 -2
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/base/ytdlp.py +2 -2
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/bilibili.py +8 -11
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/douyin.py +13 -21
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/facebook.py +1 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/instagram.py +1 -1
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/kuaishou.py +23 -15
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/twitter.py +21 -22
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/xhs.py +28 -12
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/douyin.py +8 -6
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/kuaishou.py +10 -2
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/twitter.py +7 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/xhs.py +78 -24
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/types/__init__.py +11 -1
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/types/result.py +2 -1
- {parsehub-2.3.3 → parsehub-2.3.5/src/parsehub.egg-info}/PKG-INFO +2 -1
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub.egg-info/requires.txt +1 -0
- parsehub-2.3.3/src/parsehub/errors.py +0 -35
- {parsehub-2.3.3 → parsehub-2.3.5}/LICENSE +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/README.md +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/setup.cfg +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/__init__.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/cli.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/cli_config.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/config/__init__.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/config/config.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/__init__.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/base/__init__.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/__init__.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/bangumi.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/coolapk.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/douban.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/medium.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/pipix.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/snapchat.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/threads.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/tieba.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/tiktok.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/toutiao.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/weibo.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/weixin.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/wxchannels.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/youtube.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/zhihu.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/parsers/parser/zuiyou.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/__init__.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/bangumi.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/bilibili.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/coolapk.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/douban.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/instagram.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/medium.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/pipix.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/threads.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/tieba.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/tiktok.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/toutiao.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/weibo.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/weixin.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/wxchannels.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/xiaoheihe.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/zhihu.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/provider_api/zuiyou.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/types/callback.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/types/media_file.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/types/media_ref.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/types/platform.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/types/post.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/utils/downloader.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/utils/helpers.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub/utils/media_info.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub.egg-info/SOURCES.txt +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub.egg-info/dependency_links.txt +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub.egg-info/entry_points.txt +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/src/parsehub.egg-info/top_level.txt +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/test/test_cli.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/test/test_cli_config.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/test/test_core_offline.py +0 -0
- {parsehub-2.3.3 → parsehub-2.3.5}/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.5
|
|
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.
|
|
3
|
+
version = "2.3.5"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -33,6 +31,8 @@ class BiliParse(BaseParser):
|
|
|
33
31
|
__redirect_keywords__ = ["b23.tv", "bili2233.cn"]
|
|
34
32
|
|
|
35
33
|
async def _do_parse(self, raw_url: str) -> YtVideoParseResult | BiliVideoParseResult | ImageParseResult:
|
|
34
|
+
if urlparse(raw_url).path.startswith("/bangumi/"):
|
|
35
|
+
raise ParseError("不支持解析番剧和影视")
|
|
36
36
|
if await self.is_dynamic(raw_url):
|
|
37
37
|
dynamic = await self.get_dynamic_info(raw_url)
|
|
38
38
|
content = self.hashtag_handler(dynamic.content or "")
|
|
@@ -49,14 +49,11 @@ class BiliParse(BaseParser):
|
|
|
49
49
|
photo=photos,
|
|
50
50
|
)
|
|
51
51
|
else:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return await self.ytp_parse(raw_url)
|
|
58
|
-
except Exception as e:
|
|
59
|
-
raise ParseError("Bilibili 解析失败") from e
|
|
52
|
+
return await self._fallback(
|
|
53
|
+
FallbackStage("API", lambda: self.bili_api_parse(raw_url)),
|
|
54
|
+
FallbackStage("yt-dlp", lambda: self.ytp_parse(raw_url)),
|
|
55
|
+
stop=lambda e: False,
|
|
56
|
+
)
|
|
60
57
|
|
|
61
58
|
@staticmethod
|
|
62
59
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
@@ -61,7 +61,7 @@ class InstagramParser(BaseParser):
|
|
|
61
61
|
else:
|
|
62
62
|
raise ParseError("受限视频无法解析: 你必须年满 18 周岁才能观看这个视频") from e
|
|
63
63
|
case _:
|
|
64
|
-
raise ParseError("
|
|
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,15 @@
|
|
|
1
|
-
from loguru import logger
|
|
2
|
-
|
|
3
1
|
from ...provider_api.kuaishou import KuaiShouAPI, KuaishouParser
|
|
4
|
-
from ...types import
|
|
2
|
+
from ...types import (
|
|
3
|
+
ContentUnavailableError,
|
|
4
|
+
ImageParseResult,
|
|
5
|
+
ImageRef,
|
|
6
|
+
ParseError,
|
|
7
|
+
Platform,
|
|
8
|
+
VideoParseResult,
|
|
9
|
+
VideoRef,
|
|
10
|
+
)
|
|
5
11
|
from ...utils.helpers import SecretCookie
|
|
6
|
-
from ..base.base import BaseParser
|
|
12
|
+
from ..base.base import BaseParser, FallbackStage
|
|
7
13
|
|
|
8
14
|
COOKIE = SecretCookie(
|
|
9
15
|
"kpf=PC_WEB; clientid=3; did=web_bfbcdb2f5b3dc663a745deabafcf61e6; kwpsecproductname=kuaishou-"
|
|
@@ -28,25 +34,24 @@ COOKIE = SecretCookie(
|
|
|
28
34
|
class KuaiShouParser(BaseParser):
|
|
29
35
|
__platform__ = Platform.KUAISHOU
|
|
30
36
|
__supported_type__ = ["视频", "图文"]
|
|
31
|
-
__match__ = r"^(http(s)?://)?(www|v|live|v\.m)\.(kuaishou|chenzhongtech).com/.+"
|
|
37
|
+
__match__ = r"^(http(s)?://)?(www|v|live|v\.m|[a-z0-9]+\.m)\.(kuaishou|chenzhongtech).com/.+"
|
|
32
38
|
__redirect_keywords__ = ["v.kuaishou", "/f/"]
|
|
33
39
|
|
|
34
40
|
async def _do_parse(self, raw_url: str) -> VideoParseResult | ImageParseResult:
|
|
35
41
|
ksp = await KuaishouParser.create(
|
|
36
42
|
raw_url, proxy=self.proxy, cookie=self.cookie.get_value() or COOKIE.get_value()
|
|
37
43
|
)
|
|
38
|
-
|
|
44
|
+
if ksp.page_type == "UNKNOWN" and ksp.error_result in (223, 20113):
|
|
45
|
+
raise ContentUnavailableError(f"{ksp.error_result} {ksp.error_msg}")
|
|
39
46
|
if (
|
|
40
47
|
ksp.page_type == "VIDEO" or "/short-video/" in raw_url
|
|
41
48
|
): # 地区风控导致获取到的 page_type 可能为 ATLAS 但实际为视频
|
|
42
|
-
|
|
49
|
+
async def parse_html() -> VideoParseResult:
|
|
43
50
|
content = ksp.get_title_content()
|
|
44
51
|
video = ksp.get_real_video_url()
|
|
45
|
-
except Exception as e:
|
|
46
|
-
logger.error(f"快手 HTML 解析失败, 尝试 API 解析: {e}")
|
|
47
|
-
else:
|
|
48
52
|
if not video:
|
|
49
53
|
raise ParseError("-1 快手解析失败")
|
|
54
|
+
cover = ksp.get_cover_photo_url()
|
|
50
55
|
|
|
51
56
|
return VideoParseResult(
|
|
52
57
|
title=content,
|
|
@@ -59,12 +64,9 @@ class KuaiShouParser(BaseParser):
|
|
|
59
64
|
),
|
|
60
65
|
)
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
async def parse_api() -> VideoParseResult:
|
|
68
|
+
ks = KuaiShouAPI(self.cookie.get_value() or COOKIE.get_value(), self.proxy)
|
|
64
69
|
result = await ks.get_video_info(raw_url)
|
|
65
|
-
except Exception as e:
|
|
66
|
-
raise ParseError(f"-2 快手解析失败: {e}") from e
|
|
67
|
-
else:
|
|
68
70
|
return VideoParseResult(
|
|
69
71
|
title=result.title,
|
|
70
72
|
video=VideoRef(
|
|
@@ -75,7 +77,13 @@ class KuaiShouParser(BaseParser):
|
|
|
75
77
|
width=result.width,
|
|
76
78
|
),
|
|
77
79
|
)
|
|
80
|
+
|
|
81
|
+
return await self._fallback(
|
|
82
|
+
FallbackStage("HTML", parse_html),
|
|
83
|
+
FallbackStage("API", parse_api),
|
|
84
|
+
)
|
|
78
85
|
else:
|
|
86
|
+
cover = ksp.get_cover_photo_url()
|
|
79
87
|
content = ksp.get_title_content()
|
|
80
88
|
img = ksp.get_image_list()
|
|
81
89
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1175
|
+
errors[str(e)] += 1
|
|
1175
1176
|
continue
|
|
1176
1177
|
if not content:
|
|
1177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -212,6 +212,8 @@ class KuaishouParser:
|
|
|
212
212
|
self.page_type = "UNKNOWN"
|
|
213
213
|
self.structured_data: dict = {}
|
|
214
214
|
self.client: dict = {}
|
|
215
|
+
self.error_result: int | None = None
|
|
216
|
+
self.error_msg: str | None = None
|
|
215
217
|
|
|
216
218
|
@classmethod
|
|
217
219
|
async def create(cls, real_url, proxy: str | None = None, cookie: dict | None = None):
|
|
@@ -319,8 +321,10 @@ class KuaishouParser:
|
|
|
319
321
|
return False
|
|
320
322
|
if default_client.get("VisionVideoSetRepresentation:1"):
|
|
321
323
|
return True
|
|
322
|
-
for key in default_client.
|
|
323
|
-
if "visionVideoDetail" in key
|
|
324
|
+
for key, value in default_client.items():
|
|
325
|
+
if "visionVideoDetail" in key and isinstance(value, dict) and value.get("photo"):
|
|
326
|
+
return True
|
|
327
|
+
if "VisionVideoDetailPhoto:" in key:
|
|
324
328
|
return True
|
|
325
329
|
return False
|
|
326
330
|
|
|
@@ -333,6 +337,10 @@ class KuaishouParser:
|
|
|
333
337
|
page_type, structured_data = self._identify_and_parse_data()
|
|
334
338
|
if page_type == "UNKNOWN" or not structured_data:
|
|
335
339
|
return False
|
|
340
|
+
error_payload = self._find_nested_dict(structured_data, ("result", "error_msg"))
|
|
341
|
+
if error_payload and error_payload["result"] != 1 and error_payload["error_msg"]:
|
|
342
|
+
self.error_result = error_payload["result"]
|
|
343
|
+
self.error_msg = error_payload["error_msg"]
|
|
336
344
|
if not self._is_valid_video_state(page_type, structured_data):
|
|
337
345
|
logger.warning(f"Kuaishou route {candidate_url} returned incomplete video state, trying fallback")
|
|
338
346
|
return False
|
|
@@ -91,6 +91,13 @@ class Twitter:
|
|
|
91
91
|
if not legacy:
|
|
92
92
|
if result.get("__typename") == "TweetTombstone":
|
|
93
93
|
raise Exception("error -2: 该推文开启了限制, 匿名用户无法查看")
|
|
94
|
+
match result.get("reason"):
|
|
95
|
+
case "Suspended":
|
|
96
|
+
raise ContentUnavailableError("error -5: 这个帖子来自一个被冻结的账号")
|
|
97
|
+
case "BounceDeleted":
|
|
98
|
+
raise ContentUnavailableError("error -5: 这个帖子违反了 X 规则")
|
|
99
|
+
case "Protected":
|
|
100
|
+
raise Exception("error -3: 你无法查看这个帖子,因为该账号所有者限制了可以查看其帖子的用户")
|
|
94
101
|
raise Exception(f"error -3: {result.get('reason')}")
|
|
95
102
|
|
|
96
103
|
if article := result.get("article", {}):
|
|
@@ -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
|
|
34
|
+
def __init__(self, proxy: str | None = None):
|
|
19
35
|
self.proxy = proxy
|
|
20
|
-
self.
|
|
21
|
-
|
|
22
|
-
async def __fetch_html(self, url: str) -> str:
|
|
23
|
-
async with httpx.AsyncClient(proxy=self.proxy, cookies=
|
|
24
|
-
|
|
25
|
-
url,
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
128
|
-
thumb_url=i
|
|
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
|
|
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.
|
|
3
|
+
Version: 2.3.5
|
|
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,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
|
|
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
|