parsehub 2.3.15__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 (88) hide show
  1. {parsehub-2.3.15/src/parsehub.egg-info → parsehub-2.3.17}/PKG-INFO +1 -1
  2. {parsehub-2.3.15 → parsehub-2.3.17}/pyproject.toml +1 -1
  3. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/base/ytdlp.py +1 -1
  4. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/instagram.py +52 -21
  5. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/zuiyou.py +1 -1
  6. parsehub-2.3.17/src/parsehub/provider_api/instagram.py +399 -0
  7. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/zuiyou.py +10 -2
  8. {parsehub-2.3.15 → parsehub-2.3.17/src/parsehub.egg-info}/PKG-INFO +1 -1
  9. {parsehub-2.3.15 → 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.15/src/parsehub/provider_api/instagram.py +0 -433
  13. {parsehub-2.3.15 → parsehub-2.3.17}/LICENSE +0 -0
  14. {parsehub-2.3.15 → parsehub-2.3.17}/README.md +0 -0
  15. {parsehub-2.3.15 → parsehub-2.3.17}/setup.cfg +0 -0
  16. {parsehub-2.3.15 → parsehub-2.3.17}/src/__init__.py +0 -0
  17. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/__init__.py +0 -0
  18. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/cli.py +0 -0
  19. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/cli_config.py +0 -0
  20. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/config/__init__.py +0 -0
  21. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/config/config.py +0 -0
  22. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/errors.py +0 -0
  23. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/__init__.py +0 -0
  24. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/base/__init__.py +0 -0
  25. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/base/base.py +0 -0
  26. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/__init__.py +0 -0
  27. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/bangumi.py +0 -0
  28. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/bilibili.py +0 -0
  29. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/coolapk.py +0 -0
  30. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/douban.py +0 -0
  31. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/douyin.py +0 -0
  32. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/facebook.py +0 -0
  33. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  34. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/medium.py +0 -0
  35. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/pipix.py +0 -0
  36. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/snapchat.py +0 -0
  37. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/threads.py +0 -0
  38. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/tieba.py +0 -0
  39. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/tiktok.py +0 -0
  40. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/toutiao.py +0 -0
  41. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/twitter.py +0 -0
  42. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/weibo.py +0 -0
  43. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/weixin.py +0 -0
  44. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/wxchannels.py +0 -0
  45. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/xhs.py +0 -0
  46. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  47. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/youtube.py +0 -0
  48. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/zhihu.py +0 -0
  49. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/__init__.py +0 -0
  50. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/bangumi.py +0 -0
  51. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/bilibili.py +0 -0
  52. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/coolapk.py +0 -0
  53. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/douban.py +0 -0
  54. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/douyin.py +0 -0
  55. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/kuaishou.py +0 -0
  56. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/medium.py +0 -0
  57. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/pipix.py +0 -0
  58. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/threads.py +0 -0
  59. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/tieba.py +0 -0
  60. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/tiktok.py +0 -0
  61. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/toutiao.py +0 -0
  62. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/twitter.py +0 -0
  63. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/weibo.py +0 -0
  64. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/weixin.py +0 -0
  65. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/wxchannels.py +0 -0
  66. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/xhs.py +0 -0
  67. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  68. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/zhihu.py +0 -0
  69. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/__init__.py +0 -0
  70. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/callback.py +0 -0
  71. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/media_file.py +0 -0
  72. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/media_ref.py +0 -0
  73. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/platform.py +0 -0
  74. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/post.py +0 -0
  75. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/result.py +0 -0
  76. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/downloader.py +0 -0
  77. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/fallback.py +0 -0
  78. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/helpers.py +0 -0
  79. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/media_info.py +0 -0
  80. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/dependency_links.txt +0 -0
  81. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/entry_points.txt +0 -0
  82. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/requires.txt +0 -0
  83. {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/top_level.txt +0 -0
  84. {parsehub-2.3.15 → parsehub-2.3.17}/test/test_bilibili_offline.py +0 -0
  85. {parsehub-2.3.15 → parsehub-2.3.17}/test/test_cli.py +0 -0
  86. {parsehub-2.3.15 → parsehub-2.3.17}/test/test_cli_config.py +0 -0
  87. {parsehub-2.3.15 → parsehub-2.3.17}/test/test_core_offline.py +0 -0
  88. {parsehub-2.3.15 → 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.15
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.15"
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(
@@ -1,5 +1,7 @@
1
1
  import re
2
2
 
3
+ from loguru import logger
4
+
3
5
  from ...provider_api.instagram import (
4
6
  InstagramAPI,
5
7
  InstagramAPIError,
@@ -19,6 +21,7 @@ from ...types import (
19
21
  )
20
22
  from ...utils.helpers import SecretCookie
21
23
  from ..base.base import BaseParser, FallbackStage
24
+ from ..base.ytdlp import YtParser, YtVideoParseResult
22
25
 
23
26
 
24
27
  class InstagramParser(BaseParser):
@@ -34,29 +37,57 @@ class InstagramParser(BaseParser):
34
37
 
35
38
  post = await self._parse(shortcode)
36
39
 
37
- width, height = post.width, post.height
38
-
39
- match post.typename:
40
+ match post.type:
40
41
  case InstagramMediaType.SIDECAR:
41
42
  media = [
42
- VideoRef(url=i.video_url, thumb_url=i.display_url, width=i.width, height=i.height)
43
- if i.is_video and i.video_url
44
- else ImageRef(url=i.display_url, width=i.width, height=i.height)
45
- for i in post.get_sidecar_nodes()
43
+ VideoRef(
44
+ url=item.video_url,
45
+ thumb_url=item.display_url,
46
+ duration=int(item.duration),
47
+ width=item.width,
48
+ height=item.height,
49
+ )
50
+ if item.is_video
51
+ else ImageRef(url=item.display_url, width=item.width, height=item.height)
52
+ for item in post.media
46
53
  ]
47
54
  return MultimediaParseResult(media=media, title=post.title, content=post.caption)
48
55
  case InstagramMediaType.IMAGE:
56
+ item = post.media[0]
49
57
  return ImageParseResult(
50
- photo=[ImageRef(url=post.url, width=width, height=height)], title=post.title, content=post.caption
58
+ photo=[ImageRef(url=item.display_url, width=item.width, height=item.height)],
59
+ title=post.title,
60
+ content=post.caption,
51
61
  )
52
62
  case InstagramMediaType.VIDEO:
63
+ item = post.media[0]
64
+ try:
65
+ info = await InstagramYtParse(proxy=self.proxy)._parse(f"https://www.instagram.com/p/{shortcode}/")
66
+ except ParseError as e:
67
+ logger.warning(f"Instagram yt-dlp 解析失败, 使用直链视频: {e}")
68
+ else:
69
+ return YtVideoParseResult(
70
+ dl=info,
71
+ title=post.title,
72
+ content=post.caption,
73
+ video=VideoRef(
74
+ # yt-dlp 的 info.url 是帖子页,provider 的 video_url 才是媒体直链
75
+ url=item.video_url or info.url,
76
+ thumb_url=item.display_url or info.thumbnail,
77
+ width=info.width,
78
+ height=info.height,
79
+ duration=int(item.duration or info.duration),
80
+ ),
81
+ )
82
+ if not item.video_url:
83
+ raise ParseError("未获取到视频地址")
53
84
  return VideoParseResult(
54
85
  video=VideoRef(
55
- url=post.video_url or post.url,
56
- thumb_url=post.url,
57
- duration=int(post.video_duration or 0),
58
- width=width,
59
- height=height,
86
+ url=item.video_url,
87
+ thumb_url=item.display_url,
88
+ duration=int(item.duration),
89
+ width=item.width,
90
+ height=item.height,
60
91
  ),
61
92
  title=post.title,
62
93
  content=post.caption,
@@ -80,14 +111,10 @@ class InstagramParser(BaseParser):
80
111
  return await api.get_post(shortcode)
81
112
  except ContentUnavailableError:
82
113
  raise
83
- except Exception as e:
84
- if isinstance(e, InstagramRestrictedError):
85
- text = str(e)
86
- elif isinstance(e, InstagramAPIError):
87
- text = f"无法获取帖子内容: {e}"
88
- else:
89
- text = f"无法获取帖子内容(可能为私人内容): {e}"
90
- raise ParseError(text) from e
114
+ except InstagramRestrictedError as e:
115
+ raise ParseError(str(e)) from e
116
+ except InstagramAPIError as e:
117
+ raise ParseError(f"无法获取帖子内容: {e}") from e
91
118
 
92
119
  @staticmethod
93
120
  def get_short_code(url: str) -> str | None:
@@ -96,4 +123,8 @@ class InstagramParser(BaseParser):
96
123
  return shortcode.group(2).split("/")[0] if shortcode else None
97
124
 
98
125
 
126
+ class InstagramYtParse(YtParser, register=False):
127
+ pass
128
+
129
+
99
130
  __all__ = ["InstagramParser"]
@@ -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
@@ -0,0 +1,399 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from curl_cffi import requests
11
+
12
+ from ..types import ContentUnavailableError
13
+
14
+
15
+ class InstagramAPIError(RuntimeError):
16
+ """Instagram 接口请求或响应解析失败。"""
17
+
18
+
19
+ class InstagramRestrictedError(InstagramAPIError):
20
+ """Instagram 内容受限。"""
21
+
22
+
23
+ class InstagramMediaType(StrEnum):
24
+ IMAGE = "GraphImage"
25
+ VIDEO = "GraphVideo"
26
+ SIDECAR = "GraphSidecar"
27
+
28
+
29
+ @dataclass(slots=True, kw_only=True)
30
+ class InstagramMedia:
31
+ is_video: bool
32
+ display_url: str
33
+ video_url: str = ""
34
+ width: int = 0
35
+ height: int = 0
36
+ duration: float = 0
37
+
38
+
39
+ @dataclass(slots=True, kw_only=True)
40
+ class InstagramPost:
41
+ shortcode: str
42
+ type: InstagramMediaType
43
+ caption: str = ""
44
+ title: str = ""
45
+ media: list[InstagramMedia]
46
+
47
+ @classmethod
48
+ def from_v1(cls, item: dict[str, Any]) -> InstagramPost:
49
+ media_type = _v1_type(item)
50
+ if media_type is InstagramMediaType.SIDECAR:
51
+ media_items = item.get("carousel_media") or []
52
+ if not media_items:
53
+ raise InstagramAPIError("未返回帖子数据")
54
+ else:
55
+ media_items = [item]
56
+ if not all(isinstance(media_item, dict) for media_item in media_items):
57
+ raise InstagramAPIError("Instagram 帖子数据格式无效")
58
+ return cls(
59
+ shortcode=str(item.get("code") or item.get("shortcode") or ""),
60
+ type=media_type,
61
+ caption=_caption_text(item.get("caption")),
62
+ title=str(item.get("title") or ""),
63
+ media=[_v1_media(media_item) for media_item in media_items],
64
+ )
65
+
66
+ @classmethod
67
+ def from_graph(cls, node: dict[str, Any]) -> InstagramPost:
68
+ media_type = _graph_type(node)
69
+ if media_type is InstagramMediaType.SIDECAR:
70
+ edges = (node.get("edge_sidecar_to_children") or {}).get("edges") or []
71
+ if not edges:
72
+ raise InstagramAPIError("未返回帖子数据")
73
+ media = [_graph_media(edge.get("node") or {}) for edge in edges]
74
+ else:
75
+ media = [_graph_media(node)]
76
+
77
+ caption_edges = (node.get("edge_media_to_caption") or {}).get("edges") or []
78
+ caption = caption_edges[0].get("node", {}).get("text", "") if caption_edges else ""
79
+ return cls(
80
+ shortcode=str(node.get("shortcode") or node.get("code") or ""),
81
+ type=media_type,
82
+ caption=str(caption or node.get("caption") or ""),
83
+ title=str(node.get("title") or ""),
84
+ media=media,
85
+ )
86
+
87
+
88
+ class InstagramAPI:
89
+ HOME_URL = "https://www.instagram.com/"
90
+ GRAPHQL_URL = "https://www.instagram.com/api/graphql"
91
+ EMBED_URL = "https://www.instagram.com/{kind}/{shortcode}/embed/captioned/"
92
+ MEDIA_INFO_URL = "https://www.instagram.com/api/v1/media/{media_id}/info/"
93
+ RULING_URL = "https://www.instagram.com/api/v1/web/get_ruling_for_content/"
94
+ APP_ID = "936619743392459"
95
+ ASBD_ID = "359341"
96
+ DOC_ID = "27130156389949648"
97
+ MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15"
98
+
99
+ def __init__(
100
+ self,
101
+ *,
102
+ proxy: str | None = None,
103
+ cookie: dict[str, str] | None = None,
104
+ timeout: float = 30,
105
+ ):
106
+ self.proxy = proxy
107
+ self.cookie = cookie or {}
108
+ self.timeout = timeout
109
+
110
+ async def get_post(self, shortcode: str) -> InstagramPost:
111
+ try:
112
+ async with requests.AsyncSession(
113
+ impersonate="chrome",
114
+ proxy=self.proxy,
115
+ timeout=self.timeout,
116
+ cookies=self.cookie or None,
117
+ ) as session:
118
+ if self.cookie:
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)
123
+ except requests.RequestsError as exc:
124
+ raise InstagramAPIError(f"请求 Instagram 失败: {exc}") from exc
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
+
167
+ async def _get_logged_out_post(self, session: requests.AsyncSession, shortcode: str) -> InstagramPost:
168
+ detail = "未返回帖子数据"
169
+ media_id = str(self.shortcode_to_media_id(shortcode))
170
+
171
+ try:
172
+ item = await self._fetch_graphql(session, shortcode, media_id)
173
+ if item is not None:
174
+ return InstagramPost.from_v1(item)
175
+ except InstagramAPIError as exc:
176
+ detail = str(exc)
177
+ except requests.RequestsError as exc:
178
+ detail = f"请求 Instagram 失败: {exc}"
179
+
180
+ try:
181
+ node = await self._fetch_embed(session, shortcode)
182
+ if node is not None:
183
+ return InstagramPost.from_graph(node)
184
+ except InstagramAPIError as exc:
185
+ detail = str(exc)
186
+ except requests.RequestsError as exc:
187
+ detail = f"请求 Instagram Embed 失败: {exc}"
188
+
189
+ await self._check_ruling(session, media_id)
190
+ raise InstagramAPIError(detail)
191
+
192
+ async def _fetch_graphql(
193
+ self, session: requests.AsyncSession, shortcode: str, media_id: str
194
+ ) -> dict[str, Any] | None:
195
+ response = await session.get(self.HOME_URL)
196
+ if response.status_code != 200:
197
+ raise InstagramAPIError(f"HTTP {response.status_code}: {response.text[:300]}")
198
+
199
+ match = re.search(r'\["LSD",\[\],\{"token":"([^"]+)"', response.text)
200
+ if not match:
201
+ raise InstagramAPIError(f"未能获取 Instagram LSD token: {response.text[:300]}")
202
+ lsd = match.group(1)
203
+ csrf = session.cookies.get("csrftoken", "")
204
+ headers = {
205
+ "X-IG-App-ID": self.APP_ID,
206
+ "X-ASBD-ID": self.ASBD_ID,
207
+ "X-IG-WWW-Claim": "0",
208
+ "Origin": self.HOME_URL.rstrip("/"),
209
+ "Accept": "*/*",
210
+ "X-FB-Friendly-Name": "PolarisLoggedOutDesktopWWWPostRootContentQuery",
211
+ "X-CSRFToken": csrf,
212
+ "X-FB-LSD": lsd,
213
+ "X-Requested-With": "XMLHttpRequest",
214
+ "Referer": f"https://www.instagram.com/p/{shortcode}/",
215
+ }
216
+ data = {
217
+ "lsd": lsd,
218
+ "fb_api_caller_class": "RelayModern",
219
+ "fb_api_req_friendly_name": "PolarisLoggedOutDesktopWWWPostRootContentQuery",
220
+ "server_timestamps": "true",
221
+ "variables": json.dumps({"media_id": media_id}, separators=(",", ":")),
222
+ "doc_id": self.DOC_ID,
223
+ }
224
+ response = await session.post(self.GRAPHQL_URL, headers=headers, data=data)
225
+ if response.status_code != 200:
226
+ raise InstagramAPIError(f"HTTP {response.status_code}: {response.text[:300]}")
227
+ payload = _response_json(response)
228
+ graphql_data = payload.get("data")
229
+ media = graphql_data.get("xig_polaris_media") if isinstance(graphql_data, dict) else None
230
+ item = media.get("if_not_gated_logged_out") if isinstance(media, dict) else None
231
+ if isinstance(item, dict):
232
+ return item
233
+ errors = payload.get("errors")
234
+ if errors:
235
+ raise InstagramAPIError(str(errors[0])[:300])
236
+ return None
237
+
238
+ async def _fetch_embed(self, session: requests.AsyncSession, shortcode: str) -> dict[str, Any] | None:
239
+ for kind in ("p", "reel"):
240
+ response = await session.get(
241
+ self.EMBED_URL.format(kind=kind, shortcode=shortcode),
242
+ headers={"Accept": "text/html", "User-Agent": self.MOBILE_USER_AGENT},
243
+ allow_redirects=False,
244
+ )
245
+ if response.status_code != 200:
246
+ continue
247
+ node = self._extract_embed_media(response.text)
248
+ if node is not None:
249
+ return node
250
+ return None
251
+
252
+ @staticmethod
253
+ def _extract_embed_media(html: str) -> dict[str, Any] | None:
254
+ match = re.search(r'"init",\[\],\[(.*?)\]\],', html, re.DOTALL)
255
+ if not match:
256
+ return None
257
+ try:
258
+ payload = json.loads(f"[{match.group(1)}]")
259
+ context = payload[0].get("contextJSON")
260
+ context = json.loads(context) if isinstance(context, str) else context
261
+ except (IndexError, AttributeError, json.JSONDecodeError, TypeError):
262
+ return None
263
+ if not isinstance(context, dict):
264
+ return None
265
+ node = (context.get("gql_data") or {}).get("shortcode_media")
266
+ return node if isinstance(node, dict) else None
267
+
268
+ async def _check_ruling(self, session: requests.AsyncSession, media_id: str) -> None:
269
+ try:
270
+ response = await session.get(
271
+ self.RULING_URL,
272
+ params={"content_type": "MEDIA", "target_id": media_id},
273
+ headers={"X-IG-App-ID": self.APP_ID, "Accept-Language": "zh-CN,zh;q=0.9"},
274
+ )
275
+ body = _response_json(response)
276
+ except (InstagramAPIError, requests.RequestsError):
277
+ return
278
+
279
+ if body.get("message") == "Media cannot be found":
280
+ raise ContentUnavailableError("帖子不存在或已被删除")
281
+ if title := body.get("title"):
282
+ description = body.get("description")
283
+ raise InstagramRestrictedError(f"{title}: {description}" if description else str(title))
284
+
285
+ async def _get_cookie_post(self, session: requests.AsyncSession, shortcode: str) -> InstagramPost:
286
+ media_id = self.shortcode_to_media_id(shortcode)
287
+ response = await session.get(
288
+ self.MEDIA_INFO_URL.format(media_id=media_id),
289
+ headers={
290
+ "X-IG-App-ID": self.APP_ID,
291
+ "X-ASBD-ID": self.ASBD_ID,
292
+ "X-IG-WWW-Claim": "0",
293
+ "X-CSRFToken": self.cookie.get("csrftoken", ""),
294
+ "Origin": self.HOME_URL.rstrip("/"),
295
+ "Referer": f"https://www.instagram.com/p/{shortcode}/",
296
+ },
297
+ allow_redirects=False,
298
+ )
299
+ location = response.headers.get("Location", "")
300
+ redirect_path = urlparse(location).path
301
+ if response.status_code in (301, 302, 303, 307, 308) and (
302
+ redirect_path.startswith("/accounts/login") or redirect_path == "/"
303
+ ):
304
+ raise InstagramAPIError("Cookie 已失效")
305
+ if response.status_code != 200:
306
+ raise InstagramAPIError(f"HTTP {response.status_code}: {response.text[:300]}")
307
+
308
+ payload = _response_json(response)
309
+ items = payload.get("items") or []
310
+ if not items or not isinstance(items[0], dict):
311
+ raise InstagramAPIError("未返回帖子数据")
312
+ return InstagramPost.from_v1(items[0])
313
+
314
+ @staticmethod
315
+ def shortcode_to_media_id(shortcode: str) -> int:
316
+ if len(shortcode) > 28:
317
+ shortcode = shortcode[:-28]
318
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
319
+ media_id = 0
320
+ for char in shortcode:
321
+ media_id = media_id * 64 + alphabet.index(char)
322
+ return media_id
323
+
324
+
325
+ def _response_json(response: Any) -> dict[str, Any]:
326
+ try:
327
+ payload = response.json()
328
+ except (ValueError, requests.RequestsError) as exc:
329
+ raise InstagramAPIError(f"Instagram 返回无效 JSON: {response.text[:300]}") from exc
330
+ if not isinstance(payload, dict):
331
+ raise InstagramAPIError("Instagram 返回的数据格式无效")
332
+ return payload
333
+
334
+
335
+ def _caption_text(caption: Any) -> str:
336
+ if isinstance(caption, dict):
337
+ caption = caption.get("text")
338
+ return str(caption or "")
339
+
340
+
341
+ def _v1_type(item: dict[str, Any]) -> InstagramMediaType:
342
+ media_types = {
343
+ 1: InstagramMediaType.IMAGE,
344
+ 2: InstagramMediaType.VIDEO,
345
+ 8: InstagramMediaType.SIDECAR,
346
+ }
347
+ media_type = item.get("media_type")
348
+ if isinstance(media_type, int) and media_type in media_types:
349
+ return media_types[media_type]
350
+ raise InstagramAPIError(f"不支持的 Instagram 媒体类型: {media_type}")
351
+
352
+
353
+ def _v1_media(item: dict[str, Any]) -> InstagramMedia:
354
+ candidates = (item.get("image_versions2") or {}).get("candidates") or []
355
+ candidate = candidates[0] if candidates and isinstance(candidates[0], dict) else {}
356
+ video_versions = item.get("video_versions") or []
357
+ video = video_versions[0] if video_versions and isinstance(video_versions[0], dict) else {}
358
+ video_url = str(video.get("url") or "")
359
+ return InstagramMedia(
360
+ is_video=bool(video_url),
361
+ display_url=str(candidate.get("url") or item.get("display_url") or ""),
362
+ video_url=video_url,
363
+ width=int(item.get("original_width") or candidate.get("width") or 0),
364
+ height=int(item.get("original_height") or candidate.get("height") or 0),
365
+ duration=float(item.get("video_duration") or 0),
366
+ )
367
+
368
+
369
+ def _graph_type(node: dict[str, Any]) -> InstagramMediaType:
370
+ typename = node.get("__typename")
371
+ if not isinstance(typename, str):
372
+ raise InstagramAPIError(f"不支持的 Instagram 媒体类型: {typename}")
373
+ try:
374
+ return InstagramMediaType(typename)
375
+ except ValueError as exc:
376
+ raise InstagramAPIError(f"不支持的 Instagram 媒体类型: {typename}") from exc
377
+
378
+
379
+ def _graph_media(node: dict[str, Any]) -> InstagramMedia:
380
+ video_url = str(node.get("video_url") or "")
381
+ dimensions = node.get("dimensions") or {}
382
+ return InstagramMedia(
383
+ is_video=bool(video_url),
384
+ display_url=str(node.get("display_url") or node.get("display_src") or ""),
385
+ video_url=video_url,
386
+ width=int(dimensions.get("width") or 0),
387
+ height=int(dimensions.get("height") or 0),
388
+ duration=float(node.get("video_duration") or 0),
389
+ )
390
+
391
+
392
+ __all__ = [
393
+ "InstagramAPI",
394
+ "InstagramAPIError",
395
+ "InstagramMedia",
396
+ "InstagramMediaType",
397
+ "InstagramPost",
398
+ "InstagramRestrictedError",
399
+ ]
@@ -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.15
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