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.
Files changed (83) hide show
  1. {parsehub-2.3.4/src/parsehub.egg-info → parsehub-2.3.6}/PKG-INFO +1 -1
  2. {parsehub-2.3.4 → parsehub-2.3.6}/pyproject.toml +1 -1
  3. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/bilibili.py +5 -0
  4. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/kuaishou.py +12 -2
  5. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/tiktok.py +2 -2
  6. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/twitter.py +1 -3
  7. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/kuaishou.py +10 -2
  8. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/tiktok.py +68 -106
  9. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/twitter.py +7 -0
  10. {parsehub-2.3.4 → parsehub-2.3.6/src/parsehub.egg-info}/PKG-INFO +1 -1
  11. {parsehub-2.3.4 → parsehub-2.3.6}/LICENSE +0 -0
  12. {parsehub-2.3.4 → parsehub-2.3.6}/README.md +0 -0
  13. {parsehub-2.3.4 → parsehub-2.3.6}/setup.cfg +0 -0
  14. {parsehub-2.3.4 → parsehub-2.3.6}/src/__init__.py +0 -0
  15. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/__init__.py +0 -0
  16. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/cli.py +0 -0
  17. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/cli_config.py +0 -0
  18. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/config/__init__.py +0 -0
  19. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/config/config.py +0 -0
  20. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/errors.py +0 -0
  21. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/__init__.py +0 -0
  22. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/base/__init__.py +0 -0
  23. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/base/base.py +0 -0
  24. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/base/ytdlp.py +0 -0
  25. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/__init__.py +0 -0
  26. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/bangumi.py +0 -0
  27. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/coolapk.py +0 -0
  28. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/douban.py +0 -0
  29. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/douyin.py +0 -0
  30. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/facebook.py +0 -0
  31. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/instagram.py +0 -0
  32. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/medium.py +0 -0
  33. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/pipix.py +0 -0
  34. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/snapchat.py +0 -0
  35. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/threads.py +0 -0
  36. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/tieba.py +0 -0
  37. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/toutiao.py +0 -0
  38. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/weibo.py +0 -0
  39. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/weixin.py +0 -0
  40. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/wxchannels.py +0 -0
  41. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/xhs.py +0 -0
  42. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  43. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/youtube.py +0 -0
  44. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/zhihu.py +0 -0
  45. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  46. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/__init__.py +0 -0
  47. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/bangumi.py +0 -0
  48. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/bilibili.py +0 -0
  49. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/coolapk.py +0 -0
  50. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/douban.py +0 -0
  51. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/douyin.py +0 -0
  52. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/instagram.py +0 -0
  53. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/medium.py +0 -0
  54. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/pipix.py +0 -0
  55. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/threads.py +0 -0
  56. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/tieba.py +0 -0
  57. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/toutiao.py +0 -0
  58. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/weibo.py +0 -0
  59. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/weixin.py +0 -0
  60. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/wxchannels.py +0 -0
  61. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/xhs.py +0 -0
  62. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  63. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/zhihu.py +0 -0
  64. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/provider_api/zuiyou.py +0 -0
  65. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/__init__.py +0 -0
  66. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/callback.py +0 -0
  67. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/media_file.py +0 -0
  68. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/media_ref.py +0 -0
  69. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/platform.py +0 -0
  70. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/post.py +0 -0
  71. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/types/result.py +0 -0
  72. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/utils/downloader.py +0 -0
  73. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/utils/helpers.py +0 -0
  74. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub/utils/media_info.py +0 -0
  75. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/SOURCES.txt +0 -0
  76. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/dependency_links.txt +0 -0
  77. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/entry_points.txt +0 -0
  78. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/requires.txt +0 -0
  79. {parsehub-2.3.4 → parsehub-2.3.6}/src/parsehub.egg-info/top_level.txt +0 -0
  80. {parsehub-2.3.4 → parsehub-2.3.6}/test/test_cli.py +0 -0
  81. {parsehub-2.3.4 → parsehub-2.3.6}/test/test_cli_config.py +0 -0
  82. {parsehub-2.3.4 → parsehub-2.3.6}/test/test_core_offline.py +0 -0
  83. {parsehub-2.3.4 → parsehub-2.3.6}/test/test_downloader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.4
3
+ Version: 2.3.6
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "parsehub"
3
- version = "2.3.4"
3
+ version = "2.3.6"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.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 ImageParseResult, ImageRef, ParseError, Platform, VideoParseResult, VideoRef
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 display_image.get("Height")),
239
- width=as_int(display_image.get("width") or display_image.get("Width")),
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.keys():
323
- if "visionVideoDetail" in key or "VisionVideoDetailPhoto:" 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 urlencode, urlparse
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
- TIKTOK_APP_FEED = "https://api22-normal-c-alisg.tiktokv.com/aweme/v1/feed/"
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(TIKTOK_HEADERS)
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
- item_ref = None
63
- primary_error: Exception | None = None
64
-
65
- try:
66
- resolved_url = await self.resolve_url(url)
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, *, headers: dict[str, str] | None = None) -> httpx.AsyncClient:
64
+ def _client(self) -> httpx.AsyncClient:
83
65
  return httpx.AsyncClient(
84
- headers=headers or self.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 resolve_web_url(self, url_or_text: str) -> str:
124
- url = self.extract_url(url_or_text)
125
- if self.extract_item_ref_from_url(url):
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
- expected_id = str(expected_aweme_id or item_ref.aweme_id)
193
- if item_id and item_id != expected_id:
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(headers=TIKTOK_WEB_HEADERS) as client:
201
- last_webpage = ""
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
- last_webpage = webpage
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 last_webpage
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", {}):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.4
3
+ Version: 2.3.6
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes