parsehub 2.2.3__tar.gz → 2.3.0__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.3/src/parsehub.egg-info → parsehub-2.3.0}/PKG-INFO +5 -1
  2. {parsehub-2.2.3 → parsehub-2.3.0}/README.md +3 -0
  3. {parsehub-2.2.3 → parsehub-2.3.0}/pyproject.toml +2 -1
  4. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/__init__.py +3 -0
  5. parsehub-2.3.0/src/parsehub/parsers/parser/bangumi.py +27 -0
  6. parsehub-2.3.0/src/parsehub/parsers/parser/medium.py +24 -0
  7. parsehub-2.3.0/src/parsehub/parsers/parser/toutiao.py +69 -0
  8. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/xhs.py +6 -0
  9. parsehub-2.3.0/src/parsehub/provider_api/bangumi.py +166 -0
  10. parsehub-2.3.0/src/parsehub/provider_api/medium.py +256 -0
  11. parsehub-2.3.0/src/parsehub/provider_api/toutiao.py +180 -0
  12. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/xhs.py +6 -3
  13. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/zhihu.py +13 -6
  14. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/platform.py +3 -0
  15. {parsehub-2.2.3 → parsehub-2.3.0/src/parsehub.egg-info}/PKG-INFO +5 -1
  16. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/SOURCES.txt +6 -0
  17. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/requires.txt +1 -0
  18. {parsehub-2.2.3 → parsehub-2.3.0}/LICENSE +0 -0
  19. {parsehub-2.2.3 → parsehub-2.3.0}/setup.cfg +0 -0
  20. {parsehub-2.2.3 → parsehub-2.3.0}/src/__init__.py +0 -0
  21. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/__init__.py +0 -0
  22. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/cli.py +0 -0
  23. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/cli_config.py +0 -0
  24. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/config/__init__.py +0 -0
  25. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/config/config.py +0 -0
  26. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/errors.py +0 -0
  27. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/__init__.py +0 -0
  28. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/base/__init__.py +0 -0
  29. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/base/base.py +0 -0
  30. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/base/ytdlp.py +0 -0
  31. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/bilibili.py +0 -0
  32. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/coolapk.py +0 -0
  33. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/douban.py +0 -0
  34. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/douyin.py +0 -0
  35. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/facebook.py +0 -0
  36. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/instagram.py +0 -0
  37. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  38. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/pipix.py +0 -0
  39. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/snapchat.py +0 -0
  40. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/threads.py +0 -0
  41. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/tieba.py +0 -0
  42. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/tiktok.py +0 -0
  43. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/twitter.py +0 -0
  44. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/weibo.py +0 -0
  45. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/weixin.py +0 -0
  46. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  47. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/youtube.py +0 -0
  48. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/zhihu.py +0 -0
  49. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  50. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/__init__.py +0 -0
  51. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/bilibili.py +0 -0
  52. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/coolapk.py +0 -0
  53. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/douban.py +0 -0
  54. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/douyin.py +0 -0
  55. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/instagram.py +0 -0
  56. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/kuaishou.py +0 -0
  57. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/pipix.py +0 -0
  58. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/threads.py +0 -0
  59. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/tieba.py +0 -0
  60. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/tiktok.py +0 -0
  61. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/twitter.py +0 -0
  62. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/weibo.py +0 -0
  63. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/weixin.py +0 -0
  64. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  65. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/zuiyou.py +0 -0
  66. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/__init__.py +0 -0
  67. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/callback.py +0 -0
  68. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/media_file.py +0 -0
  69. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/media_ref.py +0 -0
  70. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/post.py +0 -0
  71. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/result.py +0 -0
  72. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/utils/downloader.py +0 -0
  73. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/utils/helpers.py +0 -0
  74. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/utils/media_info.py +0 -0
  75. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/dependency_links.txt +0 -0
  76. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/entry_points.txt +0 -0
  77. {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/top_level.txt +0 -0
  78. {parsehub-2.2.3 → parsehub-2.3.0}/test/test_cli.py +0 -0
  79. {parsehub-2.2.3 → parsehub-2.3.0}/test/test_cli_config.py +0 -0
  80. {parsehub-2.2.3 → parsehub-2.3.0}/test/test_core_offline.py +0 -0
  81. {parsehub-2.2.3 → parsehub-2.3.0}/test/test_downloader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.2.3
3
+ Version: 2.3.0
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.3"
3
+ version = "2.3.0"
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]
@@ -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"]
@@ -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"]
@@ -14,6 +14,7 @@ from ...types import (
14
14
  VideoParseResult,
15
15
  VideoRef,
16
16
  )
17
+ from ...utils.helpers import UA
17
18
  from ..base import BaseParser
18
19
 
19
20
 
@@ -25,6 +26,11 @@ class XHSParser(BaseParser):
25
26
  __after_clean_parameters__ = ["xsec_token"]
26
27
 
27
28
  async def get_raw_url(self, url: str, *, clean_all: bool = False, headers: dict | None = None) -> str:
29
+ headers = {
30
+ "User-Agent": UA,
31
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,"
32
+ "*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
33
+ }
28
34
  url = await super().get_raw_url(url, clean_all=clean_all, headers=headers)
29
35
  if "/login" in url:
30
36
  raise ParseError("该帖子需要登录后查看")
@@ -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"]
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ from curl_cffi import requests
9
+
10
+ _ID_RE = re.compile(r"(?:^|-)([0-9a-f]{8,12})$")
11
+ _GRAPHQL_URL = "https://medium.com/_/graphql"
12
+ _GRAPHQL_QUERY = """
13
+ query P($id: ID!) {
14
+ post(id: $id) {
15
+ title
16
+ isLocked
17
+ content {
18
+ bodyModel {
19
+ paragraphs {
20
+ type
21
+ text
22
+ href
23
+ markups {
24
+ type
25
+ start
26
+ end
27
+ href
28
+ }
29
+ metadata {
30
+ id
31
+ originalWidth
32
+ originalHeight
33
+ }
34
+ codeBlockMetadata {
35
+ lang
36
+ }
37
+ iframe {
38
+ mediaResource {
39
+ href
40
+ iframeSrc
41
+ title
42
+ }
43
+ }
44
+ mixtapeMetadata {
45
+ href
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ """
53
+
54
+
55
+ @dataclass
56
+ class MediumImage:
57
+ url: str
58
+ width: int = 0
59
+ height: int = 0
60
+
61
+
62
+ @dataclass
63
+ class MediumPost:
64
+ title: str
65
+ markdown_content: str
66
+ images: list[MediumImage]
67
+
68
+
69
+ class Medium:
70
+ def __init__(self, proxy: str | None = None):
71
+ self.proxy = proxy
72
+
73
+ async def parse(self, url: str) -> MediumPost:
74
+ path = urlparse(url).path.rstrip("/")
75
+ path_segment = path.rsplit("/", 1)[-1]
76
+ match = _ID_RE.search(path_segment)
77
+ if not match:
78
+ raise MediumError("不支持的 Medium 链接")
79
+ post_id = match.group(1)
80
+
81
+ async with requests.AsyncSession(impersonate="chrome", proxy=self.proxy, timeout=30) as session:
82
+ response = await session.post(
83
+ _GRAPHQL_URL,
84
+ json=[
85
+ {
86
+ "operationName": "P",
87
+ "variables": {"id": post_id},
88
+ "query": _GRAPHQL_QUERY,
89
+ }
90
+ ],
91
+ )
92
+ if not 200 <= response.status_code < 300:
93
+ raise MediumError(f"HTTP {response.status_code}")
94
+
95
+ result = response.json()[0]
96
+ errors = result.get("errors")
97
+ if errors:
98
+ raise MediumError(errors[0]["message"])
99
+
100
+ post = result["data"]["post"]
101
+ if post is None:
102
+ raise MediumError("内容不存在或已删除")
103
+
104
+ paragraphs = ((post.get("content") or {}).get("bodyModel") or {}).get("paragraphs") or []
105
+ markdown_content, images = _render_paragraphs(paragraphs, post.get("title") or "")
106
+ return MediumPost(
107
+ title=post.get("title") or "",
108
+ markdown_content=markdown_content,
109
+ images=images,
110
+ )
111
+
112
+
113
+ def _render_paragraphs(paragraphs: list[dict[str, Any]], title: str) -> tuple[str, list[MediumImage]]:
114
+ images: list[MediumImage] = []
115
+ rendered_paragraphs: list[tuple[str, str]] = []
116
+ ordered_count = 0
117
+
118
+ for index, paragraph in enumerate(paragraphs):
119
+ paragraph_type = paragraph.get("type") or ""
120
+ text = paragraph.get("text") or ""
121
+ if index == 0 and paragraph_type in {"H2", "H3", "H4"} and text.strip() == title.strip():
122
+ continue
123
+
124
+ content = _render_paragraph(paragraph, images)
125
+ if content is None or not content.strip():
126
+ continue
127
+
128
+ if paragraph_type == "ULI":
129
+ content = f"- {content}"
130
+ ordered_count = 0
131
+ elif paragraph_type == "OLI":
132
+ ordered_count = ordered_count + 1 if rendered_paragraphs and rendered_paragraphs[-1][0] == "OLI" else 1
133
+ content = f"{ordered_count}. {content}"
134
+ else:
135
+ ordered_count = 0
136
+
137
+ rendered_paragraphs.append((paragraph_type, content))
138
+
139
+ markdown_parts: list[str] = []
140
+ previous_type = ""
141
+ for paragraph_type, content in rendered_paragraphs:
142
+ if markdown_parts:
143
+ is_consecutive_list = paragraph_type in {"ULI", "OLI"} and previous_type in {"ULI", "OLI"}
144
+ markdown_parts.append("\n" if is_consecutive_list else "\n\n")
145
+ markdown_parts.append(content)
146
+ previous_type = paragraph_type
147
+
148
+ return "".join(markdown_parts).strip(), images
149
+
150
+
151
+ def _render_paragraph(paragraph: dict[str, Any], images: list[MediumImage]) -> str | None:
152
+ paragraph_type = paragraph.get("type") or ""
153
+ text = paragraph.get("text") or ""
154
+ if paragraph_type == "IMG":
155
+ metadata = paragraph.get("metadata") or {}
156
+ image_id = metadata.get("id")
157
+ if not image_id:
158
+ return None
159
+ image = MediumImage(
160
+ url=f"https://miro.medium.com/v2/{image_id}",
161
+ width=int(metadata.get("originalWidth") or 0),
162
+ height=int(metadata.get("originalHeight") or 0),
163
+ )
164
+ images.append(image)
165
+ markdown = f"![]({image.url})"
166
+ caption = text.strip()
167
+ if caption:
168
+ markdown += f"\n*{caption}*"
169
+ return markdown
170
+
171
+ if paragraph_type == "PRE":
172
+ language = (paragraph.get("codeBlockMetadata") or {}).get("lang") or ""
173
+ return f"```{language}\n{text}\n```"
174
+
175
+ if paragraph_type == "IFRAME":
176
+ media_resource = (paragraph.get("iframe") or {}).get("mediaResource") or {}
177
+ href = media_resource.get("href") or media_resource.get("iframeSrc")
178
+ if not href:
179
+ return None
180
+ return f"[{media_resource.get('title') or href}]({href})"
181
+
182
+ if paragraph_type == "MIXTAPE_EMBED":
183
+ href = (paragraph.get("mixtapeMetadata") or {}).get("href")
184
+ linked_text = _render_markup(text, paragraph.get("markups") or [])
185
+ return f"[{linked_text}]({href})" if href else linked_text
186
+
187
+ rendered_text = _render_markup(text, paragraph.get("markups") or [])
188
+ match paragraph_type:
189
+ case "H2":
190
+ return f"## {rendered_text}"
191
+ case "H3":
192
+ return f"### {rendered_text}"
193
+ case "H4":
194
+ return f"#### {rendered_text}"
195
+ case "BQ" | "PQ":
196
+ return "\n".join(f"> {line}" for line in rendered_text.split("\n"))
197
+ case _:
198
+ return rendered_text
199
+
200
+
201
+ def _render_markup(text: str, markups: list[dict[str, Any]]) -> str:
202
+ encoded_text = text.encode("utf-16-le")
203
+ text_length = len(encoded_text) // 2
204
+ openings: dict[int, list[tuple[int, int, str]]] = {}
205
+ closings: dict[int, list[tuple[int, int, str]]] = {}
206
+
207
+ for index, markup in enumerate(markups):
208
+ markup_type = markup.get("type")
209
+ match markup_type:
210
+ case "STRONG":
211
+ opening, closing = "**", "**"
212
+ case "EM":
213
+ opening, closing = "*", "*"
214
+ case "CODE":
215
+ opening, closing = "`", "`"
216
+ case "A" if markup.get("href"):
217
+ opening, closing = "[", f"]({markup['href']})"
218
+ case _:
219
+ continue
220
+
221
+ start = max(0, min(int(markup.get("start") or 0), text_length))
222
+ end = max(0, min(int(markup.get("end") or 0), text_length))
223
+ if start >= end:
224
+ continue
225
+
226
+ selected_text = encoded_text[start * 2 : end * 2].decode("utf-16-le")
227
+ left_trim = selected_text[: len(selected_text) - len(selected_text.lstrip())]
228
+ right_trim = selected_text[len(selected_text.rstrip()) :]
229
+ start += len(left_trim.encode("utf-16-le")) // 2
230
+ end -= len(right_trim.encode("utf-16-le")) // 2
231
+ if start >= end:
232
+ continue
233
+
234
+ openings.setdefault(start, []).append((end, index, opening))
235
+ closings.setdefault(end, []).append((start, index, closing))
236
+
237
+ rendered: list[str] = []
238
+ position = 0
239
+ for boundary in sorted(openings.keys() | closings.keys()):
240
+ rendered.append(encoded_text[position * 2 : boundary * 2].decode("utf-16-le"))
241
+ rendered.extend(
242
+ closing for _, _, closing in sorted(closings.get(boundary, []), key=lambda item: (-item[0], -item[1]))
243
+ )
244
+ rendered.extend(
245
+ opening for _, _, opening in sorted(openings.get(boundary, []), key=lambda item: (-item[0], item[1]))
246
+ )
247
+ position = boundary
248
+ rendered.append(encoded_text[position * 2 :].decode("utf-16-le"))
249
+ return "".join(rendered)
250
+
251
+
252
+ class MediumError(Exception):
253
+ pass
254
+
255
+
256
+ __all__ = ["Medium", "MediumError", "MediumImage", "MediumPost"]
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from bs4 import BeautifulSoup
11
+ from markdownify import MarkdownConverter
12
+
13
+ from ..utils.helpers import UA
14
+
15
+ _ID_RE = re.compile(r"toutiao\.com/(?:(?:article|video|w|group|item)/|a|i)(\d+)")
16
+ _INFO_API = "https://m.toutiao.com/i{}/info/v2/"
17
+ _VOD_API = "https://vod.bytedanceapi.com/?{}"
18
+
19
+
20
+ @dataclass
21
+ class ToutiaoImage:
22
+ url: str
23
+ width: int = 0
24
+ height: int = 0
25
+
26
+
27
+ @dataclass
28
+ class ToutiaoVideo:
29
+ url: str
30
+ width: int = 0
31
+ height: int = 0
32
+ duration: int = 0
33
+ thumb_url: str | None = None
34
+
35
+
36
+ @dataclass
37
+ class ToutiaoArticle:
38
+ title: str
39
+ markdown_content: str
40
+ images: list[ToutiaoImage]
41
+ videos: list[ToutiaoVideo]
42
+
43
+
44
+ @dataclass
45
+ class ToutiaoVideoPost:
46
+ title: str
47
+ video: ToutiaoVideo
48
+
49
+
50
+ @dataclass
51
+ class ToutiaoMicroPost:
52
+ content: str
53
+ images: list[ToutiaoImage]
54
+
55
+
56
+ class Toutiao:
57
+ def __init__(self, proxy: str | None = None):
58
+ self.proxy = proxy
59
+
60
+ async def parse(self, url: str) -> ToutiaoArticle | ToutiaoVideoPost | ToutiaoMicroPost:
61
+ match = _ID_RE.search(url)
62
+ if not match:
63
+ raise ToutiaoError("不支持的今日头条链接")
64
+
65
+ async with httpx.AsyncClient(proxy=self.proxy, timeout=30, headers={"User-Agent": UA}) as client:
66
+ response = await client.get(_INFO_API.format(match.group(1)))
67
+ response.raise_for_status()
68
+ data = response.json().get("data")
69
+ if data is None:
70
+ raise ToutiaoError("内容不存在或已删除")
71
+
72
+ if "thread" in data:
73
+ return self._parse_micro_post(data["thread"])
74
+ if data.get("biz_tag") == "图文":
75
+ return await self._parse_article(client, data)
76
+ if data.get("play_auth_token_v2"):
77
+ video = await self._parse_video(
78
+ client,
79
+ data["play_auth_token_v2"],
80
+ duration=int(data.get("video_duration") or 0),
81
+ thumb_url=data.get("poster_url"),
82
+ )
83
+ return ToutiaoVideoPost(title=data.get("title") or "", video=video)
84
+ return await self._parse_article(client, data)
85
+
86
+ async def _parse_article(self, client: httpx.AsyncClient, data: dict[str, Any]) -> ToutiaoArticle:
87
+ soup = BeautifulSoup(data.get("content") or "", "lxml")
88
+ videos = []
89
+ for video_box in soup.find_all("div", class_="tt-video-box"):
90
+ token = str(video_box.get("data-token") or "")
91
+ videos.append(await self._parse_video(client, token))
92
+ video_box.decompose()
93
+
94
+ converter = _ToutiaoMarkdownConverter()
95
+ markdown_content = converter.convert(str(soup)).strip()
96
+ return ToutiaoArticle(
97
+ title=data.get("title") or "",
98
+ markdown_content=markdown_content,
99
+ images=[ToutiaoImage(url=url) for url in converter.images],
100
+ videos=videos,
101
+ )
102
+
103
+ @staticmethod
104
+ def _parse_micro_post(thread: dict[str, Any]) -> ToutiaoMicroPost:
105
+ thread_base = thread.get("thread_base") or {}
106
+ images = [
107
+ ToutiaoImage(
108
+ url=image["url"],
109
+ width=int(image.get("width") or 0),
110
+ height=int(image.get("height") or 0),
111
+ )
112
+ for image in thread_base.get("large_image_list", [])
113
+ if image.get("url")
114
+ ]
115
+ return ToutiaoMicroPost(content=(thread_base.get("content") or "").strip(), images=images)
116
+
117
+ async def _parse_video(
118
+ self,
119
+ client: httpx.AsyncClient,
120
+ token: str,
121
+ *,
122
+ duration: int = 0,
123
+ thumb_url: str | None = None,
124
+ ) -> ToutiaoVideo:
125
+ token_data = json.loads(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)))
126
+ play_info_token = token_data["GetPlayInfoToken"]
127
+ response = await client.get(_VOD_API.format(play_info_token))
128
+ response.raise_for_status()
129
+ payload = response.json()
130
+
131
+ error = (payload.get("ResponseMetadata") or {}).get("Error")
132
+ if error:
133
+ if isinstance(error, dict):
134
+ message = error.get("Message") or error.get("message") or error.get("ErrorMessage")
135
+ else:
136
+ message = error
137
+ raise ToutiaoError(str(message or error))
138
+
139
+ play_infos = payload["Result"]["Data"]["PlayInfoList"]
140
+ best = max(
141
+ play_infos,
142
+ key=lambda item: (int(item.get("Height") or 0), int(item.get("Size") or 0)),
143
+ )
144
+ return ToutiaoVideo(
145
+ url=best["MainPlayUrl"],
146
+ width=int(best.get("Width") or 0),
147
+ height=int(best.get("Height") or 0),
148
+ duration=duration or int(best.get("Duration") or 0),
149
+ thumb_url=thumb_url,
150
+ )
151
+
152
+
153
+ class _ToutiaoMarkdownConverter(MarkdownConverter):
154
+ def __init__(self) -> None:
155
+ super().__init__(heading_style="ATX")
156
+ self.images: list[str] = []
157
+
158
+ def convert_img(self, el: Any, text: Any, parent_tags: Any) -> str:
159
+ alt = el.attrs.get("alt") or ""
160
+ src = el.attrs.get("src") or ""
161
+ title = el.attrs.get("title") or ""
162
+ if src:
163
+ self.images.append(src)
164
+ title_part = ' "{}"'.format(title.replace('"', r"\"")) if title else ""
165
+ return f"![{alt}]({src}{title_part})"
166
+
167
+
168
+ class ToutiaoError(Exception):
169
+ pass
170
+
171
+
172
+ __all__ = [
173
+ "Toutiao",
174
+ "ToutiaoArticle",
175
+ "ToutiaoError",
176
+ "ToutiaoImage",
177
+ "ToutiaoMicroPost",
178
+ "ToutiaoVideo",
179
+ "ToutiaoVideoPost",
180
+ ]
@@ -10,9 +10,8 @@ from typing import Any, cast
10
10
  import httpx
11
11
  from bs4 import BeautifulSoup
12
12
 
13
- from parsehub.utils.helpers import UA
14
-
15
13
  from ..errors import ParseError
14
+ from ..utils.helpers import UA
16
15
 
17
16
 
18
17
  class XHSAPI:
@@ -24,7 +23,11 @@ class XHSAPI:
24
23
  async with httpx.AsyncClient(proxy=self.proxy, cookies=self.cookie, follow_redirects=True) as client:
25
24
  result = await client.get(
26
25
  url,
27
- headers={"User-Agent": UA},
26
+ headers={
27
+ "User-Agent": UA,
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
+ },
28
31
  timeout=30,
29
32
  )
30
33
  if "/login" in str(result.url):
@@ -172,6 +172,13 @@ class ZhihuAPI:
172
172
  self.proxy = proxy
173
173
  self.cookie = cookie
174
174
 
175
+ @staticmethod
176
+ def _json(r: httpx.Response) -> dict:
177
+ data = dict(r.json())
178
+ if err := data.get("error"):
179
+ raise ValueError(f"知乎接口错误 {err.get('code')}: {err.get('message')}")
180
+ return data
181
+
175
182
  @property
176
183
  def d_c0(self) -> str:
177
184
  v = self.cookie.get("d_c0")
@@ -258,7 +265,7 @@ class ZhihuAPI:
258
265
 
259
266
  async with httpx.AsyncClient() as client:
260
267
  r = await client.get(url, headers=headers, params=query, cookies=self.cookie)
261
- return dict(r.json())
268
+ return self._json(r)
262
269
 
263
270
  async def _questions_answers(self, question_id: int | str) -> dict:
264
271
  """获取问题的回答"""
@@ -274,7 +281,7 @@ class ZhihuAPI:
274
281
 
275
282
  async with httpx.AsyncClient() as client:
276
283
  r = await client.get(url, headers=headers, params=query, cookies=self.cookie)
277
- return dict(r.json())
284
+ return self._json(r)
278
285
 
279
286
  async def _answers(self, answers_id: int | str) -> dict:
280
287
  """获取问题的指定回答"""
@@ -287,7 +294,7 @@ class ZhihuAPI:
287
294
 
288
295
  async with httpx.AsyncClient() as client:
289
296
  r = await client.get(url, headers=headers, params=query, cookies=self.cookie)
290
- return dict(r.json())
297
+ return self._json(r)
291
298
 
292
299
  async def _zl(self, zl_id: int | str) -> dict:
293
300
  url = f"https://zhuanlan.zhihu.com/api/articles/{zl_id}"
@@ -297,7 +304,7 @@ class ZhihuAPI:
297
304
 
298
305
  async with httpx.AsyncClient() as client:
299
306
  r = await client.get(url, headers=headers, params=query, cookies=self.cookie)
300
- return dict(r.json())
307
+ return self._json(r)
301
308
 
302
309
  async def _pin(self, pin_id: int | str) -> dict:
303
310
  url = f"https://www.zhihu.com/api/v4/pins/{pin_id}"
@@ -307,13 +314,13 @@ class ZhihuAPI:
307
314
 
308
315
  async with httpx.AsyncClient() as client:
309
316
  r = await client.get(url, headers=headers, params=query, cookies=self.cookie)
310
- return dict(r.json())
317
+ return self._json(r)
311
318
 
312
319
  async def _daily(self, story_id: int | str) -> dict:
313
320
  url = f"https://daily.zhihu.com/api/7/story/{story_id}"
314
321
  async with httpx.AsyncClient(proxy=self.proxy) as client:
315
322
  r = await client.get(url)
316
- return dict(r.json())
323
+ return self._json(r)
317
324
 
318
325
  @staticmethod
319
326
  def _get_qa_id(raw_url: str) -> tuple[str, str | None]:
@@ -4,11 +4,14 @@ from enum import Enum
4
4
  class Platform(Enum):
5
5
  """支持的平台"""
6
6
 
7
+ BANGUMI = ("bangumi", "Bangumi")
7
8
  BILIBILI = ("bilibili", "Bilibili")
8
9
  COOLAPK = ("coolapk", "酷安")
9
10
  DOUBAN = ("douban", "豆瓣")
10
11
  DOUYIN = ("douyin", "抖音")
11
12
  TIKTOK = ("tiktok", "TikTok")
13
+ TOUTIAO = ("toutiao", "今日头条")
14
+ MEDIUM = ("medium", "Medium")
12
15
  FACEBOOK = ("facebook", "Facebook")
13
16
  INSTAGRAM = ("instagram", "Instagram")
14
17
  KUAISHOU = ("kuaishou", "快手")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.2.3
3
+ Version: 2.3.0
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
 
@@ -19,6 +19,7 @@ src/parsehub/parsers/base/__init__.py
19
19
  src/parsehub/parsers/base/base.py
20
20
  src/parsehub/parsers/base/ytdlp.py
21
21
  src/parsehub/parsers/parser/__init__.py
22
+ src/parsehub/parsers/parser/bangumi.py
22
23
  src/parsehub/parsers/parser/bilibili.py
23
24
  src/parsehub/parsers/parser/coolapk.py
24
25
  src/parsehub/parsers/parser/douban.py
@@ -26,11 +27,13 @@ src/parsehub/parsers/parser/douyin.py
26
27
  src/parsehub/parsers/parser/facebook.py
27
28
  src/parsehub/parsers/parser/instagram.py
28
29
  src/parsehub/parsers/parser/kuaishou.py
30
+ src/parsehub/parsers/parser/medium.py
29
31
  src/parsehub/parsers/parser/pipix.py
30
32
  src/parsehub/parsers/parser/snapchat.py
31
33
  src/parsehub/parsers/parser/threads.py
32
34
  src/parsehub/parsers/parser/tieba.py
33
35
  src/parsehub/parsers/parser/tiktok.py
36
+ src/parsehub/parsers/parser/toutiao.py
34
37
  src/parsehub/parsers/parser/twitter.py
35
38
  src/parsehub/parsers/parser/weibo.py
36
39
  src/parsehub/parsers/parser/weixin.py
@@ -40,16 +43,19 @@ src/parsehub/parsers/parser/youtube.py
40
43
  src/parsehub/parsers/parser/zhihu.py
41
44
  src/parsehub/parsers/parser/zuiyou.py
42
45
  src/parsehub/provider_api/__init__.py
46
+ src/parsehub/provider_api/bangumi.py
43
47
  src/parsehub/provider_api/bilibili.py
44
48
  src/parsehub/provider_api/coolapk.py
45
49
  src/parsehub/provider_api/douban.py
46
50
  src/parsehub/provider_api/douyin.py
47
51
  src/parsehub/provider_api/instagram.py
48
52
  src/parsehub/provider_api/kuaishou.py
53
+ src/parsehub/provider_api/medium.py
49
54
  src/parsehub/provider_api/pipix.py
50
55
  src/parsehub/provider_api/threads.py
51
56
  src/parsehub/provider_api/tieba.py
52
57
  src/parsehub/provider_api/tiktok.py
58
+ src/parsehub/provider_api/toutiao.py
53
59
  src/parsehub/provider_api/twitter.py
54
60
  src/parsehub/provider_api/weibo.py
55
61
  src/parsehub/provider_api/weixin.py
@@ -19,6 +19,7 @@ opencv-python-headless>=4.13.0.92
19
19
  cryptography>=49.0.0
20
20
  gmssl>=3.2.2
21
21
  signerpy>=0.12.0
22
+ curl-cffi>=0.13
22
23
 
23
24
  [cli]
24
25
  argcomplete>=3.6.3
File without changes
File without changes
File without changes
File without changes
File without changes