parsehub 2.3.0__tar.gz → 2.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {parsehub-2.3.0/src/parsehub.egg-info → parsehub-2.3.1}/PKG-INFO +1 -1
- {parsehub-2.3.0 → parsehub-2.3.1}/pyproject.toml +1 -1
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/errors.py +6 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/douyin.py +9 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/douyin.py +59 -11
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/types/__init__.py +2 -1
- {parsehub-2.3.0 → parsehub-2.3.1/src/parsehub.egg-info}/PKG-INFO +1 -1
- {parsehub-2.3.0 → parsehub-2.3.1}/LICENSE +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/README.md +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/setup.cfg +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/__init__.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/__init__.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/cli.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/cli_config.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/config/__init__.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/config/config.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/__init__.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/base/__init__.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/base/base.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/base/ytdlp.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/__init__.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/bangumi.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/bilibili.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/coolapk.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/douban.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/facebook.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/instagram.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/kuaishou.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/medium.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/pipix.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/snapchat.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/threads.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/tieba.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/tiktok.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/toutiao.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/twitter.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/weibo.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/weixin.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/xhs.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/youtube.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/zhihu.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/parsers/parser/zuiyou.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/__init__.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/bangumi.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/bilibili.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/coolapk.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/douban.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/instagram.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/kuaishou.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/medium.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/pipix.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/threads.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/tieba.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/tiktok.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/toutiao.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/twitter.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/weibo.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/weixin.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/xhs.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/xiaoheihe.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/zhihu.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/provider_api/zuiyou.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/types/callback.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/types/media_file.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/types/media_ref.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/types/platform.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/types/post.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/types/result.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/utils/downloader.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/utils/helpers.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub/utils/media_info.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub.egg-info/SOURCES.txt +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub.egg-info/dependency_links.txt +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub.egg-info/entry_points.txt +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub.egg-info/requires.txt +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/src/parsehub.egg-info/top_level.txt +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/test/test_cli.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/test/test_cli_config.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/test/test_core_offline.py +0 -0
- {parsehub-2.3.0 → parsehub-2.3.1}/test/test_downloader.py +0 -0
|
@@ -6,6 +6,7 @@ from typing import Self, Union
|
|
|
6
6
|
from ... import ProgressCallback
|
|
7
7
|
from ...provider_api.douyin import DouyinMobileCrawler, DouyinMobileDevice, DouyinWebCrawler
|
|
8
8
|
from ...types import (
|
|
9
|
+
ContentUnavailableError,
|
|
9
10
|
DownloadResult,
|
|
10
11
|
ImageParseResult,
|
|
11
12
|
ImageRef,
|
|
@@ -46,6 +47,8 @@ class DouyinParser(BaseParser):
|
|
|
46
47
|
web_crawler = DouyinWebCrawler(proxy=self.proxy, cookie=web_cookie)
|
|
47
48
|
response = await web_crawler.parse(raw_url)
|
|
48
49
|
return DouyinApiResult.parse(response)
|
|
50
|
+
except ContentUnavailableError:
|
|
51
|
+
raise
|
|
49
52
|
except ParseError as e:
|
|
50
53
|
web_error = e
|
|
51
54
|
|
|
@@ -57,6 +60,8 @@ class DouyinParser(BaseParser):
|
|
|
57
60
|
except ParseError as e:
|
|
58
61
|
mobile_error = e
|
|
59
62
|
|
|
63
|
+
if isinstance(mobile_error, ContentUnavailableError):
|
|
64
|
+
raise mobile_error
|
|
60
65
|
if web_error:
|
|
61
66
|
raise ParseError(f"{web_error};移动端解析也失败: {mobile_error}") from mobile_error
|
|
62
67
|
raise mobile_error
|
|
@@ -188,6 +193,10 @@ class DouyinApiResult:
|
|
|
188
193
|
def parse(cls, json_dict: dict) -> Self:
|
|
189
194
|
data = json_dict.get("aweme_detail")
|
|
190
195
|
if not data:
|
|
196
|
+
filter_detail = json_dict.get("filter_detail") or {}
|
|
197
|
+
if reason := filter_detail.get("filter_reason"):
|
|
198
|
+
detail_msg = filter_detail.get("detail_msg") or filter_detail.get("notice") or ""
|
|
199
|
+
raise ContentUnavailableError(f"抖音 {reason} {detail_msg}".strip())
|
|
191
200
|
raise ParseError("抖音解析失败: 未获取到作品详情")
|
|
192
201
|
|
|
193
202
|
desc = data.get("desc", "")
|
|
@@ -27,6 +27,8 @@ DEFAULT_USER_AGENT = (
|
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
POST_DETAIL = "https://www.douyin.com/aweme/v1/web/aweme/detail/"
|
|
30
|
+
WEB_SIGN_SALT = "A96D855A08C0A9707F8BEF0D9A527E4E"
|
|
31
|
+
WEB_SIGN_UIFID_COOKIES = ("uifid", "uifid_temp", "uifidtemp", "UIFID", "UIFID_TEMP", "UIFIDTEMP")
|
|
30
32
|
MOBILE_BROWSER_USER_AGENT = (
|
|
31
33
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) "
|
|
32
34
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1"
|
|
@@ -754,6 +756,24 @@ class DouyinWebCrawler:
|
|
|
754
756
|
"Referer": "https://www.douyin.com/",
|
|
755
757
|
}
|
|
756
758
|
|
|
759
|
+
def _signed_query(self, params: dict[str, str]) -> tuple[str, dict[str, str]]:
|
|
760
|
+
uifid = next((v for name in WEB_SIGN_UIFID_COOKIES if (v := self.cookie.get(name))), None)
|
|
761
|
+
if not uifid:
|
|
762
|
+
raise ParseError("抖音 cookie 缺少 UIFID_TEMP, 无法生成网页签名")
|
|
763
|
+
pairs = [*params.items(), ("a_bogus", ABogus().get_value(params))]
|
|
764
|
+
if verify_fp := self.cookie.get("s_v_web_id"):
|
|
765
|
+
pairs += [("verifyFp", verify_fp), ("fp", verify_fp)]
|
|
766
|
+
timestamp = str(int(time.time()))
|
|
767
|
+
pairs += [("uifid", uifid), ("timestamp", timestamp)]
|
|
768
|
+
query = "&".join(f"{quote(k, safe='*-._')}={quote(str(v), safe='*-._')}" for k, v in pairs)
|
|
769
|
+
signature = hashlib.md5(f"{uifid}_{timestamp}_{WEB_SIGN_SALT}_{query}".encode()).hexdigest()
|
|
770
|
+
headers = {
|
|
771
|
+
"uifid": uifid,
|
|
772
|
+
"x-secsdk-web-signature": signature,
|
|
773
|
+
"x-secsdk-web-expire": timestamp,
|
|
774
|
+
}
|
|
775
|
+
return f"{query}&x-secsdk-web-signature={signature}", headers
|
|
776
|
+
|
|
757
777
|
@staticmethod
|
|
758
778
|
async def get_aweme_id(raw_url: str) -> str:
|
|
759
779
|
for pattern in [
|
|
@@ -799,17 +819,23 @@ class DouyinWebCrawler:
|
|
|
799
819
|
}
|
|
800
820
|
for attempt in range(3):
|
|
801
821
|
try:
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
response.raise_for_status()
|
|
806
|
-
return cast(dict[str, Any], response.json())
|
|
807
|
-
except Exception as e:
|
|
822
|
+
query, sign_headers = self._signed_query(params)
|
|
823
|
+
response = await client.get(f"{POST_DETAIL}?{query}", headers=sign_headers)
|
|
824
|
+
except httpx.HTTPError as e:
|
|
808
825
|
if attempt + 1 < 3:
|
|
809
826
|
await asyncio.sleep(1)
|
|
810
827
|
else:
|
|
811
|
-
raise ParseError("
|
|
812
|
-
|
|
828
|
+
raise ParseError(f"抖音网页端请求失败: {e}") from e
|
|
829
|
+
continue
|
|
830
|
+
if response.status_code != 200:
|
|
831
|
+
raise ParseError(f"抖音网页端请求被拒绝: HTTP {response.status_code} {response.text[:80]}")
|
|
832
|
+
if not response.content:
|
|
833
|
+
raise ParseError("抖音网页端返回空响应, cookie 可能已被限流")
|
|
834
|
+
try:
|
|
835
|
+
return cast(dict[str, Any], response.json())
|
|
836
|
+
except (ValueError, UnicodeDecodeError) as e:
|
|
837
|
+
raise ParseError("抖音网页端返回非 JSON 响应") from e
|
|
838
|
+
raise ParseError("抖音网页端请求失败: 未知错误")
|
|
813
839
|
|
|
814
840
|
async def parse(self, raw_url: str) -> dict:
|
|
815
841
|
aweme_id = await self.get_aweme_id(raw_url)
|
|
@@ -1101,11 +1127,23 @@ class DouyinMobileCrawler:
|
|
|
1101
1127
|
cls._device_pool_index = (cls._device_pool_index + 1) % len(cls._device_pool)
|
|
1102
1128
|
return device
|
|
1103
1129
|
|
|
1130
|
+
@classmethod
|
|
1131
|
+
def _evict_device(cls, device: DouyinMobileDevice) -> None:
|
|
1132
|
+
if device in cls._device_pool:
|
|
1133
|
+
cls._device_pool.remove(device)
|
|
1134
|
+
cls._device_pool_index = cls._device_pool_index % len(cls._device_pool) if cls._device_pool else 0
|
|
1135
|
+
|
|
1104
1136
|
async def _ensure_device_pool(self, client: httpx.AsyncClient) -> None:
|
|
1105
|
-
|
|
1137
|
+
cls = self.__class__
|
|
1138
|
+
if len(cls._device_pool) >= MOBILE_DEVICE_POOL_SIZE:
|
|
1106
1139
|
return
|
|
1107
|
-
|
|
1108
|
-
self.
|
|
1140
|
+
missing = MOBILE_DEVICE_POOL_SIZE - len(cls._device_pool)
|
|
1141
|
+
new_devices = await self.register_device_pool(client, count=missing)
|
|
1142
|
+
current_pool = cls._device_pool
|
|
1143
|
+
cls._device_pool = (current_pool + [device for device in new_devices if device not in current_pool])[
|
|
1144
|
+
:MOBILE_DEVICE_POOL_SIZE
|
|
1145
|
+
]
|
|
1146
|
+
cls._device_pool_index = cls._device_pool_index % len(cls._device_pool) if cls._device_pool else 0
|
|
1109
1147
|
|
|
1110
1148
|
async def _select_device(self, client: httpx.AsyncClient) -> DouyinMobileDevice:
|
|
1111
1149
|
if self._fixed_device:
|
|
@@ -1124,6 +1162,7 @@ class DouyinMobileCrawler:
|
|
|
1124
1162
|
await self._select_device(client)
|
|
1125
1163
|
params = self._mobile_query(aweme_id)
|
|
1126
1164
|
query = urlencode(params)
|
|
1165
|
+
replace_device = False
|
|
1127
1166
|
for profile in MOBILE_SIGN_PROFILES:
|
|
1128
1167
|
headers = self._signed_headers(params, profile)
|
|
1129
1168
|
for host in MOBILE_DETAIL_HOSTS:
|
|
@@ -1136,6 +1175,11 @@ class DouyinMobileCrawler:
|
|
|
1136
1175
|
continue
|
|
1137
1176
|
if not content:
|
|
1138
1177
|
last_error = f"{host} returned empty body"
|
|
1178
|
+
if not self._fixed_device:
|
|
1179
|
+
if self.device is not None:
|
|
1180
|
+
self.__class__._evict_device(self.device)
|
|
1181
|
+
replace_device = True
|
|
1182
|
+
break
|
|
1139
1183
|
continue
|
|
1140
1184
|
try:
|
|
1141
1185
|
payload = response.json()
|
|
@@ -1145,7 +1189,11 @@ class DouyinMobileCrawler:
|
|
|
1145
1189
|
if payload.get("aweme_detail"):
|
|
1146
1190
|
await self._attach_story_default_play(payload["aweme_detail"])
|
|
1147
1191
|
return cast(dict[str, Any], payload)
|
|
1192
|
+
if payload.get("filter_detail"):
|
|
1193
|
+
return cast(dict[str, Any], payload)
|
|
1148
1194
|
last_error = f"{host} missing aweme_detail: {payload.get('status_msg') or payload}"
|
|
1195
|
+
if replace_device:
|
|
1196
|
+
break
|
|
1149
1197
|
await asyncio.sleep(0.15)
|
|
1150
1198
|
raise ParseError(f"获取抖音作品失败: {last_error}")
|
|
1151
1199
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from ..errors import DownloadError, ParseError
|
|
1
|
+
from ..errors import ContentUnavailableError, DownloadError, ParseError
|
|
2
2
|
from .callback import ProgressCallback, ProgressUnit
|
|
3
3
|
from .media_file import AniFile, AnyMediaFile, ImageFile, LivePhotoFile, MediaFile, VideoFile
|
|
4
4
|
from .media_ref import AniRef, AnyMediaRef, ImageRef, LivePhotoRef, MediaRef, VideoRef
|
|
@@ -17,6 +17,7 @@ from .result import (
|
|
|
17
17
|
__all__ = [
|
|
18
18
|
"DownloadError",
|
|
19
19
|
"ParseError",
|
|
20
|
+
"ContentUnavailableError",
|
|
20
21
|
"MediaRef",
|
|
21
22
|
"AniRef",
|
|
22
23
|
"VideoRef",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|