parsehub 2.3.4__tar.gz → 2.3.6__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.4/src/parsehub.egg-info → parsehub-2.3.6}/PKG-INFO +1 -1
- {parsehub-2.3.4 → parsehub-2.3.6}/pyproject.toml +1 -1
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/bilibili.py +5 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/kuaishou.py +12 -2
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/tiktok.py +2 -2
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/twitter.py +1 -3
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/kuaishou.py +10 -2
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/tiktok.py +68 -106
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/twitter.py +7 -0
- {parsehub-2.3.4 → parsehub-2.3.6/src/parsehub.egg-info}/PKG-INFO +1 -1
- {parsehub-2.3.4 → parsehub-2.3.6}/LICENSE +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/README.md +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/setup.cfg +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/cli.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/cli_config.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/config/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/config/config.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/errors.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/base/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/base/base.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/base/ytdlp.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/bangumi.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/coolapk.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/douban.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/douyin.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/facebook.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/instagram.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/medium.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/pipix.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/snapchat.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/threads.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/tieba.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/toutiao.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/weibo.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/weixin.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/wxchannels.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/xhs.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/youtube.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/zhihu.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/zuiyou.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/bangumi.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/bilibili.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/coolapk.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/douban.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/douyin.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/instagram.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/medium.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/pipix.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/threads.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/tieba.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/toutiao.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/weibo.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/weixin.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/wxchannels.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/xhs.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/xiaoheihe.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/zhihu.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/zuiyou.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/__init__.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/callback.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/media_file.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/media_ref.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/platform.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/post.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/result.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/utils/downloader.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/utils/helpers.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/utils/media_info.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/SOURCES.txt +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/dependency_links.txt +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/entry_points.txt +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/requires.txt +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/top_level.txt +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/test/test_cli.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/test/test_cli_config.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/test/test_core_offline.py +0 -0
- {parsehub-2.3.4 → parsehub-2.3.6}/test/test_downloader.py +0 -0
|
@@ -31,6 +31,11 @@ class BiliParse(BaseParser):
|
|
|
31
31
|
__redirect_keywords__ = ["b23.tv", "bili2233.cn"]
|
|
32
32
|
|
|
33
33
|
async def _do_parse(self, raw_url: str) -> YtVideoParseResult | BiliVideoParseResult | ImageParseResult:
|
|
34
|
+
parsed = urlparse(raw_url)
|
|
35
|
+
if parsed.hostname == "mall.bilibili.com":
|
|
36
|
+
raise ParseError("不支持解析商品")
|
|
37
|
+
if parsed.path.startswith("/bangumi/"):
|
|
38
|
+
raise ParseError("不支持解析番剧和影视")
|
|
34
39
|
if await self.is_dynamic(raw_url):
|
|
35
40
|
dynamic = await self.get_dynamic_info(raw_url)
|
|
36
41
|
content = self.hashtag_handler(dynamic.content or "")
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
from ...provider_api.kuaishou import KuaiShouAPI, KuaishouParser
|
|
2
|
-
from ...types import
|
|
2
|
+
from ...types import (
|
|
3
|
+
ContentUnavailableError,
|
|
4
|
+
ImageParseResult,
|
|
5
|
+
ImageRef,
|
|
6
|
+
ParseError,
|
|
7
|
+
Platform,
|
|
8
|
+
VideoParseResult,
|
|
9
|
+
VideoRef,
|
|
10
|
+
)
|
|
3
11
|
from ...utils.helpers import SecretCookie
|
|
4
12
|
from ..base.base import BaseParser, FallbackStage
|
|
5
13
|
|
|
@@ -26,13 +34,15 @@ COOKIE = SecretCookie(
|
|
|
26
34
|
class KuaiShouParser(BaseParser):
|
|
27
35
|
__platform__ = Platform.KUAISHOU
|
|
28
36
|
__supported_type__ = ["视频", "图文"]
|
|
29
|
-
__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/.+"
|
|
30
38
|
__redirect_keywords__ = ["v.kuaishou", "/f/"]
|
|
31
39
|
|
|
32
40
|
async def _do_parse(self, raw_url: str) -> VideoParseResult | ImageParseResult:
|
|
33
41
|
ksp = await KuaishouParser.create(
|
|
34
42
|
raw_url, proxy=self.proxy, cookie=self.cookie.get_value() or COOKIE.get_value()
|
|
35
43
|
)
|
|
44
|
+
if ksp.page_type == "UNKNOWN" and ksp.error_result in (223, 20113):
|
|
45
|
+
raise ContentUnavailableError(f"{ksp.error_result} {ksp.error_msg}")
|
|
36
46
|
if (
|
|
37
47
|
ksp.page_type == "VIDEO" or "/short-video/" in raw_url
|
|
38
48
|
): # 地区风控导致获取到的 page_type 可能为 ATLAS 但实际为视频
|
|
@@ -235,8 +235,8 @@ class TikTokApiResult:
|
|
|
235
235
|
image_list.append(
|
|
236
236
|
ImageRef(
|
|
237
237
|
url=url,
|
|
238
|
-
height=as_int(display_image.get("height") or
|
|
239
|
-
width=as_int(display_image.get("width") or
|
|
238
|
+
height=as_int(display_image.get("height") or image.get("imageHeight")),
|
|
239
|
+
width=as_int(display_image.get("width") or image.get("imageWidth")),
|
|
240
240
|
)
|
|
241
241
|
)
|
|
242
242
|
|
|
@@ -44,9 +44,7 @@ class TwitterParser(BaseParser):
|
|
|
44
44
|
try:
|
|
45
45
|
return await Twitter(self.proxy, cookie=cookie).fetch_tweet(url)
|
|
46
46
|
except Exception as e2:
|
|
47
|
-
raise ParseError(
|
|
48
|
-
f"Twitter 账号无权限或已被封禁: {e2}\n\n使用的 Cookie: {self.cookie}"
|
|
49
|
-
) from e2
|
|
47
|
+
raise ParseError(f"{e2}\n\n使用的 Cookie: {self.cookie}") from e2
|
|
50
48
|
|
|
51
49
|
stages.append(FallbackStage("cookie", fetch_cookie, cookie=True))
|
|
52
50
|
|
|
@@ -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
|
|
@@ -1,28 +1,24 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
2
4
|
import html
|
|
3
5
|
import json
|
|
4
6
|
import re
|
|
5
7
|
from typing import Any, NamedTuple, cast
|
|
6
|
-
from urllib.parse import
|
|
8
|
+
from urllib.parse import urlparse
|
|
7
9
|
|
|
8
10
|
import httpx
|
|
9
11
|
|
|
12
|
+
from ..errors import ContentUnavailableError
|
|
10
13
|
from ..utils.helpers import UA
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
TIKTOK_ITEM_PAGE = "https://www.tiktok.com/@_/video/{aweme_id}"
|
|
13
16
|
|
|
14
|
-
FACEBOOK_EXTERNAL_HIT_UA = "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
|
|
15
17
|
UNIVERSAL_DATA_RE = re.compile(
|
|
16
18
|
r'<script[^>]+id=["\']__UNIVERSAL_DATA_FOR_REHYDRATION__["\'][^>]*>(?P<json>.*?)</script>',
|
|
17
19
|
re.DOTALL,
|
|
18
20
|
)
|
|
19
21
|
|
|
20
|
-
TIKTOK_HEADERS = {
|
|
21
|
-
"User-Agent": UA,
|
|
22
|
-
"Referer": "https://www.tiktok.com/",
|
|
23
|
-
"x-ladon": "Hello From Evil0ctal!",
|
|
24
|
-
}
|
|
25
|
-
|
|
26
22
|
TIKTOK_WEB_HEADERS = {
|
|
27
23
|
"User-Agent": UA,
|
|
28
24
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
@@ -48,7 +44,7 @@ class TikTokWebCrawler:
|
|
|
48
44
|
max_retries: int = 3,
|
|
49
45
|
timeout: int = 15,
|
|
50
46
|
):
|
|
51
|
-
self.headers = dict(
|
|
47
|
+
self.headers = dict(TIKTOK_WEB_HEADERS)
|
|
52
48
|
if user_agent:
|
|
53
49
|
self.headers["User-Agent"] = user_agent
|
|
54
50
|
self.cookies = httpx.Cookies()
|
|
@@ -58,30 +54,16 @@ class TikTokWebCrawler:
|
|
|
58
54
|
self.max_retries = max_retries
|
|
59
55
|
self.timeout = timeout
|
|
60
56
|
|
|
61
|
-
async def parse(self, url: str) -> dict:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
item_ref = self.extract_item_ref_from_url(resolved_url)
|
|
68
|
-
if not item_ref:
|
|
69
|
-
raise ValueError(f"无法从链接中提取作品 ID: {resolved_url}")
|
|
70
|
-
return await self.fetch_one_video(item_ref.aweme_id)
|
|
71
|
-
except Exception as exc:
|
|
72
|
-
primary_error = exc
|
|
73
|
-
|
|
74
|
-
if item_ref and item_ref.media_type == "photo":
|
|
75
|
-
raise RuntimeError(f"获取 TikTok 图文作品失败: {primary_error}") from primary_error
|
|
76
|
-
|
|
77
|
-
try:
|
|
78
|
-
return await self.fetch_video_from_web(url, expected_aweme_id=item_ref.aweme_id if item_ref else None)
|
|
79
|
-
except Exception as web_error:
|
|
80
|
-
raise RuntimeError(f"获取 TikTok 作品失败: feed={primary_error}; web={web_error}") from web_error
|
|
57
|
+
async def parse(self, url: str) -> dict[str, Any]:
|
|
58
|
+
resolved_url = await self.resolve_url(url)
|
|
59
|
+
item_ref = self.extract_item_ref_from_url(resolved_url)
|
|
60
|
+
if not item_ref:
|
|
61
|
+
raise ValueError(f"无法从链接中提取作品 ID: {resolved_url}")
|
|
62
|
+
return await self.fetch_item(item_ref.aweme_id)
|
|
81
63
|
|
|
82
|
-
def _client(self
|
|
64
|
+
def _client(self) -> httpx.AsyncClient:
|
|
83
65
|
return httpx.AsyncClient(
|
|
84
|
-
headers=
|
|
66
|
+
headers=self.headers,
|
|
85
67
|
timeout=self.timeout,
|
|
86
68
|
follow_redirects=True,
|
|
87
69
|
proxy=self.proxy,
|
|
@@ -120,85 +102,24 @@ class TikTokWebCrawler:
|
|
|
120
102
|
raise ValueError("TikTok 页面不可用,可能是地区、代理或链接问题")
|
|
121
103
|
return resolved
|
|
122
104
|
|
|
123
|
-
async def
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return url
|
|
127
|
-
|
|
128
|
-
headers = dict(TIKTOK_WEB_HEADERS)
|
|
129
|
-
headers["User-Agent"] = FACEBOOK_EXTERNAL_HIT_UA
|
|
130
|
-
async with self._client(headers=headers) as client:
|
|
131
|
-
response = await client.head(url)
|
|
132
|
-
if response.status_code >= 400:
|
|
133
|
-
response = await client.get(url)
|
|
134
|
-
response.raise_for_status()
|
|
135
|
-
return str(response.url)
|
|
136
|
-
|
|
137
|
-
async def fetch_one_video(self, aweme_id: str) -> dict[str, Any]:
|
|
138
|
-
params = {
|
|
139
|
-
"iid": 7318518857994389254,
|
|
140
|
-
"device_id": 7318517321748022790,
|
|
141
|
-
"channel": "googleplay",
|
|
142
|
-
"app_name": "musical_ly",
|
|
143
|
-
"version_code": "300904",
|
|
144
|
-
"device_platform": "android",
|
|
145
|
-
"device_type": "SM-ASUS_Z01QD",
|
|
146
|
-
"os_version": "9",
|
|
147
|
-
"aweme_id": aweme_id,
|
|
148
|
-
}
|
|
149
|
-
endpoint = f"{TIKTOK_APP_FEED}?{urlencode(params)}"
|
|
150
|
-
last_error: Exception | None = None
|
|
151
|
-
|
|
152
|
-
for attempt in range(self.max_retries):
|
|
153
|
-
try:
|
|
154
|
-
async with self._client() as client:
|
|
155
|
-
response = await client.get(endpoint)
|
|
156
|
-
response.raise_for_status()
|
|
157
|
-
payload = response.json()
|
|
158
|
-
|
|
159
|
-
aweme_list = payload.get("aweme_list") or []
|
|
160
|
-
for item in aweme_list:
|
|
161
|
-
if str(item.get("aweme_id")) == str(aweme_id):
|
|
162
|
-
return cast(dict[str, Any], item)
|
|
163
|
-
|
|
164
|
-
if aweme_list:
|
|
165
|
-
first_id = aweme_list[0].get("aweme_id")
|
|
166
|
-
raise RuntimeError(f"返回作品 ID 不匹配: expected={aweme_id}, got={first_id}")
|
|
167
|
-
|
|
168
|
-
status_msg = payload.get("status_msg") or payload.get("statusMessage") or payload
|
|
169
|
-
raise RuntimeError(f"接口未返回 aweme_list: {status_msg}")
|
|
170
|
-
except Exception as exc:
|
|
171
|
-
last_error = exc
|
|
172
|
-
if attempt + 1 < self.max_retries:
|
|
173
|
-
await asyncio.sleep(1)
|
|
174
|
-
|
|
175
|
-
raise RuntimeError(f"获取 TikTok 作品失败: {last_error}")
|
|
176
|
-
|
|
177
|
-
async def fetch_video_from_web(self, url_or_text: str, expected_aweme_id: str | None = None) -> dict[str, Any]:
|
|
178
|
-
url = await self.resolve_web_url(url_or_text)
|
|
179
|
-
item_ref = self.extract_item_ref_from_url(url)
|
|
180
|
-
if not item_ref:
|
|
181
|
-
raise ValueError(f"无法从链接中提取作品 ID: {url}")
|
|
182
|
-
if item_ref.media_type == "photo":
|
|
183
|
-
raise ValueError("TikTok 图文作品不支持 Web hydration fallback")
|
|
184
|
-
|
|
185
|
-
webpage = await self.download_webpage(url)
|
|
105
|
+
async def fetch_item(self, aweme_id: str) -> dict[str, Any]:
|
|
106
|
+
# 图文也要请求 /video/ 页面, /photo/ 页面里没有 webapp.video-detail
|
|
107
|
+
webpage = await self.download_webpage(TIKTOK_ITEM_PAGE.format(aweme_id=aweme_id))
|
|
186
108
|
universal_data = self._search_universal_data(webpage)
|
|
187
109
|
if not universal_data:
|
|
188
110
|
raise RuntimeError("无法从页面提取 __UNIVERSAL_DATA_FOR_REHYDRATION__")
|
|
189
111
|
|
|
190
112
|
item = self._extract_web_item(universal_data)
|
|
191
113
|
item_id = str(item.get("aweme_id") or item.get("id") or "")
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
raise RuntimeError(f"返回作品 ID 不匹配: expected={expected_id}, got={item_id}")
|
|
114
|
+
if item_id and item_id != aweme_id:
|
|
115
|
+
raise RuntimeError(f"返回作品 ID 不匹配: expected={aweme_id}, got={item_id}")
|
|
195
116
|
if item_id and not item.get("aweme_id"):
|
|
196
117
|
item["aweme_id"] = item_id
|
|
197
118
|
return item
|
|
198
119
|
|
|
199
120
|
async def download_webpage(self, url: str) -> str:
|
|
200
|
-
async with self._client(
|
|
201
|
-
|
|
121
|
+
async with self._client() as client:
|
|
122
|
+
webpage = ""
|
|
202
123
|
for attempt in range(self.max_retries):
|
|
203
124
|
response = await client.get(url)
|
|
204
125
|
if urlparse(str(response.url)).path == "/login":
|
|
@@ -207,10 +128,51 @@ class TikTokWebCrawler:
|
|
|
207
128
|
webpage = response.text
|
|
208
129
|
if self._search_universal_data(webpage):
|
|
209
130
|
return webpage
|
|
210
|
-
|
|
131
|
+
if self._solve_challenge(client, webpage):
|
|
132
|
+
continue
|
|
211
133
|
if attempt + 1 < self.max_retries:
|
|
212
134
|
await asyncio.sleep(1)
|
|
213
|
-
return
|
|
135
|
+
return webpage
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _html_class(webpage: str, element_id: str) -> str | None:
|
|
139
|
+
match = re.search(rf'<[^>]+\bid="{element_id}"[^>]*>', webpage)
|
|
140
|
+
if not match:
|
|
141
|
+
return None
|
|
142
|
+
class_match = re.search(r'\bclass="([^"]*)"', match.group(0))
|
|
143
|
+
return class_match.group(1) if class_match else None
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def _solve_challenge(cls, client: httpx.AsyncClient, webpage: str) -> bool:
|
|
147
|
+
"""解 WAF 的 sha256 计算挑战并写入 cookie, 做法同 yt-dlp"""
|
|
148
|
+
encoded = cls._html_class(webpage, "cs")
|
|
149
|
+
cookie_name = cls._html_class(webpage, "wci")
|
|
150
|
+
if not encoded or not cookie_name:
|
|
151
|
+
return False
|
|
152
|
+
try:
|
|
153
|
+
challenge = json.loads(base64.b64decode(f"{encoded}==="))
|
|
154
|
+
expected_digest = base64.b64decode(challenge["v"]["c"])
|
|
155
|
+
base_hash = hashlib.sha256(base64.b64decode(challenge["v"]["a"]))
|
|
156
|
+
except (ValueError, KeyError, TypeError):
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
for i in range(1_000_001):
|
|
160
|
+
number = str(i).encode()
|
|
161
|
+
test_hash = base_hash.copy()
|
|
162
|
+
test_hash.update(number)
|
|
163
|
+
if test_hash.digest() == expected_digest:
|
|
164
|
+
challenge["d"] = base64.b64encode(number).decode()
|
|
165
|
+
break
|
|
166
|
+
else:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
cookie_value = base64.b64encode(json.dumps(challenge, separators=(",", ":")).encode()).decode()
|
|
170
|
+
client.cookies.set(cookie_name, cookie_value, domain=".tiktok.com")
|
|
171
|
+
rci_name = cls._html_class(webpage, "rci")
|
|
172
|
+
rci_value = cls._html_class(webpage, "rs")
|
|
173
|
+
if rci_name and rci_value:
|
|
174
|
+
client.cookies.set(rci_name, rci_value, domain=".tiktok.com")
|
|
175
|
+
return True
|
|
214
176
|
|
|
215
177
|
@staticmethod
|
|
216
178
|
def _search_universal_data(webpage: str) -> dict[str, Any]:
|
|
@@ -224,7 +186,7 @@ class TikTokWebCrawler:
|
|
|
224
186
|
data = json.loads(raw)
|
|
225
187
|
except json.JSONDecodeError:
|
|
226
188
|
return {}
|
|
227
|
-
return data.get("__DEFAULT_SCOPE__") or {}
|
|
189
|
+
return cast(dict[str, Any], data.get("__DEFAULT_SCOPE__") or {})
|
|
228
190
|
|
|
229
191
|
@staticmethod
|
|
230
192
|
def _extract_web_item(universal_data: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -236,10 +198,10 @@ class TikTokWebCrawler:
|
|
|
236
198
|
status = 0
|
|
237
199
|
item = ((detail.get("itemInfo") or {}).get("itemStruct")) or {}
|
|
238
200
|
if item:
|
|
239
|
-
return item
|
|
201
|
+
return cast(dict[str, Any], item)
|
|
240
202
|
if status in (10216, 10222):
|
|
241
203
|
raise RuntimeError("这个 TikTok 内容需要登录或无权访问")
|
|
242
|
-
if status == 10204:
|
|
243
|
-
raise RuntimeError("当前 IP 被 TikTok 阻止访问这个内容")
|
|
244
204
|
status_msg = detail.get("statusMsg") or detail.get("statusMessage") or status
|
|
205
|
+
if status == 10204:
|
|
206
|
+
raise ContentUnavailableError(f"TikTok 作品不存在或不可见: {status_msg}")
|
|
245
207
|
raise RuntimeError(f"页面中没有作品详情,status={status_msg}")
|
|
@@ -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", {}):
|
|
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
|