parsehub 2.3.16__tar.gz → 2.3.17__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 (87) hide show
  1. {parsehub-2.3.16/src/parsehub.egg-info → parsehub-2.3.17}/PKG-INFO +1 -1
  2. {parsehub-2.3.16 → parsehub-2.3.17}/pyproject.toml +1 -1
  3. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/base/ytdlp.py +1 -1
  4. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/instagram.py +3 -2
  5. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/zuiyou.py +1 -1
  6. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/instagram.py +45 -2
  7. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/zuiyou.py +10 -2
  8. {parsehub-2.3.16 → parsehub-2.3.17/src/parsehub.egg-info}/PKG-INFO +1 -1
  9. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub.egg-info/SOURCES.txt +3 -1
  10. parsehub-2.3.17/test/test_instagram_duration_offline.py +134 -0
  11. parsehub-2.3.17/test/test_zuiyou_duration_offline.py +69 -0
  12. {parsehub-2.3.16 → parsehub-2.3.17}/LICENSE +0 -0
  13. {parsehub-2.3.16 → parsehub-2.3.17}/README.md +0 -0
  14. {parsehub-2.3.16 → parsehub-2.3.17}/setup.cfg +0 -0
  15. {parsehub-2.3.16 → parsehub-2.3.17}/src/__init__.py +0 -0
  16. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/__init__.py +0 -0
  17. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/cli.py +0 -0
  18. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/cli_config.py +0 -0
  19. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/config/__init__.py +0 -0
  20. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/config/config.py +0 -0
  21. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/errors.py +0 -0
  22. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/__init__.py +0 -0
  23. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/base/__init__.py +0 -0
  24. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/base/base.py +0 -0
  25. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/__init__.py +0 -0
  26. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/bangumi.py +0 -0
  27. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/bilibili.py +0 -0
  28. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/coolapk.py +0 -0
  29. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/douban.py +0 -0
  30. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/douyin.py +0 -0
  31. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/facebook.py +0 -0
  32. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  33. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/medium.py +0 -0
  34. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/pipix.py +0 -0
  35. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/snapchat.py +0 -0
  36. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/threads.py +0 -0
  37. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/tieba.py +0 -0
  38. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/tiktok.py +0 -0
  39. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/toutiao.py +0 -0
  40. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/twitter.py +0 -0
  41. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/weibo.py +0 -0
  42. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/weixin.py +0 -0
  43. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/wxchannels.py +0 -0
  44. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/xhs.py +0 -0
  45. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  46. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/youtube.py +0 -0
  47. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/parsers/parser/zhihu.py +0 -0
  48. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/__init__.py +0 -0
  49. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/bangumi.py +0 -0
  50. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/bilibili.py +0 -0
  51. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/coolapk.py +0 -0
  52. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/douban.py +0 -0
  53. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/douyin.py +0 -0
  54. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/kuaishou.py +0 -0
  55. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/medium.py +0 -0
  56. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/pipix.py +0 -0
  57. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/threads.py +0 -0
  58. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/tieba.py +0 -0
  59. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/tiktok.py +0 -0
  60. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/toutiao.py +0 -0
  61. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/twitter.py +0 -0
  62. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/weibo.py +0 -0
  63. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/weixin.py +0 -0
  64. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/wxchannels.py +0 -0
  65. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/xhs.py +0 -0
  66. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  67. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/provider_api/zhihu.py +0 -0
  68. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/types/__init__.py +0 -0
  69. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/types/callback.py +0 -0
  70. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/types/media_file.py +0 -0
  71. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/types/media_ref.py +0 -0
  72. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/types/platform.py +0 -0
  73. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/types/post.py +0 -0
  74. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/types/result.py +0 -0
  75. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/utils/downloader.py +0 -0
  76. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/utils/fallback.py +0 -0
  77. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/utils/helpers.py +0 -0
  78. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub/utils/media_info.py +0 -0
  79. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub.egg-info/dependency_links.txt +0 -0
  80. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub.egg-info/entry_points.txt +0 -0
  81. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub.egg-info/requires.txt +0 -0
  82. {parsehub-2.3.16 → parsehub-2.3.17}/src/parsehub.egg-info/top_level.txt +0 -0
  83. {parsehub-2.3.16 → parsehub-2.3.17}/test/test_bilibili_offline.py +0 -0
  84. {parsehub-2.3.16 → parsehub-2.3.17}/test/test_cli.py +0 -0
  85. {parsehub-2.3.16 → parsehub-2.3.17}/test/test_cli_config.py +0 -0
  86. {parsehub-2.3.16 → parsehub-2.3.17}/test/test_core_offline.py +0 -0
  87. {parsehub-2.3.16 → parsehub-2.3.17}/test/test_downloader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.16
3
+ Version: 2.3.17
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "parsehub"
3
- version = "2.3.16"
3
+ version = "2.3.17"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -411,7 +411,7 @@ class YtParser(BaseParser, register=False):
411
411
  title = dl["title"]
412
412
  duration = dl.get("duration", 0)
413
413
  thumbnail = dl.get("thumbnail")
414
- description = dl["description"]
414
+ description = dl.get("description", "")
415
415
  width = dl.get("width", 0)
416
416
  height = dl.get("height", 0)
417
417
  return YtVideoInfo(
@@ -71,11 +71,12 @@ class InstagramParser(BaseParser):
71
71
  title=post.title,
72
72
  content=post.caption,
73
73
  video=VideoRef(
74
- url=info.url,
74
+ # yt-dlp 的 info.url 是帖子页,provider 的 video_url 才是媒体直链
75
+ url=item.video_url or info.url,
75
76
  thumb_url=item.display_url or info.thumbnail,
76
77
  width=info.width,
77
78
  height=info.height,
78
- duration=int(info.duration or item.duration),
79
+ duration=int(item.duration or info.duration),
79
80
  ),
80
81
  )
81
82
  if not item.video_url:
@@ -15,7 +15,7 @@ class ZuiYouParser(BaseParser):
15
15
  return MultimediaParseResult(
16
16
  content=zy.content,
17
17
  media=[
18
- VideoRef(url=i.url, thumb_url=i.thumb_url)
18
+ VideoRef(url=i.url, thumb_url=i.thumb_url, duration=i.duration)
19
19
  if i.type == MediaType.VIDEO
20
20
  else ImageRef(url=i.url, thumb_url=i.thumb_url)
21
21
  for i in zy.media
@@ -116,11 +116,54 @@ class InstagramAPI:
116
116
  cookies=self.cookie or None,
117
117
  ) as session:
118
118
  if self.cookie:
119
- return await self._get_cookie_post(session, shortcode)
120
- return await self._get_logged_out_post(session, shortcode)
119
+ post = await self._get_cookie_post(session, shortcode)
120
+ else:
121
+ post = await self._get_logged_out_post(session, shortcode)
122
+ return await self._fill_missing_video_fields(session, post, shortcode)
121
123
  except requests.RequestsError as exc:
122
124
  raise InstagramAPIError(f"请求 Instagram 失败: {exc}") from exc
123
125
 
126
+ async def _fill_missing_video_fields(
127
+ self,
128
+ session: requests.AsyncSession,
129
+ post: InstagramPost,
130
+ shortcode: str,
131
+ ) -> InstagramPost:
132
+ """用 embed 补齐 v1 / GraphQL 缺失的视频直链和时长。"""
133
+ if not any(
134
+ (post.type is InstagramMediaType.VIDEO or item.is_video)
135
+ and (not item.video_url or item.duration <= 0)
136
+ for item in post.media
137
+ ):
138
+ return post
139
+
140
+ try:
141
+ node = await self._fetch_embed(session, shortcode)
142
+ except requests.RequestsError:
143
+ return post
144
+ if node is None:
145
+ return post
146
+
147
+ try:
148
+ embed = InstagramPost.from_graph(node)
149
+ except InstagramAPIError:
150
+ return post
151
+
152
+ for index, item in enumerate(post.media):
153
+ if post.type is not InstagramMediaType.VIDEO and not item.is_video:
154
+ continue
155
+ source = embed.media[index] if index < len(embed.media) else None
156
+ if source is None and len(post.media) == 1 and len(embed.media) == 1:
157
+ source = embed.media[0]
158
+ if source is None:
159
+ continue
160
+ if not item.video_url and source.video_url:
161
+ item.video_url = source.video_url
162
+ item.is_video = True
163
+ if item.duration <= 0 and source.duration > 0:
164
+ item.duration = source.duration
165
+ return post
166
+
124
167
  async def _get_logged_out_post(self, session: requests.AsyncSession, shortcode: str) -> InstagramPost:
125
168
  detail = "未返回帖子数据"
126
169
  media_id = str(self.shortcode_to_media_id(shortcode))
@@ -15,6 +15,7 @@ class Media:
15
15
  url: str
16
16
  thumb_url: str
17
17
  type: MediaType
18
+ duration: int = 0
18
19
 
19
20
 
20
21
  @dataclass
@@ -34,8 +35,15 @@ class ZuiYouPost:
34
35
  id_ = img["id"]
35
36
  img_url = list(img["urls"].values())[-1]["urls"][-1]
36
37
  if img.get("video"):
37
- video_url = videos[str(id_)]["url"]
38
- media.append(Media(url=video_url, thumb_url=img_url, type=MediaType.VIDEO))
38
+ video = videos[str(id_)]
39
+ media.append(
40
+ Media(
41
+ url=video["url"],
42
+ thumb_url=img_url,
43
+ type=MediaType.VIDEO,
44
+ duration=int(video.get("dur") or 0),
45
+ )
46
+ )
39
47
  continue
40
48
  media.append(Media(url=img_url, thumb_url=img_url, type=MediaType.PHOTO))
41
49
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.16
3
+ Version: 2.3.17
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -80,4 +80,6 @@ test/test_bilibili_offline.py
80
80
  test/test_cli.py
81
81
  test/test_cli_config.py
82
82
  test/test_core_offline.py
83
- test/test_downloader.py
83
+ test/test_downloader.py
84
+ test/test_instagram_duration_offline.py
85
+ test/test_zuiyou_duration_offline.py
@@ -0,0 +1,134 @@
1
+ import unittest
2
+ from unittest.mock import AsyncMock, patch
3
+
4
+ from parsehub.parsers.base.ytdlp import YtParser, YtVideoInfo
5
+ from parsehub.parsers.parser.instagram import InstagramParser
6
+ from parsehub.provider_api.instagram import InstagramAPI, InstagramMedia, InstagramMediaType, InstagramPost
7
+
8
+
9
+ class TestInstagramParserDuration(unittest.IsolatedAsyncioTestCase):
10
+ async def test_video_prefers_provider_url_and_duration(self):
11
+ post = InstagramPost(
12
+ shortcode="abc",
13
+ type=InstagramMediaType.VIDEO,
14
+ media=[
15
+ InstagramMedia(
16
+ is_video=True,
17
+ display_url="https://cdn.example/thumb.jpg",
18
+ video_url="https://cdn.example/v.mp4",
19
+ duration=18.018,
20
+ width=1920,
21
+ height=1080,
22
+ )
23
+ ],
24
+ )
25
+ info = YtVideoInfo(
26
+ title="t",
27
+ description="",
28
+ thumbnail=None,
29
+ url="https://www.instagram.com/p/abc/",
30
+ info_json={},
31
+ duration=0,
32
+ width=1920,
33
+ height=1080,
34
+ )
35
+ parser = InstagramParser()
36
+
37
+ with (
38
+ patch.object(InstagramParser, "_parse", AsyncMock(return_value=post)),
39
+ patch(
40
+ "parsehub.parsers.parser.instagram.InstagramYtParse._parse",
41
+ AsyncMock(return_value=info),
42
+ ),
43
+ ):
44
+ result = await parser._do_parse("https://www.instagram.com/reel/abc/")
45
+
46
+ self.assertEqual(result.media.url, "https://cdn.example/v.mp4")
47
+ self.assertEqual(result.media.duration, 18)
48
+
49
+
50
+ class TestInstagramAPIDuration(unittest.IsolatedAsyncioTestCase):
51
+ @staticmethod
52
+ def _embed_node() -> dict:
53
+ return {
54
+ "__typename": "GraphVideo",
55
+ "shortcode": "abc",
56
+ "display_url": "https://cdn.example/thumb.jpg",
57
+ "video_url": "https://cdn.example/v.mp4",
58
+ "video_duration": 30.933,
59
+ "dimensions": {"width": 1080, "height": 1920},
60
+ }
61
+
62
+ async def test_fills_duration_from_embed(self):
63
+ api = InstagramAPI()
64
+ post = InstagramPost(
65
+ shortcode="abc",
66
+ type=InstagramMediaType.VIDEO,
67
+ media=[
68
+ InstagramMedia(
69
+ is_video=True,
70
+ display_url="https://cdn.example/thumb.jpg",
71
+ video_url="https://cdn.example/v.mp4",
72
+ duration=0,
73
+ )
74
+ ],
75
+ )
76
+
77
+ with patch.object(InstagramAPI, "_fetch_embed", AsyncMock(return_value=self._embed_node())):
78
+ result = await api._fill_missing_video_fields(object(), post, "abc")
79
+
80
+ self.assertEqual(result.media[0].duration, 30.933)
81
+
82
+ async def test_fills_missing_video_url_from_embed(self):
83
+ api = InstagramAPI()
84
+ post = InstagramPost(
85
+ shortcode="abc",
86
+ type=InstagramMediaType.VIDEO,
87
+ media=[
88
+ InstagramMedia(
89
+ is_video=False,
90
+ display_url="https://cdn.example/thumb.jpg",
91
+ duration=0,
92
+ )
93
+ ],
94
+ )
95
+
96
+ with patch.object(InstagramAPI, "_fetch_embed", AsyncMock(return_value=self._embed_node())):
97
+ result = await api._fill_missing_video_fields(object(), post, "abc")
98
+
99
+ self.assertEqual(result.media[0].video_url, "https://cdn.example/v.mp4")
100
+ self.assertTrue(result.media[0].is_video)
101
+ self.assertEqual(result.media[0].duration, 30.933)
102
+
103
+ async def test_skips_embed_when_complete(self):
104
+ api = InstagramAPI()
105
+ post = InstagramPost(
106
+ shortcode="abc",
107
+ type=InstagramMediaType.VIDEO,
108
+ media=[
109
+ InstagramMedia(
110
+ is_video=True,
111
+ display_url="https://cdn.example/thumb.jpg",
112
+ video_url="https://cdn.example/v.mp4",
113
+ duration=18,
114
+ )
115
+ ],
116
+ )
117
+
118
+ with patch.object(InstagramAPI, "_fetch_embed", AsyncMock()) as fetch:
119
+ await api._fill_missing_video_fields(object(), post, "abc")
120
+
121
+ fetch.assert_not_awaited()
122
+
123
+
124
+ class _FakeYtParser(YtParser, register=False):
125
+ async def _extract_info(self, url: str) -> dict:
126
+ return {"title": "t", "duration": 10, "webpage_url": url}
127
+
128
+
129
+ class TestYtParserDescription(unittest.IsolatedAsyncioTestCase):
130
+ async def test_missing_description_does_not_raise(self):
131
+ info = await _FakeYtParser()._parse("https://example.com/v")
132
+
133
+ self.assertEqual(info.description, "")
134
+ self.assertEqual(info.duration, 10)
@@ -0,0 +1,69 @@
1
+ import unittest
2
+ from unittest.mock import AsyncMock, patch
3
+
4
+ from parsehub.parsers.parser.zuiyou import ZuiYouParser
5
+ from parsehub.provider_api.zuiyou import Media, MediaType, ZuiYou, ZuiYouPost
6
+
7
+
8
+ class TestZuiyouDuration(unittest.TestCase):
9
+ def test_parse_reads_video_dur(self):
10
+ payload = {
11
+ "data": {
12
+ "post": {
13
+ "content": "x",
14
+ "imgs": [
15
+ {
16
+ "id": 1,
17
+ "urls": {"a": {"urls": ["https://cdn.example/thumb.jpg"]}},
18
+ "video": True,
19
+ }
20
+ ],
21
+ "videos": {"1": {"dur": 5, "url": "https://cdn.example/v.mp4"}},
22
+ }
23
+ }
24
+ }
25
+
26
+ post = ZuiYouPost.parse(payload)
27
+
28
+ self.assertEqual(post.media[0].type, MediaType.VIDEO)
29
+ self.assertEqual(post.media[0].duration, 5)
30
+ self.assertEqual(post.media[0].url, "https://cdn.example/v.mp4")
31
+
32
+ def test_photo_duration_defaults_to_zero(self):
33
+ payload = {
34
+ "data": {
35
+ "post": {
36
+ "imgs": [
37
+ {
38
+ "id": 1,
39
+ "urls": {"a": {"urls": ["https://cdn.example/p.jpg"]}},
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ }
45
+
46
+ post = ZuiYouPost.parse(payload)
47
+
48
+ self.assertEqual(post.media[0].duration, 0)
49
+
50
+
51
+ class TestZuiyouParserDuration(unittest.IsolatedAsyncioTestCase):
52
+ async def test_parser_sets_video_duration(self):
53
+ zy = ZuiYouPost(
54
+ content="x",
55
+ media=[
56
+ Media(
57
+ url="https://cdn.example/v.mp4",
58
+ thumb_url="https://cdn.example/thumb.jpg",
59
+ type=MediaType.VIDEO,
60
+ duration=19,
61
+ )
62
+ ],
63
+ raw={},
64
+ )
65
+
66
+ with patch.object(ZuiYou, "parse", AsyncMock(return_value=zy)):
67
+ result = await ZuiYouParser()._do_parse("https://s.izuiyou.com/s/abc")
68
+
69
+ self.assertEqual(result.media[0].duration, 19)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes