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.
- {parsehub-2.2.3/src/parsehub.egg-info → parsehub-2.3.0}/PKG-INFO +5 -1
- {parsehub-2.2.3 → parsehub-2.3.0}/README.md +3 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/pyproject.toml +2 -1
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/__init__.py +3 -0
- parsehub-2.3.0/src/parsehub/parsers/parser/bangumi.py +27 -0
- parsehub-2.3.0/src/parsehub/parsers/parser/medium.py +24 -0
- parsehub-2.3.0/src/parsehub/parsers/parser/toutiao.py +69 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/xhs.py +6 -0
- parsehub-2.3.0/src/parsehub/provider_api/bangumi.py +166 -0
- parsehub-2.3.0/src/parsehub/provider_api/medium.py +256 -0
- parsehub-2.3.0/src/parsehub/provider_api/toutiao.py +180 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/xhs.py +6 -3
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/zhihu.py +13 -6
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/platform.py +3 -0
- {parsehub-2.2.3 → parsehub-2.3.0/src/parsehub.egg-info}/PKG-INFO +5 -1
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/SOURCES.txt +6 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/requires.txt +1 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/LICENSE +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/setup.cfg +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/__init__.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/__init__.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/cli.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/cli_config.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/config/__init__.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/config/config.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/errors.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/__init__.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/base/__init__.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/base/base.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/base/ytdlp.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/bilibili.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/coolapk.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/douban.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/douyin.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/facebook.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/instagram.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/kuaishou.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/pipix.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/snapchat.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/threads.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/tieba.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/tiktok.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/twitter.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/weibo.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/weixin.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/youtube.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/zhihu.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/parsers/parser/zuiyou.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/__init__.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/bilibili.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/coolapk.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/douban.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/douyin.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/instagram.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/kuaishou.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/pipix.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/threads.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/tieba.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/tiktok.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/twitter.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/weibo.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/weixin.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/xiaoheihe.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/provider_api/zuiyou.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/__init__.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/callback.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/media_file.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/media_ref.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/post.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/types/result.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/utils/downloader.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/utils/helpers.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub/utils/media_info.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/dependency_links.txt +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/entry_points.txt +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/src/parsehub.egg-info/top_level.txt +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/test/test_cli.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/test/test_cli_config.py +0 -0
- {parsehub-2.2.3 → parsehub-2.3.0}/test/test_core_offline.py +0 -0
- {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.
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "parsehub"
|
|
3
|
-
version = "2.
|
|
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" 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"")
|
|
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"")
|
|
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""
|
|
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""
|
|
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={
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|