parsehub 2.0.34__tar.gz → 2.0.35__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 (71) hide show
  1. {parsehub-2.0.34/src/parsehub.egg-info → parsehub-2.0.35}/PKG-INFO +1 -1
  2. {parsehub-2.0.34 → parsehub-2.0.35}/pyproject.toml +1 -1
  3. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/instagram.py +3 -3
  4. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/xhs.py +8 -8
  5. {parsehub-2.0.34 → parsehub-2.0.35/src/parsehub.egg-info}/PKG-INFO +1 -1
  6. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub.egg-info/SOURCES.txt +2 -1
  7. {parsehub-2.0.34 → parsehub-2.0.35}/test/test_core_offline.py +1 -0
  8. parsehub-2.0.35/test/test_downloader.py +161 -0
  9. {parsehub-2.0.34 → parsehub-2.0.35}/LICENSE +0 -0
  10. {parsehub-2.0.34 → parsehub-2.0.35}/README.md +0 -0
  11. {parsehub-2.0.34 → parsehub-2.0.35}/setup.cfg +0 -0
  12. {parsehub-2.0.34 → parsehub-2.0.35}/src/__init__.py +0 -0
  13. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/__init__.py +0 -0
  14. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/cli.py +0 -0
  15. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/cli_config.py +0 -0
  16. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/config/__init__.py +0 -0
  17. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/config/config.py +0 -0
  18. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/errors.py +0 -0
  19. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/__init__.py +0 -0
  20. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/base/__init__.py +0 -0
  21. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/base/base.py +0 -0
  22. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/base/ytdlp.py +0 -0
  23. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/__init__.py +0 -0
  24. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/bilibili.py +0 -0
  25. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/coolapk.py +0 -0
  26. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/douyin.py +0 -0
  27. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/facebook.py +0 -0
  28. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  29. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/pipix.py +0 -0
  30. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/snapchat.py +0 -0
  31. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/threads.py +0 -0
  32. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/tieba.py +0 -0
  33. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/tiktok.py +0 -0
  34. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/twitter.py +0 -0
  35. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/weibo.py +0 -0
  36. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/weixin.py +0 -0
  37. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/xhs.py +0 -0
  38. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  39. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/youtube.py +0 -0
  40. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  41. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/__init__.py +0 -0
  42. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/bilibili.py +0 -0
  43. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/coolapk.py +0 -0
  44. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/douyin.py +0 -0
  45. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/instagram.py +0 -0
  46. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/kuaishou.py +0 -0
  47. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/pipix.py +0 -0
  48. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/threads.py +0 -0
  49. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/tieba.py +0 -0
  50. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/tiktok.py +0 -0
  51. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/twitter.py +0 -0
  52. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/weibo.py +0 -0
  53. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/weixin.py +0 -0
  54. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  55. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/provider_api/zuiyou.py +0 -0
  56. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/types/__init__.py +0 -0
  57. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/types/callback.py +0 -0
  58. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/types/media_file.py +0 -0
  59. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/types/media_ref.py +0 -0
  60. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/types/platform.py +0 -0
  61. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/types/post.py +0 -0
  62. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/types/result.py +0 -0
  63. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/utils/downloader.py +0 -0
  64. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/utils/helpers.py +0 -0
  65. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub/utils/media_info.py +0 -0
  66. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub.egg-info/dependency_links.txt +0 -0
  67. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub.egg-info/entry_points.txt +0 -0
  68. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub.egg-info/requires.txt +0 -0
  69. {parsehub-2.0.34 → parsehub-2.0.35}/src/parsehub.egg-info/top_level.txt +0 -0
  70. {parsehub-2.0.34 → parsehub-2.0.35}/test/test_cli.py +0 -0
  71. {parsehub-2.0.34 → parsehub-2.0.35}/test/test_cli_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.0.34
3
+ Version: 2.0.35
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.0.34"
3
+ version = "2.0.35"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -9,7 +9,7 @@ from ..base.base import BaseParser
9
9
  class InstagramParser(BaseParser):
10
10
  __platform__ = Platform.INSTAGRAM
11
11
  __supported_type__ = ["视频", "图文"]
12
- __match__ = r"^(http(s)?://)(www\.|)instagram\.com/(p|reel|share|.*/p|.*/reel)/.*"
12
+ __match__ = r"^(http(s)?://)(www\.|)instagram\.com/(p|reels|share|.*/p)/.*"
13
13
  __redirect_keywords__ = ["share"]
14
14
 
15
15
  async def _do_parse(self, raw_url: str) -> VideoParseResult | ImageParseResult | MultimediaParseResult:
@@ -67,12 +67,12 @@ class InstagramParser(BaseParser):
67
67
  text = f"Instagram 账号可能已被封禁\n\n使用的Cookie: {cookie}"
68
68
  else:
69
69
  text = str(e)
70
- raise ParseError(f"无法获取帖子内容: {text}") from e
70
+ raise ParseError(f"无法获取帖子内容(可能为私人内容): {text}") from e
71
71
 
72
72
  @staticmethod
73
73
  def get_short_code(url: str) -> str | None:
74
74
  url = url.removesuffix("/")
75
- shortcode = re.search(r"/(share|p|reel|.*/p|.*/reel)/(.*)", url)
75
+ shortcode = re.search(r"/(share|p|reels|.*/p)/(.*)", url)
76
76
  return shortcode.group(2).split("/")[0] if shortcode else None
77
77
 
78
78
 
@@ -122,14 +122,14 @@ class XHSAPI:
122
122
 
123
123
  @staticmethod
124
124
  def get_trace_id(img_url: str) -> str:
125
- trace_id = img_url.split("/")[-1].split("!")[0]
126
- if "spectrum" in img_url:
127
- return "spectrum/" + trace_id
128
- if "note_pre_post_uhdr" in img_url:
129
- return "note_pre_post_uhdr/" + trace_id
130
- if "notes_pre_post" in img_url:
131
- return "notes_pre_post/" + trace_id
132
- return trace_id
125
+ match = re.search(r"/(spectrum|note_pre_post_uhdr|notes_pre_post|notes_uhdr)/([^/!]+)(?:!.*)?$", img_url)
126
+ if match:
127
+ return f"{match.group(1)}/{match.group(2)}"
128
+
129
+ match = re.search(r"/([^/!]+)(?:!.*)?$", img_url)
130
+ if match:
131
+ return match.group(1)
132
+ return img_url
133
133
 
134
134
  def get_raw_image_url(self, ime_url: str) -> str:
135
135
  """拼接无水印图片链接"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.0.34
3
+ Version: 2.0.35
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -65,4 +65,5 @@ src/parsehub/utils/helpers.py
65
65
  src/parsehub/utils/media_info.py
66
66
  test/test_cli.py
67
67
  test/test_cli_config.py
68
- test/test_core_offline.py
68
+ test/test_core_offline.py
69
+ test/test_downloader.py
@@ -238,6 +238,7 @@ class TestPlatformUrlMatching(unittest.TestCase):
238
238
  "https://www.instagram.com/share/BAexample/",
239
239
  "https://www.instagram.com/user.name/p/C0example/",
240
240
  "https://www.instagram.com/user.name/reel/C0example/",
241
+ "https://www.instagram.com/reels/DaGI8bPS3ed/",
241
242
  ],
242
243
  Platform.KUAISHOU: [
243
244
  "https://www.kuaishou.com/short-video/3xexample",
@@ -0,0 +1,161 @@
1
+ import contextlib
2
+ import threading
3
+ import unittest
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory
7
+ from typing import ClassVar
8
+
9
+ from parsehub.errors import DownloadError
10
+ from parsehub.utils.downloader import download
11
+
12
+
13
+ class RangeTestHandler(BaseHTTPRequestHandler):
14
+ content: ClassVar[bytes] = b""
15
+ support_range: ClassVar[bool] = True
16
+ fail_all: ClassVar[bool] = False
17
+ requests: ClassVar[list[tuple[str, str | None]]] = []
18
+
19
+ def log_message(self, format: str, *args: object) -> None:
20
+ return
21
+
22
+ def do_HEAD(self) -> None:
23
+ self.__class__.requests.append(("HEAD", self.headers.get("Range")))
24
+ if self.fail_all:
25
+ self.send_response(500)
26
+ self.end_headers()
27
+ return
28
+
29
+ self.send_response(200)
30
+ self.send_header("Content-Length", str(len(self.content)))
31
+ self.send_header("Accept-Ranges", "bytes" if self.support_range else "none")
32
+ self.end_headers()
33
+
34
+ def do_GET(self) -> None:
35
+ range_header = self.headers.get("Range")
36
+ self.__class__.requests.append(("GET", range_header))
37
+ if self.fail_all:
38
+ self.send_response(500)
39
+ self.end_headers()
40
+ return
41
+
42
+ if range_header and self.support_range:
43
+ start, end = self._parse_range(range_header)
44
+ if start >= len(self.content):
45
+ self.send_response(416)
46
+ self.send_header("Content-Range", f"bytes */{len(self.content)}")
47
+ self.end_headers()
48
+ return
49
+
50
+ end = min(end, len(self.content) - 1)
51
+ body = self.content[start : end + 1]
52
+ self.send_response(206)
53
+ self.send_header("Content-Length", str(len(body)))
54
+ self.send_header("Content-Range", f"bytes {start}-{end}/{len(self.content)}")
55
+ self.send_header("Accept-Ranges", "bytes")
56
+ self.end_headers()
57
+ self.wfile.write(body)
58
+ return
59
+
60
+ self.send_response(200)
61
+ self.send_header("Content-Length", str(len(self.content)))
62
+ self.send_header("Accept-Ranges", "none")
63
+ self.end_headers()
64
+ self.wfile.write(self.content)
65
+
66
+ @staticmethod
67
+ def _parse_range(header: str) -> tuple[int, int]:
68
+ prefix = "bytes="
69
+ if not header.startswith(prefix):
70
+ return 0, 0
71
+ start_text, end_text = header.removeprefix(prefix).split("-", 1)
72
+ return int(start_text), int(end_text)
73
+
74
+
75
+ @contextlib.contextmanager
76
+ def range_server(*, content: bytes, support_range: bool = True, fail_all: bool = False):
77
+ class Handler(RangeTestHandler):
78
+ pass
79
+
80
+ Handler.content = content
81
+ Handler.support_range = support_range
82
+ Handler.fail_all = fail_all
83
+ Handler.requests = []
84
+
85
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
86
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
87
+ thread.start()
88
+ try:
89
+ yield f"http://127.0.0.1:{server.server_port}/file.bin", Handler
90
+ finally:
91
+ server.shutdown()
92
+ server.server_close()
93
+ thread.join(timeout=5)
94
+
95
+
96
+ class DownloaderTest(unittest.IsolatedAsyncioTestCase):
97
+ async def test_download_uses_range_parts_when_server_supports_range(self):
98
+ content = bytes(range(251)) * 20
99
+ progresses: list[tuple[int, int]] = []
100
+
101
+ async def progress(current: int, total: int) -> None:
102
+ progresses.append((current, total))
103
+
104
+ with TemporaryDirectory() as tmp, range_server(content=content, support_range=True) as (url, handler):
105
+ target = Path(tmp) / "video.bin"
106
+
107
+ path = await download(
108
+ url,
109
+ target,
110
+ progress=progress,
111
+ connections=4,
112
+ min_split_size=512,
113
+ chunk_size=128,
114
+ )
115
+
116
+ self.assertEqual(Path(path), target)
117
+ self.assertEqual(target.read_bytes(), content)
118
+ self.assertEqual(progresses[-1], (len(content), len(content)))
119
+ range_gets = [range_header for method, range_header in handler.requests if method == "GET" and range_header]
120
+ self.assertGreaterEqual(len(range_gets), 2)
121
+ self.assertFalse(list(Path(tmp).glob(".*.parsehub-tmp")))
122
+
123
+ async def test_download_falls_back_to_single_request_when_range_is_ignored(self):
124
+ content = b"fallback-body" * 100
125
+
126
+ with TemporaryDirectory() as tmp, range_server(content=content, support_range=False) as (url, handler):
127
+ target = Path(tmp) / "image.bin"
128
+
129
+ await download(url, target, connections=4, min_split_size=10, chunk_size=32)
130
+
131
+ self.assertEqual(target.read_bytes(), content)
132
+ self.assertIn(("GET", "bytes=0-0"), handler.requests)
133
+ self.assertIn(("GET", None), handler.requests)
134
+ self.assertFalse(list(Path(tmp).glob(".*.parsehub-tmp")))
135
+
136
+ async def test_download_keeps_existing_file_when_request_fails(self):
137
+ with TemporaryDirectory() as tmp, range_server(content=b"new", support_range=True, fail_all=True) as (url, _):
138
+ target = Path(tmp) / "video.bin"
139
+ target.write_bytes(b"old")
140
+
141
+ with self.assertRaises(DownloadError):
142
+ await download(url, target, connections=4, max_retries=0)
143
+
144
+ self.assertEqual(target.read_bytes(), b"old")
145
+ self.assertFalse(list(Path(tmp).glob(".*.parsehub-tmp")))
146
+
147
+ async def test_connections_one_uses_single_request(self):
148
+ content = b"single" * 200
149
+
150
+ with TemporaryDirectory() as tmp, range_server(content=content, support_range=True) as (url, handler):
151
+ target = Path(tmp) / "single.bin"
152
+
153
+ await download(url, target, connections=1, min_split_size=10)
154
+
155
+ self.assertEqual(target.read_bytes(), content)
156
+ range_gets = [range_header for method, range_header in handler.requests if method == "GET" and range_header]
157
+ self.assertEqual(range_gets, [])
158
+
159
+
160
+ if __name__ == "__main__":
161
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes