parsehub 2.2.0__tar.gz → 2.2.2__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 (77) hide show
  1. {parsehub-2.2.0/src/parsehub.egg-info → parsehub-2.2.2}/PKG-INFO +25 -24
  2. {parsehub-2.2.0 → parsehub-2.2.2}/README.md +22 -22
  3. {parsehub-2.2.0 → parsehub-2.2.2}/pyproject.toml +3 -2
  4. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/douban.py +20 -6
  5. parsehub-2.2.2/src/parsehub/parsers/parser/weixin.py +40 -0
  6. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/zhihu.py +2 -2
  7. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/douban.py +4 -2
  8. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/weibo.py +1 -0
  9. parsehub-2.2.2/src/parsehub/provider_api/weixin.py +153 -0
  10. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/xhs.py +19 -6
  11. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/zhihu.py +29 -0
  12. {parsehub-2.2.0 → parsehub-2.2.2/src/parsehub.egg-info}/PKG-INFO +25 -24
  13. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub.egg-info/requires.txt +2 -1
  14. {parsehub-2.2.0 → parsehub-2.2.2}/test/test_core_offline.py +1 -0
  15. parsehub-2.2.0/src/parsehub/parsers/parser/weixin.py +0 -20
  16. parsehub-2.2.0/src/parsehub/provider_api/weixin.py +0 -62
  17. {parsehub-2.2.0 → parsehub-2.2.2}/LICENSE +0 -0
  18. {parsehub-2.2.0 → parsehub-2.2.2}/setup.cfg +0 -0
  19. {parsehub-2.2.0 → parsehub-2.2.2}/src/__init__.py +0 -0
  20. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/__init__.py +0 -0
  21. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/cli.py +0 -0
  22. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/cli_config.py +0 -0
  23. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/config/__init__.py +0 -0
  24. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/config/config.py +0 -0
  25. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/errors.py +0 -0
  26. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/__init__.py +0 -0
  27. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/base/__init__.py +0 -0
  28. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/base/base.py +0 -0
  29. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/base/ytdlp.py +0 -0
  30. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/__init__.py +0 -0
  31. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/bilibili.py +0 -0
  32. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/coolapk.py +0 -0
  33. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/douyin.py +0 -0
  34. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/facebook.py +0 -0
  35. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/instagram.py +0 -0
  36. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  37. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/pipix.py +0 -0
  38. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/snapchat.py +0 -0
  39. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/threads.py +0 -0
  40. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/tieba.py +0 -0
  41. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/tiktok.py +0 -0
  42. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/twitter.py +0 -0
  43. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/weibo.py +0 -0
  44. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/xhs.py +0 -0
  45. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  46. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/youtube.py +0 -0
  47. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  48. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/__init__.py +0 -0
  49. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/bilibili.py +0 -0
  50. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/coolapk.py +0 -0
  51. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/douyin.py +0 -0
  52. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/instagram.py +0 -0
  53. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/kuaishou.py +0 -0
  54. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/pipix.py +0 -0
  55. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/threads.py +0 -0
  56. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/tieba.py +0 -0
  57. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/tiktok.py +0 -0
  58. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/twitter.py +0 -0
  59. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  60. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/provider_api/zuiyou.py +0 -0
  61. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/types/__init__.py +0 -0
  62. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/types/callback.py +0 -0
  63. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/types/media_file.py +0 -0
  64. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/types/media_ref.py +0 -0
  65. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/types/platform.py +0 -0
  66. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/types/post.py +0 -0
  67. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/types/result.py +0 -0
  68. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/utils/downloader.py +0 -0
  69. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/utils/helpers.py +0 -0
  70. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub/utils/media_info.py +0 -0
  71. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub.egg-info/SOURCES.txt +0 -0
  72. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub.egg-info/dependency_links.txt +0 -0
  73. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub.egg-info/entry_points.txt +0 -0
  74. {parsehub-2.2.0 → parsehub-2.2.2}/src/parsehub.egg-info/top_level.txt +0 -0
  75. {parsehub-2.2.0 → parsehub-2.2.2}/test/test_cli.py +0 -0
  76. {parsehub-2.2.0 → parsehub-2.2.2}/test/test_cli_config.py +0 -0
  77. {parsehub-2.2.0 → parsehub-2.2.2}/test/test_downloader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -27,8 +27,9 @@ Requires-Dist: lxml>=5.3.0
27
27
  Requires-Dist: pydantic>=1.10.19
28
28
  Requires-Dist: markdownify>=1.1.0
29
29
  Requires-Dist: markdown>=3.7
30
+ Requires-Dist: json5>=0.12
30
31
  Requires-Dist: requests
31
- Requires-Dist: httpx>=0.24.1
32
+ Requires-Dist: httpx[socks]>=0.24.1
32
33
  Requires-Dist: pillow>=12.1.0
33
34
  Requires-Dist: python-slugify[unidecode]>=8.0.4
34
35
  Requires-Dist: opencv-python-headless>=4.13.0.92
@@ -72,28 +73,28 @@ Dynamic: license-file
72
73
 
73
74
  ## 🌐 支持平台
74
75
 
75
- | 平台 | 视频 | 图文 | 其他 |
76
- |-----------------|:----:|:----:|---------------------|
77
- | **Twitter / X** | ✅ | ✅ | 📝 文章 |
78
- | **Instagram** | ✅ | ✅ | |
79
- | **YouTube** | ✅ | | 🎵 音乐 |
80
- | **Facebook** | ✅ | | |
81
- | **Threads** | ✅ | ✅ | |
82
- | **Bilibili** | ✅ | | 📝 动态 |
83
- | **抖音** | ✅ | ✅ | ☀️日常 |
84
- | **TikTok** | ✅ | ✅ | |
85
- | **微博** | ✅ | ✅ | |
86
- | **小红书** | ✅ | ✅ | |
87
- | **贴吧** | ✅ | ✅ | |
88
- | **微信公众号** | | ✅ | |
89
- | **快手** | ✅ | ✅ | |
90
- | **酷安** | | ✅ | |
91
- | **皮皮虾** | ✅ | ✅ | |
92
- | **最右** | ✅ | ✅ | |
93
- | **小黑盒** | ✅ | ✅ | |
94
- | **Snapchat** | ✅ | | |
95
- | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子 |
96
- | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
76
+ | 平台 | 视频 | 图文 | 其他 |
77
+ |-----------------|:----:|:----:|---------------------------|
78
+ | **Twitter / X** | ✅ | ✅ | 📝 文章 |
79
+ | **Instagram** | ✅ | ✅ | |
80
+ | **YouTube** | ✅ | | 🎵 音乐 |
81
+ | **Facebook** | ✅ | | |
82
+ | **Threads** | ✅ | ✅ | |
83
+ | **Bilibili** | ✅ | | 📝 动态 |
84
+ | **抖音** | ✅ | ✅ | ☀️日常 |
85
+ | **TikTok** | ✅ | ✅ | |
86
+ | **微博** | ✅ | ✅ | |
87
+ | **小红书** | ✅ | ✅ | |
88
+ | **贴吧** | ✅ | ✅ | |
89
+ | **微信公众号** | | ✅ | |
90
+ | **快手** | ✅ | ✅ | |
91
+ | **酷安** | | ✅ | |
92
+ | **皮皮虾** | ✅ | ✅ | |
93
+ | **最右** | ✅ | ✅ | |
94
+ | **小黑盒** | ✅ | ✅ | |
95
+ | **Snapchat** | ✅ | | |
96
+ | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子, 日报 |
97
+ | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
97
98
 
98
99
  ## 📦 安装
99
100
 
@@ -30,28 +30,28 @@
30
30
 
31
31
  ## 🌐 支持平台
32
32
 
33
- | 平台 | 视频 | 图文 | 其他 |
34
- |-----------------|:----:|:----:|---------------------|
35
- | **Twitter / X** | ✅ | ✅ | 📝 文章 |
36
- | **Instagram** | ✅ | ✅ | |
37
- | **YouTube** | ✅ | | 🎵 音乐 |
38
- | **Facebook** | ✅ | | |
39
- | **Threads** | ✅ | ✅ | |
40
- | **Bilibili** | ✅ | | 📝 动态 |
41
- | **抖音** | ✅ | ✅ | ☀️日常 |
42
- | **TikTok** | ✅ | ✅ | |
43
- | **微博** | ✅ | ✅ | |
44
- | **小红书** | ✅ | ✅ | |
45
- | **贴吧** | ✅ | ✅ | |
46
- | **微信公众号** | | ✅ | |
47
- | **快手** | ✅ | ✅ | |
48
- | **酷安** | | ✅ | |
49
- | **皮皮虾** | ✅ | ✅ | |
50
- | **最右** | ✅ | ✅ | |
51
- | **小黑盒** | ✅ | ✅ | |
52
- | **Snapchat** | ✅ | | |
53
- | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子 |
54
- | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
33
+ | 平台 | 视频 | 图文 | 其他 |
34
+ |-----------------|:----:|:----:|---------------------------|
35
+ | **Twitter / X** | ✅ | ✅ | 📝 文章 |
36
+ | **Instagram** | ✅ | ✅ | |
37
+ | **YouTube** | ✅ | | 🎵 音乐 |
38
+ | **Facebook** | ✅ | | |
39
+ | **Threads** | ✅ | ✅ | |
40
+ | **Bilibili** | ✅ | | 📝 动态 |
41
+ | **抖音** | ✅ | ✅ | ☀️日常 |
42
+ | **TikTok** | ✅ | ✅ | |
43
+ | **微博** | ✅ | ✅ | |
44
+ | **小红书** | ✅ | ✅ | |
45
+ | **贴吧** | ✅ | ✅ | |
46
+ | **微信公众号** | | ✅ | |
47
+ | **快手** | ✅ | ✅ | |
48
+ | **酷安** | | ✅ | |
49
+ | **皮皮虾** | ✅ | ✅ | |
50
+ | **最右** | ✅ | ✅ | |
51
+ | **小黑盒** | ✅ | ✅ | |
52
+ | **Snapchat** | ✅ | | |
53
+ | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子, 日报 |
54
+ | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
55
55
 
56
56
  ## 📦 安装
57
57
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "parsehub"
3
- version = "2.2.0"
3
+ version = "2.2.2"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -30,8 +30,9 @@ dependencies = [
30
30
  "pydantic>=1.10.19",
31
31
  "markdownify>=1.1.0",
32
32
  "markdown>=3.7",
33
+ "json5>=0.12",
33
34
  "requests",
34
- "httpx>=0.24.1",
35
+ "httpx[socks]>=0.24.1",
35
36
  "pillow>=12.1.0",
36
37
  "python-slugify[unidecode]>=8.0.4",
37
38
  "opencv-python-headless>=4.13.0.92",
@@ -1,12 +1,15 @@
1
1
  from pathlib import Path
2
+ from typing import Union
2
3
 
3
4
  from ...provider_api.douban import IMAGE_REFERER, Douban, DoubanError, DoubanPhoto, DoubanVideo
4
5
  from ...types import (
5
6
  AniRef,
6
7
  AnyMediaRef,
7
8
  DownloadResult,
9
+ ImageParseResult,
8
10
  ImageRef,
9
11
  ParseError,
12
+ ParseResult,
10
13
  Platform,
11
14
  ProgressCallback,
12
15
  RichTextParseResult,
@@ -22,7 +25,7 @@ class DoubanParser(BaseParser):
22
25
  __match__ = r"^(http(s)?://)?(((www|m)\.)?douban\.com/((group/)?topic/\d+|doubanapp/dispatch)|douc\.cc/.+)"
23
26
  __redirect_keywords__ = ["douc.cc", "doubanapp/dispatch"]
24
27
 
25
- async def _do_parse(self, raw_url: str) -> "DoubanRichTextParseResult":
28
+ async def _do_parse(self, raw_url: str) -> Union["DoubanRichTextParseResult", "DoubanImageParseResult"]:
26
29
  try:
27
30
  topic = await Douban(proxy=self.proxy, cookie=self.cookie.get_value()).parse(raw_url)
28
31
  except DoubanError as e:
@@ -30,11 +33,16 @@ class DoubanParser(BaseParser):
30
33
  except Exception as e:
31
34
  raise ParseError("豆瓣解析失败: 未知错误") from e
32
35
 
36
+ photos = [self.to_media_ref(p) for p in topic.photos]
37
+
38
+ if topic.image_layout == "horizontal":
39
+ return DoubanImageParseResult(title=topic.title, photo=photos, content=topic.text_content)
40
+
33
41
  # 图片和视频在正文里有位置关系, 交给 RichText 由 markdown 保留顺序
34
- media: list[AnyMediaRef] = [
35
- *([self.to_video_ref(topic.video)] if topic.video else []),
36
- *(self.to_media_ref(p) for p in topic.photos),
37
- ]
42
+ media: list[AnyMediaRef] = []
43
+ if topic.video:
44
+ media.append(self.to_video_ref(topic.video))
45
+ media.extend(photos)
38
46
  return DoubanRichTextParseResult(title=topic.title, media=media, markdown_content=topic.markdown_content)
39
47
 
40
48
  @staticmethod
@@ -59,7 +67,7 @@ class DoubanParser(BaseParser):
59
67
  )
60
68
 
61
69
 
62
- class DoubanRichTextParseResult(RichTextParseResult):
70
+ class DoubanParseResult(ParseResult):
63
71
  async def _do_download(
64
72
  self,
65
73
  *,
@@ -83,4 +91,10 @@ class DoubanRichTextParseResult(RichTextParseResult):
83
91
  )
84
92
 
85
93
 
94
+ class DoubanImageParseResult(ImageParseResult, DoubanParseResult): ...
95
+
96
+
97
+ class DoubanRichTextParseResult(RichTextParseResult, DoubanParseResult): ...
98
+
99
+
86
100
  __all__ = ["DoubanParser", "DoubanRichTextParseResult"]
@@ -0,0 +1,40 @@
1
+ from ...provider_api.weixin import WX, WXItemShowType
2
+ from ...types import AnyParseResult, ImageParseResult, ImageRef, ParseError, Platform, RichTextParseResult
3
+ from ..base.base import BaseParser
4
+
5
+
6
+ class WXParser(BaseParser):
7
+ __platform__ = Platform.WEIXIN
8
+ __supported_type__ = ["图文"]
9
+ __match__ = r"^(http(s)?://)mp.weixin.qq.com/s/.*"
10
+
11
+ @staticmethod
12
+ def _refs(urls: list[str]) -> list[ImageRef]:
13
+ """图片地址带 wx_fmt 参数标明实际格式, 缺失时才用默认的 jpg."""
14
+ refs = []
15
+ for url in urls:
16
+ fmt = url.partition("wx_fmt=")[2].partition("&")[0]
17
+ refs.append(ImageRef(url=url, ext=fmt or "jpg"))
18
+ return refs
19
+
20
+ async def _do_parse(self, raw_url: str) -> AnyParseResult:
21
+ wx = await WX.parse(raw_url, self.proxy)
22
+
23
+ match wx.item_show_type:
24
+ case WXItemShowType.ARTICLE:
25
+ return RichTextParseResult(
26
+ title=wx.title,
27
+ media=self._refs(wx.imgs),
28
+ markdown_content=wx.markdown_content,
29
+ )
30
+ case WXItemShowType.IMAGE | WXItemShowType.TEXT:
31
+ return ImageParseResult(
32
+ title=wx.title,
33
+ photo=self._refs(wx.photos),
34
+ content=wx.text_content,
35
+ )
36
+ case _:
37
+ raise ParseError(f"不支持的内容类型: {wx.item_show_type!r}")
38
+
39
+
40
+ __all__ = ["WXParser"]
@@ -13,8 +13,8 @@ from ..base.base import BaseParser
13
13
 
14
14
  class ZhihuParser(BaseParser):
15
15
  __platform__ = Platform.ZHIHU
16
- __supported_type__ = ["问答", "专栏", "圈子"]
17
- __match__ = r"^(http(s)?://)?(www|zhuanlan).zhihu.com/(pin|question|p)/.*"
16
+ __supported_type__ = ["问答", "专栏", "圈子", "日报"]
17
+ __match__ = r"^(http(s)?://)?(www|zhuanlan|daily).zhihu.com/(pin|question|p|story)/.*"
18
18
 
19
19
  async def _do_parse(
20
20
  self, raw_url: str
@@ -1,6 +1,6 @@
1
1
  import re
2
2
  from dataclasses import dataclass, field
3
- from typing import Any, cast
3
+ from typing import Any, Literal, cast
4
4
 
5
5
  import httpx
6
6
  from bs4 import BeautifulSoup
@@ -80,6 +80,7 @@ class DoubanTopic:
80
80
  title: str
81
81
  markdown_content: str
82
82
  text_content: str
83
+ image_layout: Literal["horizontal", "vertical"] # vertical 图文混排, horizontal 图集
83
84
  video: DoubanVideo | None = None
84
85
  photos: list[DoubanPhoto] = field(default_factory=list)
85
86
 
@@ -95,7 +96,8 @@ class DoubanTopic:
95
96
  markdown_content=markdown_content,
96
97
  text_content=text_content,
97
98
  video=parse_video(video_info) if video_info else None,
98
- photos=[p for photo in data.get("photos") or [] if (p := parse_photo(photo))],
99
+ photos=[p for photo in data.get("photos", []) if (p := parse_photo(photo))],
100
+ image_layout=data.get("image_layout", "horizontal"),
99
101
  )
100
102
 
101
103
 
@@ -206,6 +206,7 @@ class PageInfo(Info):
206
206
  @dataclass
207
207
  class Pic:
208
208
  url: str | None = None
209
+ hdr_url: str | None = None
209
210
  width: int | None = None
210
211
  height: int | None = None
211
212
  cut_type: int | None = None
@@ -0,0 +1,153 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from enum import IntEnum
4
+ from html import unescape
5
+ from typing import Any, cast
6
+
7
+ import httpx
8
+ import json5
9
+ from bs4 import BeautifulSoup
10
+ from markdownify import MarkdownConverter
11
+
12
+ from ..errors import ParseError
13
+ from ..utils.helpers import UA
14
+
15
+
16
+ class WXItemShowType(IntEnum):
17
+ """页面数据里的 ``item_show_type``, 决定正文与媒体的取值位置."""
18
+
19
+ ARTICLE = 0
20
+ """公众号文章"""
21
+ IMAGE = 8
22
+ """图文"""
23
+ TEXT = 10
24
+ """纯文本"""
25
+
26
+
27
+ _TAG_RE = re.compile(r"</?[a-zA-Z][^>]*>")
28
+ _BREAK_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
29
+ _IMG_SRC_RE = re.compile(r"<img[^>]*?\bdata-src\s*=\s*[\"']([^\"']+)[\"']", re.IGNORECASE)
30
+ #: 页面数据用 '0' * 1 这类写法做隐式类型转换, 不是合法的 JSON5 值
31
+ _IMPLICIT_INT_RE = re.compile(r"('[^'\n]*'|\"[^\"\n]*\")\s*\*\s*1(?![.\d])")
32
+ #: 对象以 "};" 结束后还跟着 try/catch 与 IIFE 收尾
33
+ _OBJECT_END_RE = re.compile(r"}\s*;.*$", re.DOTALL)
34
+
35
+ _PAYLOAD_NAME = "cgiDataNew"
36
+
37
+
38
+ def _load_payload(html: str) -> dict[str, Any]:
39
+ """解析 ``window.cgiDataNew`` 为字典.
40
+
41
+ 数据在 script 标签里, 是 JS 对象字面量 (键无引号、单引号字符串、尾随逗号),
42
+ 用 json5 解析, 只需去掉两处 JS 语法.
43
+ """
44
+ scripts = [s.text for s in BeautifulSoup(html, "lxml").find_all("script") if _PAYLOAD_NAME in s.text]
45
+ if not scripts:
46
+ raise ParseError(f"未找到 {_PAYLOAD_NAME} 数据")
47
+
48
+ text = scripts[0].split(_PAYLOAD_NAME, 1)[1]
49
+ text = _IMPLICIT_INT_RE.sub(r"\1", text)
50
+ text = text[text.index("{") :]
51
+ return cast(dict[str, Any], json5.loads(_OBJECT_END_RE.sub("}", text, count=1)))
52
+
53
+
54
+ def _to_text(source: Any) -> str:
55
+ """正文片段转纯文本, 兼容本身就是纯文本的情况."""
56
+ if not isinstance(source, str) or not source:
57
+ return ""
58
+ return unescape(_TAG_RE.sub("", _BREAK_RE.sub("\n", unescape(source)))).strip()
59
+
60
+
61
+ def _collect_photos(data: dict[str, Any]) -> list[str]:
62
+ """图片消息的图片地址, 取原始图 ``watermark_info.cdn_url``, 缺失时退回 ``cdn_url``."""
63
+ entries = data.get("picture_page_info_list") or []
64
+ return [
65
+ url
66
+ for e in entries
67
+ if isinstance(e, dict)
68
+ for url in [(e.get("watermark_info") or {}).get("cdn_url") or e.get("cdn_url")]
69
+ if url
70
+ ]
71
+
72
+
73
+ class WXConverter(MarkdownConverter):
74
+ """把正文 HTML 转为 markdown, 图片地址取自 ``data-src``."""
75
+
76
+ def convert_img(self, el: Any, text: Any, parent_tags: Any) -> str:
77
+ alt = el.attrs.get("alt", None) or ""
78
+ src = el.attrs.get("data-src", None) or el.attrs.get("src", None) or ""
79
+ title = el.attrs.get("title", None) or ""
80
+ title_part = ' "{}"'.format(title.replace('"', r"\"")) if title else ""
81
+ options = cast(dict[str, Any], getattr(self, "options")) # noqa: B009
82
+ if "_inline" in parent_tags and el.parent.name not in options["keep_inline_images_in"]:
83
+ return alt
84
+
85
+ return f"![{alt}]({src}{title_part})"
86
+
87
+
88
+ @dataclass
89
+ class WX:
90
+ """微信文章的解析结果.
91
+
92
+ Attributes:
93
+ item_show_type: 内容形态
94
+ title: 标题
95
+ markdown_content: markdown 正文, 仅图文形态有值
96
+ imgs: 正文内的图片地址
97
+ text_content: 纯文本正文
98
+ photos: 图片消息的图片地址, 仅图片形态有值
99
+ """
100
+
101
+ item_show_type: WXItemShowType
102
+ title: str
103
+ markdown_content: str
104
+ imgs: list[str]
105
+ text_content: str
106
+ photos: list[str]
107
+
108
+ @staticmethod
109
+ async def parse(url: str, proxy: str | None = None) -> "WX":
110
+ async with httpx.AsyncClient(proxy=proxy) as client:
111
+ response = await client.get(url, headers={"User-Agent": UA})
112
+ return WX._parse_data(response.text)
113
+
114
+ @classmethod
115
+ def _parse_data(cls, html: str) -> "WX":
116
+ data = _load_payload(html)
117
+ try:
118
+ item_show_type = WXItemShowType(int(data["item_show_type"]))
119
+ except (KeyError, TypeError, ValueError) as e:
120
+ raise ParseError(f"不支持的内容类型: {data.get('item_show_type')!r}") from e
121
+
122
+ match item_show_type:
123
+ case WXItemShowType.ARTICLE:
124
+ source = data.get("content_noencode") or ""
125
+ return cls(
126
+ item_show_type=item_show_type,
127
+ title=data.get("title") or "",
128
+ markdown_content=WXConverter(heading_style="ATX").convert(source),
129
+ imgs=_IMG_SRC_RE.findall(source),
130
+ text_content=_to_text(source),
131
+ photos=[],
132
+ )
133
+ case WXItemShowType.TEXT:
134
+ return cls(
135
+ item_show_type=item_show_type,
136
+ title="",
137
+ markdown_content="",
138
+ imgs=[],
139
+ text_content=_to_text((data.get("text_page_info") or {}).get("content_noencode")),
140
+ photos=[],
141
+ )
142
+ case WXItemShowType.IMAGE:
143
+ return cls(
144
+ item_show_type=item_show_type,
145
+ title=data.get("title") or "",
146
+ markdown_content="",
147
+ imgs=[],
148
+ text_content=_to_text(data.get("content_noencode")),
149
+ photos=_collect_photos(data),
150
+ )
151
+
152
+
153
+ __all__ = ["WX", "WXConverter", "WXItemShowType"]
@@ -10,6 +10,10 @@ 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
+ from ..errors import ParseError
16
+
13
17
 
14
18
  class XHSAPI:
15
19
  def __init__(self, proxy: str | None = None, cookie: dict | None = None):
@@ -17,8 +21,17 @@ class XHSAPI:
17
21
  self.cookie = cookie
18
22
 
19
23
  async def __fetch_html(self, url: str) -> str:
20
- async with httpx.AsyncClient(proxy=self.proxy, cookies=self.cookie) as client:
21
- return (await client.get(url, timeout=30)).text
24
+ async with httpx.AsyncClient(proxy=self.proxy, cookies=self.cookie, follow_redirects=True) as client:
25
+ result = await client.get(
26
+ url,
27
+ headers={"User-Agent": UA},
28
+ timeout=30,
29
+ )
30
+ if "/login" in str(result.url):
31
+ raise ParseError("该帖子需要登录后查看")
32
+ elif "/404" in str(result.url):
33
+ raise ParseError("帖子不存在")
34
+ return result.text
22
35
 
23
36
  @staticmethod
24
37
  async def __extract_data(html: str) -> dict[str, Any]:
@@ -27,7 +40,7 @@ class XHSAPI:
27
40
  script for script in soup.find_all("script") if script.text.lstrip().startswith("window.__INITIAL_STATE__")
28
41
  ]
29
42
  if not scripts:
30
- raise ValueError("No data found")
43
+ raise ParseError("No data found")
31
44
  script = scripts[0].text
32
45
  json_data = script.replace("window.__INITIAL_STATE__=", "")
33
46
  json_data = re.sub(r"\bundefined\b", "null", json_data) # 清理js对象
@@ -36,11 +49,11 @@ class XHSAPI:
36
49
 
37
50
  def __parse(self, data: dict[str, Any]) -> XHSPost:
38
51
  if not data.get("note"):
39
- raise ValueError("该帖子需要登录后查看")
52
+ raise ParseError("该帖子需要登录后查看")
40
53
  first_note_id = data["note"]["firstNoteId"]
41
54
  note = data["note"]["noteDetailMap"][first_note_id]["note"]
42
55
  if not note:
43
- raise ValueError("未获取到内容, 该帖子可能需要登录后查看")
56
+ raise ParseError("未获取到内容, 该帖子可能需要登录后查看")
44
57
 
45
58
  title = note["title"]
46
59
  desc = note["desc"]
@@ -79,7 +92,7 @@ class XHSAPI:
79
92
  stream = media["stream"]
80
93
  selected_stream = self.__select_stream(stream)
81
94
  if not selected_stream:
82
- raise ValueError("未获取到视频流")
95
+ raise ParseError("未获取到视频流")
83
96
  stream = selected_stream[0]
84
97
  media_list.append(
85
98
  XHSMedia(
@@ -208,8 +208,24 @@ class ZhihuAPI:
208
208
  return await self.parse_zl(raw_url)
209
209
  if "/pin/" in raw_url:
210
210
  return await self.parse_pin(raw_url)
211
+ if "daily.zhihu.com" in raw_url or "/story/" in raw_url:
212
+ return await self.parse_daily(raw_url)
211
213
  raise ValueError("不支持的类型")
212
214
 
215
+ async def parse_daily(self, raw_url: str) -> ZhihuQA | ZhihuZhuanLan | ZhihuPin:
216
+ story_id = self._get_daily_id(raw_url)
217
+ result = await self._daily(story_id)
218
+ body = result.get("body", "")
219
+ soup = BeautifulSoup(body, "lxml")
220
+ origin_url: str | None = None
221
+ for a in soup.find_all("a"):
222
+ if "查看知乎原文" in a.get_text():
223
+ origin_url = str(a.get("href"))
224
+ break
225
+ if not origin_url:
226
+ raise ValueError("未在知乎日报中找到原文链接")
227
+ return await self.parse(origin_url)
228
+
213
229
  async def parse_qa(self, raw_url: str) -> ZhihuQA:
214
230
  qid, aid = self._get_qa_id(raw_url)
215
231
  if aid:
@@ -293,6 +309,12 @@ class ZhihuAPI:
293
309
  r = await client.get(url, headers=headers, params=query, cookies=self.cookie)
294
310
  return dict(r.json())
295
311
 
312
+ async def _daily(self, story_id: int | str) -> dict:
313
+ url = f"https://daily.zhihu.com/api/7/story/{story_id}"
314
+ async with httpx.AsyncClient(proxy=self.proxy) as client:
315
+ r = await client.get(url)
316
+ return dict(r.json())
317
+
296
318
  @staticmethod
297
319
  def _get_qa_id(raw_url: str) -> tuple[str, str | None]:
298
320
  """返回问题和回答 id, 没有回答 id 时返回 None"""
@@ -315,6 +337,13 @@ class ZhihuAPI:
315
337
  raise ValueError("从链接中提取 圈子 id 错误")
316
338
  return r.group(1)
317
339
 
340
+ @staticmethod
341
+ def _get_daily_id(raw_url: str) -> str:
342
+ r = re.search(r"/story/(\d+)", raw_url)
343
+ if not r:
344
+ raise ValueError("从链接中提取 日报 id 错误")
345
+ return r.group(1)
346
+
318
347
 
319
348
  # --- SM4 constants (extracted from other.js VM) ---------------------------------
320
349
  SM4_ZB = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -27,8 +27,9 @@ Requires-Dist: lxml>=5.3.0
27
27
  Requires-Dist: pydantic>=1.10.19
28
28
  Requires-Dist: markdownify>=1.1.0
29
29
  Requires-Dist: markdown>=3.7
30
+ Requires-Dist: json5>=0.12
30
31
  Requires-Dist: requests
31
- Requires-Dist: httpx>=0.24.1
32
+ Requires-Dist: httpx[socks]>=0.24.1
32
33
  Requires-Dist: pillow>=12.1.0
33
34
  Requires-Dist: python-slugify[unidecode]>=8.0.4
34
35
  Requires-Dist: opencv-python-headless>=4.13.0.92
@@ -72,28 +73,28 @@ Dynamic: license-file
72
73
 
73
74
  ## 🌐 支持平台
74
75
 
75
- | 平台 | 视频 | 图文 | 其他 |
76
- |-----------------|:----:|:----:|---------------------|
77
- | **Twitter / X** | ✅ | ✅ | 📝 文章 |
78
- | **Instagram** | ✅ | ✅ | |
79
- | **YouTube** | ✅ | | 🎵 音乐 |
80
- | **Facebook** | ✅ | | |
81
- | **Threads** | ✅ | ✅ | |
82
- | **Bilibili** | ✅ | | 📝 动态 |
83
- | **抖音** | ✅ | ✅ | ☀️日常 |
84
- | **TikTok** | ✅ | ✅ | |
85
- | **微博** | ✅ | ✅ | |
86
- | **小红书** | ✅ | ✅ | |
87
- | **贴吧** | ✅ | ✅ | |
88
- | **微信公众号** | | ✅ | |
89
- | **快手** | ✅ | ✅ | |
90
- | **酷安** | | ✅ | |
91
- | **皮皮虾** | ✅ | ✅ | |
92
- | **最右** | ✅ | ✅ | |
93
- | **小黑盒** | ✅ | ✅ | |
94
- | **Snapchat** | ✅ | | |
95
- | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子 |
96
- | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
76
+ | 平台 | 视频 | 图文 | 其他 |
77
+ |-----------------|:----:|:----:|---------------------------|
78
+ | **Twitter / X** | ✅ | ✅ | 📝 文章 |
79
+ | **Instagram** | ✅ | ✅ | |
80
+ | **YouTube** | ✅ | | 🎵 音乐 |
81
+ | **Facebook** | ✅ | | |
82
+ | **Threads** | ✅ | ✅ | |
83
+ | **Bilibili** | ✅ | | 📝 动态 |
84
+ | **抖音** | ✅ | ✅ | ☀️日常 |
85
+ | **TikTok** | ✅ | ✅ | |
86
+ | **微博** | ✅ | ✅ | |
87
+ | **小红书** | ✅ | ✅ | |
88
+ | **贴吧** | ✅ | ✅ | |
89
+ | **微信公众号** | | ✅ | |
90
+ | **快手** | ✅ | ✅ | |
91
+ | **酷安** | | ✅ | |
92
+ | **皮皮虾** | ✅ | ✅ | |
93
+ | **最右** | ✅ | ✅ | |
94
+ | **小黑盒** | ✅ | ✅ | |
95
+ | **Snapchat** | ✅ | | |
96
+ | **知乎** | ✅ | ✅ | 🐶 问答, 专栏, 圈子, 日报 |
97
+ | **豆瓣** | ✅ | ✅ | 👥 小组话题 |
97
98
 
98
99
  ## 📦 安装
99
100
 
@@ -10,8 +10,9 @@ lxml>=5.3.0
10
10
  pydantic>=1.10.19
11
11
  markdownify>=1.1.0
12
12
  markdown>=3.7
13
+ json5>=0.12
13
14
  requests
14
- httpx>=0.24.1
15
+ httpx[socks]>=0.24.1
15
16
  pillow>=12.1.0
16
17
  python-slugify[unidecode]>=8.0.4
17
18
  opencv-python-headless>=4.13.0.92
@@ -447,6 +447,7 @@ class TestDoubanParserResultTypes(unittest.IsolatedAsyncioTestCase):
447
447
  "title": "标题",
448
448
  "markdown_content": "",
449
449
  "text_content": "",
450
+ "image_layout": "vertical", # 默认图文混排, 走 RichText 分支
450
451
  "video": None,
451
452
  "photos": [],
452
453
  }
@@ -1,20 +0,0 @@
1
- from ...provider_api.weixin import WX
2
- from ...types import ImageRef, Platform, RichTextParseResult
3
- from ..base.base import BaseParser
4
-
5
-
6
- class WXParser(BaseParser):
7
- __platform__ = Platform.WEIXIN
8
- __supported_type__ = ["图文"]
9
- __match__ = r"^(http(s)?://)mp.weixin.qq.com/s/.*"
10
-
11
- async def _do_parse(self, raw_url: str) -> "RichTextParseResult":
12
- wx = await WX.parse(raw_url, self.proxy)
13
- return RichTextParseResult(
14
- title=wx.title,
15
- media=[ImageRef(url=i) for i in wx.imgs],
16
- markdown_content=wx.markdown_content,
17
- )
18
-
19
-
20
- __all__ = ["WXParser"]
@@ -1,62 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Any, cast
3
-
4
- import httpx
5
- from bs4 import BeautifulSoup, Tag
6
- from markdown import markdown
7
- from markdownify import MarkdownConverter
8
-
9
- from ..types import ParseError
10
- from ..utils.helpers import UA
11
-
12
-
13
- class WXConverter(MarkdownConverter):
14
- def convert_img(self, el: Any, text: Any, parent_tags: Any) -> str:
15
- alt = el.attrs.get("alt", None) or ""
16
- src = el.attrs.get("data-src", None) or ""
17
- title = el.attrs.get("title", None) or ""
18
- title_part = ' "{}"'.format(title.replace('"', r"\"")) if title else ""
19
- options = cast(dict[str, Any], getattr(self, "options")) # noqa: B009
20
- if "_inline" in parent_tags and el.parent.name not in options["keep_inline_images_in"]:
21
- return alt
22
-
23
- return f"![{alt}]({src}{title_part})"
24
-
25
-
26
- @dataclass
27
- class WX:
28
- title: str
29
- imgs: list[str]
30
- markdown_content: str
31
- text_content: str
32
-
33
- @staticmethod
34
- async def parse(url: str, proxy: str | None = None) -> "WX":
35
- async with httpx.AsyncClient(proxy=proxy) as client:
36
- response = await client.get(url, headers={"User-Agent": UA})
37
- html = response.text
38
- return WX._parse_html(html)
39
-
40
- @classmethod
41
- def _parse_html(cls, html: str) -> "WX":
42
- soup = BeautifulSoup(html, "lxml")
43
- title_tag = soup.find("h1", {"class": "rich_media_title"})
44
- title = title_tag.text.strip() if isinstance(title_tag, Tag) else ""
45
- wxc = WXConverter(heading_style="ATX")
46
- if isinstance(rich_media_content := soup.find("div", {"class": "rich_media_content"}), Tag):
47
- imgs = [str(i.get("data-src") or "") for i in rich_media_content.find_all("img", {"class": "rich_pages"})]
48
-
49
- markdown_content = wxc.convert(str(rich_media_content))
50
- text_content = "".join(BeautifulSoup(markdown(markdown_content), "lxml").find_all(string=True))
51
- return cls(title, imgs, markdown_content, text_content)
52
- elif isinstance(share_content_page := soup.find("div", {"class": "share_content_page"}), Tag):
53
- imgs = [str(i.get("data-src") or "") for i in share_content_page.find_all("div", {"class": "swiper_item"})]
54
-
55
- description = soup.find("meta", {"name": "description"})
56
- if not isinstance(description, Tag):
57
- raise ParseError("获取内容失败")
58
- markdown_content = wxc.convert(str(description.get("content") or ""))
59
- text_content = "".join(BeautifulSoup(markdown(markdown_content), "lxml").find_all(string=True))
60
- return cls(title, imgs, markdown_content, text_content)
61
- else:
62
- raise ParseError("获取内容失败")
File without changes
File without changes
File without changes
File without changes
File without changes