parsehub 2.2.4__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.
Files changed (81) hide show
  1. {parsehub-2.2.4/src/parsehub.egg-info → parsehub-2.3.1}/PKG-INFO +5 -1
  2. {parsehub-2.2.4 → parsehub-2.3.1}/README.md +3 -0
  3. {parsehub-2.2.4 → parsehub-2.3.1}/pyproject.toml +2 -1
  4. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/errors.py +6 -0
  5. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/__init__.py +3 -0
  6. parsehub-2.3.1/src/parsehub/parsers/parser/bangumi.py +27 -0
  7. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/douyin.py +9 -0
  8. parsehub-2.3.1/src/parsehub/parsers/parser/medium.py +24 -0
  9. parsehub-2.3.1/src/parsehub/parsers/parser/toutiao.py +69 -0
  10. parsehub-2.3.1/src/parsehub/provider_api/bangumi.py +166 -0
  11. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/douyin.py +59 -11
  12. parsehub-2.3.1/src/parsehub/provider_api/medium.py +256 -0
  13. parsehub-2.3.1/src/parsehub/provider_api/toutiao.py +180 -0
  14. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/zhihu.py +13 -6
  15. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/types/__init__.py +2 -1
  16. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/types/platform.py +3 -0
  17. {parsehub-2.2.4 → parsehub-2.3.1/src/parsehub.egg-info}/PKG-INFO +5 -1
  18. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub.egg-info/SOURCES.txt +6 -0
  19. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub.egg-info/requires.txt +1 -0
  20. {parsehub-2.2.4 → parsehub-2.3.1}/LICENSE +0 -0
  21. {parsehub-2.2.4 → parsehub-2.3.1}/setup.cfg +0 -0
  22. {parsehub-2.2.4 → parsehub-2.3.1}/src/__init__.py +0 -0
  23. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/__init__.py +0 -0
  24. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/cli.py +0 -0
  25. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/cli_config.py +0 -0
  26. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/config/__init__.py +0 -0
  27. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/config/config.py +0 -0
  28. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/__init__.py +0 -0
  29. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/base/__init__.py +0 -0
  30. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/base/base.py +0 -0
  31. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/base/ytdlp.py +0 -0
  32. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/bilibili.py +0 -0
  33. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/coolapk.py +0 -0
  34. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/douban.py +0 -0
  35. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/facebook.py +0 -0
  36. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/instagram.py +0 -0
  37. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  38. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/pipix.py +0 -0
  39. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/snapchat.py +0 -0
  40. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/threads.py +0 -0
  41. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/tieba.py +0 -0
  42. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/tiktok.py +0 -0
  43. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/twitter.py +0 -0
  44. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/weibo.py +0 -0
  45. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/weixin.py +0 -0
  46. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/xhs.py +0 -0
  47. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  48. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/youtube.py +0 -0
  49. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/zhihu.py +0 -0
  50. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  51. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/__init__.py +0 -0
  52. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/bilibili.py +0 -0
  53. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/coolapk.py +0 -0
  54. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/douban.py +0 -0
  55. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/instagram.py +0 -0
  56. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/kuaishou.py +0 -0
  57. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/pipix.py +0 -0
  58. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/threads.py +0 -0
  59. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/tieba.py +0 -0
  60. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/tiktok.py +0 -0
  61. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/twitter.py +0 -0
  62. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/weibo.py +0 -0
  63. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/weixin.py +0 -0
  64. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/xhs.py +0 -0
  65. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  66. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/provider_api/zuiyou.py +0 -0
  67. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/types/callback.py +0 -0
  68. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/types/media_file.py +0 -0
  69. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/types/media_ref.py +0 -0
  70. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/types/post.py +0 -0
  71. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/types/result.py +0 -0
  72. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/utils/downloader.py +0 -0
  73. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/utils/helpers.py +0 -0
  74. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub/utils/media_info.py +0 -0
  75. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub.egg-info/dependency_links.txt +0 -0
  76. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub.egg-info/entry_points.txt +0 -0
  77. {parsehub-2.2.4 → parsehub-2.3.1}/src/parsehub.egg-info/top_level.txt +0 -0
  78. {parsehub-2.2.4 → parsehub-2.3.1}/test/test_cli.py +0 -0
  79. {parsehub-2.2.4 → parsehub-2.3.1}/test/test_cli_config.py +0 -0
  80. {parsehub-2.2.4 → parsehub-2.3.1}/test/test_core_offline.py +0 -0
  81. {parsehub-2.2.4 → parsehub-2.3.1}/test/test_downloader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.2.4
3
+ Version: 2.3.1
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -36,6 +36,7 @@ Requires-Dist: opencv-python-headless>=4.13.0.92
36
36
  Requires-Dist: cryptography>=49.0.0
37
37
  Requires-Dist: gmssl>=3.2.2
38
38
  Requires-Dist: signerpy>=0.12.0
39
+ Requires-Dist: curl-cffi>=0.13
39
40
  Provides-Extra: cli
40
41
  Requires-Dist: argcomplete>=3.6.3; extra == "cli"
41
42
  Requires-Dist: platformdirs>=4.5.1; extra == "cli"
@@ -95,6 +96,9 @@ Dynamic: license-file
95
96
  | **Snapchat** | ✅ | | |
96
97
  | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子, 日报 |
97
98
  | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
99
+ | **Bangumi** | | ✅ | 💬 条目讨论, 日志, 小组话题 |
100
+ | **今日头条** | ✅ | ✅ | 📰 文章, 微头条 |
101
+ | **Medium** | ✅ | ✅ | 📰 文章 |
98
102
 
99
103
  ## 📦 安装
100
104
 
@@ -52,6 +52,9 @@
52
52
  | **Snapchat** | ✅ | | |
53
53
  | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子, 日报 |
54
54
  | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
55
+ | **Bangumi** | | ✅ | 💬 条目讨论, 日志, 小组话题 |
56
+ | **今日头条** | ✅ | ✅ | 📰 文章, 微头条 |
57
+ | **Medium** | ✅ | ✅ | 📰 文章 |
55
58
 
56
59
  ## 📦 安装
57
60
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "parsehub"
3
- version = "2.2.4"
3
+ version = "2.3.1"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -39,6 +39,7 @@ dependencies = [
39
39
  "cryptography>=49.0.0",
40
40
  "gmssl>=3.2.2",
41
41
  "signerpy>=0.12.0",
42
+ "curl-cffi>=0.13",
42
43
  ]
43
44
 
44
45
  [project.scripts]
@@ -11,6 +11,12 @@ class ParseError(ParseHubError):
11
11
  MSG = __doc__
12
12
 
13
13
 
14
+ class ContentUnavailableError(ParseError):
15
+ """作品不可用"""
16
+
17
+ MSG = __doc__
18
+
19
+
14
20
  class DownloadError(ParseHubError):
15
21
  """下载错误"""
16
22
 
@@ -1,12 +1,15 @@
1
1
  # ruff: noqa: F403
2
+ from .bangumi import *
2
3
  from .bilibili import *
3
4
  from .coolapk import *
4
5
  from .douban import *
5
6
  from .douyin import *
6
7
  from .facebook import *
7
8
  from .instagram import *
9
+ from .medium import *
8
10
  from .threads import *
9
11
  from .tieba import *
12
+ from .toutiao import *
10
13
  from .twitter import *
11
14
  from .weibo import *
12
15
  from .weixin import *
@@ -0,0 +1,27 @@
1
+ from ...provider_api.bangumi import Bangumi, BangumiError
2
+ from ...types import ImageRef, ParseError, Platform, RichTextParseResult
3
+ from ..base.base import BaseParser
4
+
5
+
6
+ class BangumiParser(BaseParser):
7
+ __platform__ = Platform.BANGUMI
8
+ __supported_type__ = ["条目讨论", "日志", "小组话题"]
9
+ __match__ = (
10
+ r"^(http(s)?://)?(www\.)?(bgm\.tv|bangumi\.tv|chii\.in)/"
11
+ r"(subject/topic|group/topic|rakuen/topic/(subject|group)|blog)/\d+"
12
+ )
13
+
14
+ async def _do_parse(self, raw_url: str) -> RichTextParseResult:
15
+ try:
16
+ post = await Bangumi(proxy=self.proxy).parse(raw_url)
17
+ except BangumiError as e:
18
+ raise ParseError(f"Bangumi 解析失败: {e.msg}") from e
19
+
20
+ return RichTextParseResult(
21
+ title=post.title,
22
+ media=[ImageRef(url=url) for url in post.images],
23
+ markdown_content=post.markdown_content,
24
+ )
25
+
26
+
27
+ __all__ = ["BangumiParser"]
@@ -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", "")
@@ -0,0 +1,24 @@
1
+ from ...provider_api.medium import Medium, MediumError
2
+ from ...types import ImageRef, ParseError, Platform, RichTextParseResult
3
+ from ..base.base import BaseParser
4
+
5
+
6
+ class MediumParser(BaseParser):
7
+ __platform__ = Platform.MEDIUM
8
+ __supported_type__ = ["文章"]
9
+ __match__ = r"^(http(s)?://)?([\w-]+\.)?medium\.com/(.+/)?([^/?#]*-)?[0-9a-f]{8,12}(/|\?|#|$)"
10
+
11
+ async def _do_parse(self, raw_url: str) -> RichTextParseResult:
12
+ try:
13
+ post = await Medium(proxy=self.proxy).parse(raw_url)
14
+ except MediumError as e:
15
+ raise ParseError(f"Medium 解析失败: {e}") from e
16
+
17
+ return RichTextParseResult(
18
+ title=post.title,
19
+ media=[ImageRef(url=image.url, width=image.width, height=image.height) for image in post.images],
20
+ markdown_content=post.markdown_content,
21
+ )
22
+
23
+
24
+ __all__ = ["MediumParser"]
@@ -0,0 +1,69 @@
1
+ from ...provider_api.toutiao import Toutiao, ToutiaoArticle, ToutiaoError, ToutiaoVideoPost
2
+ from ...types import (
3
+ AnyMediaRef,
4
+ ImageParseResult,
5
+ ImageRef,
6
+ ParseError,
7
+ Platform,
8
+ RichTextParseResult,
9
+ VideoParseResult,
10
+ VideoRef,
11
+ )
12
+ from ..base.base import BaseParser
13
+
14
+
15
+ class ToutiaoParser(BaseParser):
16
+ __platform__ = Platform.TOUTIAO
17
+ __supported_type__ = ["文章", "视频", "微头条"]
18
+ __match__ = (
19
+ r"^(http(s)?://)?(www|m)\.toutiao\.com/"
20
+ r"(article/\d+|video/\d+|w/\d+|group/\d+|item/\d+|a\d+|i\d+|is/\w+)"
21
+ )
22
+ __redirect_keywords__ = ["toutiao.com/is/"]
23
+
24
+ async def _do_parse(self, raw_url: str) -> ImageParseResult | RichTextParseResult | VideoParseResult:
25
+ try:
26
+ post = await Toutiao(proxy=self.proxy).parse(raw_url)
27
+ except ToutiaoError as e:
28
+ raise ParseError(f"今日头条解析失败: {e}") from e
29
+
30
+ if isinstance(post, ToutiaoVideoPost):
31
+ video = post.video
32
+ return VideoParseResult(
33
+ title=post.title,
34
+ video=VideoRef(
35
+ url=video.url,
36
+ thumb_url=video.thumb_url,
37
+ duration=video.duration,
38
+ width=video.width,
39
+ height=video.height,
40
+ ),
41
+ )
42
+ if isinstance(post, ToutiaoArticle):
43
+ media: list[AnyMediaRef] = [
44
+ ImageRef(url=image.url, width=image.width, height=image.height) for image in post.images
45
+ ]
46
+ media.extend(
47
+ VideoRef(
48
+ url=video.url,
49
+ thumb_url=video.thumb_url,
50
+ duration=video.duration,
51
+ width=video.width,
52
+ height=video.height,
53
+ )
54
+ for video in post.videos
55
+ )
56
+ return RichTextParseResult(
57
+ title=post.title,
58
+ media=media,
59
+ markdown_content=post.markdown_content,
60
+ )
61
+
62
+ return ImageParseResult(
63
+ title="",
64
+ content=post.content,
65
+ photo=[ImageRef(url=image.url, width=image.width, height=image.height) for image in post.images] or None,
66
+ )
67
+
68
+
69
+ __all__ = ["ToutiaoParser"]
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+
6
+ import httpx
7
+
8
+ from ..utils.helpers import UA
9
+
10
+ _TAG_RE = re.compile(
11
+ r"\[(/?)(img|url|b|i|s|u|mask|quote|code|user|size|color|align|left|right|center|photo)(?:=([^\]]*))?\]",
12
+ re.IGNORECASE,
13
+ )
14
+ _URL_RE = re.compile(r"/(?:(subject|group)/topic|rakuen/topic/(subject|group)|(blog))/(\d+)(?:[/?#]|$)")
15
+ _API_BASE = "https://next.bgm.tv/p1"
16
+
17
+
18
+ @dataclass
19
+ class _BBCodeNode:
20
+ tag: str
21
+ value: str | None = None
22
+ children: list[str | _BBCodeNode] = field(default_factory=list)
23
+
24
+
25
+ @dataclass
26
+ class BangumiPost:
27
+ title: str
28
+ markdown_content: str
29
+ images: list[str]
30
+
31
+
32
+ class Bangumi:
33
+ def __init__(self, proxy: str | None = None):
34
+ self.proxy = proxy
35
+
36
+ async def parse(self, url: str) -> BangumiPost:
37
+ match = _URL_RE.search(url)
38
+ if not match:
39
+ raise BangumiError("不支持的 Bangumi 链接")
40
+
41
+ kind = match.group(1) or match.group(2) or match.group(3)
42
+ topic_id = match.group(4)
43
+ assert kind is not None and topic_id is not None
44
+ async with httpx.AsyncClient(proxy=self.proxy, timeout=30, headers={"User-Agent": UA}) as client:
45
+ if kind == "blog":
46
+ blog = await self._get_json(client, f"{_API_BASE}/blogs/{topic_id}")
47
+ photos = await self._get_json(client, f"{_API_BASE}/blogs/{topic_id}/photos", params={"limit": 100})
48
+ markdown_content, images = convert_bbcode(blog.get("content") or "")
49
+ photo_urls = [
50
+ f"https://lain.bgm.tv/pic/photo/l/{photo['target'].lstrip('/')}"
51
+ for photo in photos.get("data", [])
52
+ if photo.get("target")
53
+ ]
54
+ content_images = set(images)
55
+ extra_photo_urls = list(dict.fromkeys(url for url in photo_urls if url not in content_images))
56
+ images = list(dict.fromkeys([*images, *photo_urls]))
57
+ markdown_content += "".join(f"\n\n![]({url})" for url in extra_photo_urls)
58
+ return BangumiPost(
59
+ title=blog.get("title") or "",
60
+ markdown_content=markdown_content,
61
+ images=images,
62
+ )
63
+
64
+ api_type = "subjects" if kind == "subject" else "groups"
65
+ topic = await self._get_json(client, f"{_API_BASE}/{api_type}/-/topics/{topic_id}")
66
+ replies = topic.get("replies") or []
67
+ content = replies[0].get("content", "") if replies else ""
68
+ markdown_content, images = convert_bbcode(content)
69
+ return BangumiPost(
70
+ title=topic.get("title") or "",
71
+ markdown_content=markdown_content,
72
+ images=images,
73
+ )
74
+
75
+ @staticmethod
76
+ async def _get_json(client: httpx.AsyncClient, url: str, *, params: dict[str, int] | None = None) -> dict:
77
+ response = await client.get(url, params=params)
78
+ if not response.is_success:
79
+ try:
80
+ payload = response.json()
81
+ except ValueError:
82
+ payload = {}
83
+ message = payload.get("message") if isinstance(payload, dict) else None
84
+ raise BangumiError(str(message or f"HTTP {response.status_code}"))
85
+ payload = response.json()
86
+ if not isinstance(payload, dict):
87
+ raise BangumiError(f"HTTP {response.status_code}: invalid JSON object")
88
+ return payload
89
+
90
+
91
+ def convert_bbcode(content: str) -> tuple[str, list[str]]:
92
+ content = content.replace("\r\n", "\n")
93
+
94
+ root = _BBCodeNode("")
95
+ stack = [root]
96
+ position = 0
97
+ for match in _TAG_RE.finditer(content):
98
+ stack[-1].children.append(content[position : match.start()])
99
+ closing, tag, value = match.groups()
100
+ tag = tag.lower()
101
+ if closing:
102
+ for index in range(len(stack) - 1, 0, -1):
103
+ if stack[index].tag == tag:
104
+ del stack[index:]
105
+ break
106
+ else:
107
+ node = _BBCodeNode(tag=tag, value=value)
108
+ stack[-1].children.append(node)
109
+ stack.append(node)
110
+ position = match.end()
111
+ stack[-1].children.append(content[position:])
112
+
113
+ images: list[str] = []
114
+ markdown_content = _render_nodes(root.children, images)
115
+ return markdown_content.strip(), images
116
+
117
+
118
+ def _render_nodes(nodes: list[str | _BBCodeNode], images: list[str]) -> str:
119
+ rendered: list[str] = []
120
+ for node in nodes:
121
+ if isinstance(node, str):
122
+ rendered.append(node)
123
+ continue
124
+
125
+ body = _render_nodes(node.children, images)
126
+ match node.tag:
127
+ case "img":
128
+ url = body.strip()
129
+ if url:
130
+ images.append(url)
131
+ rendered.append(f"![]({url})")
132
+ case "photo":
133
+ photo_path = body.strip().lstrip("/")
134
+ if photo_path:
135
+ url = f"https://lain.bgm.tv/pic/photo/l/{photo_path}"
136
+ images.append(url)
137
+ rendered.append(f"![]({url})")
138
+ case "url" if node.value:
139
+ rendered.append(f"[{body}]({node.value})")
140
+ case "url":
141
+ rendered.append(body)
142
+ case "b":
143
+ rendered.append(f"**{body}**")
144
+ case "i":
145
+ rendered.append(f"*{body}*")
146
+ case "s":
147
+ rendered.append(f"~~{body}~~")
148
+ case "quote":
149
+ quote = body.strip("\n")
150
+ rendered.append("\n".join(f"> {line}" for line in quote.split("\n")) if quote else "")
151
+ case "code":
152
+ rendered.append(f"```\n{body.strip('\n')}\n```")
153
+ case "user":
154
+ rendered.append(f"@{body}")
155
+ case _:
156
+ rendered.append(body)
157
+ return "".join(rendered)
158
+
159
+
160
+ class BangumiError(Exception):
161
+ def __init__(self, msg: str):
162
+ self.msg = msg
163
+ super().__init__(msg)
164
+
165
+
166
+ __all__ = ["Bangumi", "BangumiError", "BangumiPost", "convert_bbcode"]
@@ -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
- a_bogus = ABogus().get_value(params)
803
- endpoint = f"{POST_DETAIL}?{urlencode(params)}&a_bogus={quote(a_bogus, safe='')}"
804
- response = await client.get(endpoint)
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("获取抖音作品失败, 请检查 cookie") from e
812
- raise ParseError("获取抖音作品失败, 请检查 cookie")
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
- if self.__class__._device_pool:
1137
+ cls = self.__class__
1138
+ if len(cls._device_pool) >= MOBILE_DEVICE_POOL_SIZE:
1106
1139
  return
1107
- self.__class__._device_pool = await self.register_device_pool(client)
1108
- self.__class__._device_pool_index = 0
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