parsehub 2.3.15__tar.gz → 2.3.16__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 (86) hide show
  1. {parsehub-2.3.15/src/parsehub.egg-info → parsehub-2.3.16}/PKG-INFO +1 -1
  2. {parsehub-2.3.15 → parsehub-2.3.16}/pyproject.toml +1 -1
  3. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/instagram.py +51 -21
  4. parsehub-2.3.16/src/parsehub/provider_api/instagram.py +356 -0
  5. {parsehub-2.3.15 → parsehub-2.3.16/src/parsehub.egg-info}/PKG-INFO +1 -1
  6. parsehub-2.3.15/src/parsehub/provider_api/instagram.py +0 -433
  7. {parsehub-2.3.15 → parsehub-2.3.16}/LICENSE +0 -0
  8. {parsehub-2.3.15 → parsehub-2.3.16}/README.md +0 -0
  9. {parsehub-2.3.15 → parsehub-2.3.16}/setup.cfg +0 -0
  10. {parsehub-2.3.15 → parsehub-2.3.16}/src/__init__.py +0 -0
  11. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/__init__.py +0 -0
  12. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/cli.py +0 -0
  13. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/cli_config.py +0 -0
  14. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/config/__init__.py +0 -0
  15. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/config/config.py +0 -0
  16. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/errors.py +0 -0
  17. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/__init__.py +0 -0
  18. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/base/__init__.py +0 -0
  19. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/base/base.py +0 -0
  20. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/base/ytdlp.py +0 -0
  21. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/__init__.py +0 -0
  22. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/bangumi.py +0 -0
  23. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/bilibili.py +0 -0
  24. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/coolapk.py +0 -0
  25. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/douban.py +0 -0
  26. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/douyin.py +0 -0
  27. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/facebook.py +0 -0
  28. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  29. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/medium.py +0 -0
  30. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/pipix.py +0 -0
  31. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/snapchat.py +0 -0
  32. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/threads.py +0 -0
  33. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/tieba.py +0 -0
  34. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/tiktok.py +0 -0
  35. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/toutiao.py +0 -0
  36. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/twitter.py +0 -0
  37. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/weibo.py +0 -0
  38. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/weixin.py +0 -0
  39. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/wxchannels.py +0 -0
  40. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/xhs.py +0 -0
  41. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  42. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/youtube.py +0 -0
  43. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/zhihu.py +0 -0
  44. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  45. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/__init__.py +0 -0
  46. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/bangumi.py +0 -0
  47. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/bilibili.py +0 -0
  48. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/coolapk.py +0 -0
  49. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/douban.py +0 -0
  50. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/douyin.py +0 -0
  51. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/kuaishou.py +0 -0
  52. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/medium.py +0 -0
  53. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/pipix.py +0 -0
  54. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/threads.py +0 -0
  55. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/tieba.py +0 -0
  56. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/tiktok.py +0 -0
  57. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/toutiao.py +0 -0
  58. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/twitter.py +0 -0
  59. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/weibo.py +0 -0
  60. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/weixin.py +0 -0
  61. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/wxchannels.py +0 -0
  62. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/xhs.py +0 -0
  63. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  64. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/zhihu.py +0 -0
  65. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/provider_api/zuiyou.py +0 -0
  66. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/types/__init__.py +0 -0
  67. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/types/callback.py +0 -0
  68. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/types/media_file.py +0 -0
  69. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/types/media_ref.py +0 -0
  70. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/types/platform.py +0 -0
  71. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/types/post.py +0 -0
  72. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/types/result.py +0 -0
  73. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/utils/downloader.py +0 -0
  74. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/utils/fallback.py +0 -0
  75. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/utils/helpers.py +0 -0
  76. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub/utils/media_info.py +0 -0
  77. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub.egg-info/SOURCES.txt +0 -0
  78. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub.egg-info/dependency_links.txt +0 -0
  79. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub.egg-info/entry_points.txt +0 -0
  80. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub.egg-info/requires.txt +0 -0
  81. {parsehub-2.3.15 → parsehub-2.3.16}/src/parsehub.egg-info/top_level.txt +0 -0
  82. {parsehub-2.3.15 → parsehub-2.3.16}/test/test_bilibili_offline.py +0 -0
  83. {parsehub-2.3.15 → parsehub-2.3.16}/test/test_cli.py +0 -0
  84. {parsehub-2.3.15 → parsehub-2.3.16}/test/test_cli_config.py +0 -0
  85. {parsehub-2.3.15 → parsehub-2.3.16}/test/test_core_offline.py +0 -0
  86. {parsehub-2.3.15 → parsehub-2.3.16}/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.16
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.16"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -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,56 @@ 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
+ url=info.url,
75
+ thumb_url=item.display_url or info.thumbnail,
76
+ width=info.width,
77
+ height=info.height,
78
+ duration=int(info.duration or item.duration),
79
+ ),
80
+ )
81
+ if not item.video_url:
82
+ raise ParseError("未获取到视频地址")
53
83
  return VideoParseResult(
54
84
  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,
85
+ url=item.video_url,
86
+ thumb_url=item.display_url,
87
+ duration=int(item.duration),
88
+ width=item.width,
89
+ height=item.height,
60
90
  ),
61
91
  title=post.title,
62
92
  content=post.caption,
@@ -80,14 +110,10 @@ class InstagramParser(BaseParser):
80
110
  return await api.get_post(shortcode)
81
111
  except ContentUnavailableError:
82
112
  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
113
+ except InstagramRestrictedError as e:
114
+ raise ParseError(str(e)) from e
115
+ except InstagramAPIError as e:
116
+ raise ParseError(f"无法获取帖子内容: {e}") from e
91
117
 
92
118
  @staticmethod
93
119
  def get_short_code(url: str) -> str | None:
@@ -96,4 +122,8 @@ class InstagramParser(BaseParser):
96
122
  return shortcode.group(2).split("/")[0] if shortcode else None
97
123
 
98
124
 
125
+ class InstagramYtParse(YtParser, register=False):
126
+ pass
127
+
128
+
99
129
  __all__ = ["InstagramParser"]
@@ -0,0 +1,356 @@
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
+ return await self._get_cookie_post(session, shortcode)
120
+ return await self._get_logged_out_post(session, shortcode)
121
+ except requests.RequestsError as exc:
122
+ raise InstagramAPIError(f"请求 Instagram 失败: {exc}") from exc
123
+
124
+ async def _get_logged_out_post(self, session: requests.AsyncSession, shortcode: str) -> InstagramPost:
125
+ detail = "未返回帖子数据"
126
+ media_id = str(self.shortcode_to_media_id(shortcode))
127
+
128
+ try:
129
+ item = await self._fetch_graphql(session, shortcode, media_id)
130
+ if item is not None:
131
+ return InstagramPost.from_v1(item)
132
+ except InstagramAPIError as exc:
133
+ detail = str(exc)
134
+ except requests.RequestsError as exc:
135
+ detail = f"请求 Instagram 失败: {exc}"
136
+
137
+ try:
138
+ node = await self._fetch_embed(session, shortcode)
139
+ if node is not None:
140
+ return InstagramPost.from_graph(node)
141
+ except InstagramAPIError as exc:
142
+ detail = str(exc)
143
+ except requests.RequestsError as exc:
144
+ detail = f"请求 Instagram Embed 失败: {exc}"
145
+
146
+ await self._check_ruling(session, media_id)
147
+ raise InstagramAPIError(detail)
148
+
149
+ async def _fetch_graphql(
150
+ self, session: requests.AsyncSession, shortcode: str, media_id: str
151
+ ) -> dict[str, Any] | None:
152
+ response = await session.get(self.HOME_URL)
153
+ if response.status_code != 200:
154
+ raise InstagramAPIError(f"HTTP {response.status_code}: {response.text[:300]}")
155
+
156
+ match = re.search(r'\["LSD",\[\],\{"token":"([^"]+)"', response.text)
157
+ if not match:
158
+ raise InstagramAPIError(f"未能获取 Instagram LSD token: {response.text[:300]}")
159
+ lsd = match.group(1)
160
+ csrf = session.cookies.get("csrftoken", "")
161
+ headers = {
162
+ "X-IG-App-ID": self.APP_ID,
163
+ "X-ASBD-ID": self.ASBD_ID,
164
+ "X-IG-WWW-Claim": "0",
165
+ "Origin": self.HOME_URL.rstrip("/"),
166
+ "Accept": "*/*",
167
+ "X-FB-Friendly-Name": "PolarisLoggedOutDesktopWWWPostRootContentQuery",
168
+ "X-CSRFToken": csrf,
169
+ "X-FB-LSD": lsd,
170
+ "X-Requested-With": "XMLHttpRequest",
171
+ "Referer": f"https://www.instagram.com/p/{shortcode}/",
172
+ }
173
+ data = {
174
+ "lsd": lsd,
175
+ "fb_api_caller_class": "RelayModern",
176
+ "fb_api_req_friendly_name": "PolarisLoggedOutDesktopWWWPostRootContentQuery",
177
+ "server_timestamps": "true",
178
+ "variables": json.dumps({"media_id": media_id}, separators=(",", ":")),
179
+ "doc_id": self.DOC_ID,
180
+ }
181
+ response = await session.post(self.GRAPHQL_URL, headers=headers, data=data)
182
+ if response.status_code != 200:
183
+ raise InstagramAPIError(f"HTTP {response.status_code}: {response.text[:300]}")
184
+ payload = _response_json(response)
185
+ graphql_data = payload.get("data")
186
+ media = graphql_data.get("xig_polaris_media") if isinstance(graphql_data, dict) else None
187
+ item = media.get("if_not_gated_logged_out") if isinstance(media, dict) else None
188
+ if isinstance(item, dict):
189
+ return item
190
+ errors = payload.get("errors")
191
+ if errors:
192
+ raise InstagramAPIError(str(errors[0])[:300])
193
+ return None
194
+
195
+ async def _fetch_embed(self, session: requests.AsyncSession, shortcode: str) -> dict[str, Any] | None:
196
+ for kind in ("p", "reel"):
197
+ response = await session.get(
198
+ self.EMBED_URL.format(kind=kind, shortcode=shortcode),
199
+ headers={"Accept": "text/html", "User-Agent": self.MOBILE_USER_AGENT},
200
+ allow_redirects=False,
201
+ )
202
+ if response.status_code != 200:
203
+ continue
204
+ node = self._extract_embed_media(response.text)
205
+ if node is not None:
206
+ return node
207
+ return None
208
+
209
+ @staticmethod
210
+ def _extract_embed_media(html: str) -> dict[str, Any] | None:
211
+ match = re.search(r'"init",\[\],\[(.*?)\]\],', html, re.DOTALL)
212
+ if not match:
213
+ return None
214
+ try:
215
+ payload = json.loads(f"[{match.group(1)}]")
216
+ context = payload[0].get("contextJSON")
217
+ context = json.loads(context) if isinstance(context, str) else context
218
+ except (IndexError, AttributeError, json.JSONDecodeError, TypeError):
219
+ return None
220
+ if not isinstance(context, dict):
221
+ return None
222
+ node = (context.get("gql_data") or {}).get("shortcode_media")
223
+ return node if isinstance(node, dict) else None
224
+
225
+ async def _check_ruling(self, session: requests.AsyncSession, media_id: str) -> None:
226
+ try:
227
+ response = await session.get(
228
+ self.RULING_URL,
229
+ params={"content_type": "MEDIA", "target_id": media_id},
230
+ headers={"X-IG-App-ID": self.APP_ID, "Accept-Language": "zh-CN,zh;q=0.9"},
231
+ )
232
+ body = _response_json(response)
233
+ except (InstagramAPIError, requests.RequestsError):
234
+ return
235
+
236
+ if body.get("message") == "Media cannot be found":
237
+ raise ContentUnavailableError("帖子不存在或已被删除")
238
+ if title := body.get("title"):
239
+ description = body.get("description")
240
+ raise InstagramRestrictedError(f"{title}: {description}" if description else str(title))
241
+
242
+ async def _get_cookie_post(self, session: requests.AsyncSession, shortcode: str) -> InstagramPost:
243
+ media_id = self.shortcode_to_media_id(shortcode)
244
+ response = await session.get(
245
+ self.MEDIA_INFO_URL.format(media_id=media_id),
246
+ headers={
247
+ "X-IG-App-ID": self.APP_ID,
248
+ "X-ASBD-ID": self.ASBD_ID,
249
+ "X-IG-WWW-Claim": "0",
250
+ "X-CSRFToken": self.cookie.get("csrftoken", ""),
251
+ "Origin": self.HOME_URL.rstrip("/"),
252
+ "Referer": f"https://www.instagram.com/p/{shortcode}/",
253
+ },
254
+ allow_redirects=False,
255
+ )
256
+ location = response.headers.get("Location", "")
257
+ redirect_path = urlparse(location).path
258
+ if response.status_code in (301, 302, 303, 307, 308) and (
259
+ redirect_path.startswith("/accounts/login") or redirect_path == "/"
260
+ ):
261
+ raise InstagramAPIError("Cookie 已失效")
262
+ if response.status_code != 200:
263
+ raise InstagramAPIError(f"HTTP {response.status_code}: {response.text[:300]}")
264
+
265
+ payload = _response_json(response)
266
+ items = payload.get("items") or []
267
+ if not items or not isinstance(items[0], dict):
268
+ raise InstagramAPIError("未返回帖子数据")
269
+ return InstagramPost.from_v1(items[0])
270
+
271
+ @staticmethod
272
+ def shortcode_to_media_id(shortcode: str) -> int:
273
+ if len(shortcode) > 28:
274
+ shortcode = shortcode[:-28]
275
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
276
+ media_id = 0
277
+ for char in shortcode:
278
+ media_id = media_id * 64 + alphabet.index(char)
279
+ return media_id
280
+
281
+
282
+ def _response_json(response: Any) -> dict[str, Any]:
283
+ try:
284
+ payload = response.json()
285
+ except (ValueError, requests.RequestsError) as exc:
286
+ raise InstagramAPIError(f"Instagram 返回无效 JSON: {response.text[:300]}") from exc
287
+ if not isinstance(payload, dict):
288
+ raise InstagramAPIError("Instagram 返回的数据格式无效")
289
+ return payload
290
+
291
+
292
+ def _caption_text(caption: Any) -> str:
293
+ if isinstance(caption, dict):
294
+ caption = caption.get("text")
295
+ return str(caption or "")
296
+
297
+
298
+ def _v1_type(item: dict[str, Any]) -> InstagramMediaType:
299
+ media_types = {
300
+ 1: InstagramMediaType.IMAGE,
301
+ 2: InstagramMediaType.VIDEO,
302
+ 8: InstagramMediaType.SIDECAR,
303
+ }
304
+ media_type = item.get("media_type")
305
+ if isinstance(media_type, int) and media_type in media_types:
306
+ return media_types[media_type]
307
+ raise InstagramAPIError(f"不支持的 Instagram 媒体类型: {media_type}")
308
+
309
+
310
+ def _v1_media(item: dict[str, Any]) -> InstagramMedia:
311
+ candidates = (item.get("image_versions2") or {}).get("candidates") or []
312
+ candidate = candidates[0] if candidates and isinstance(candidates[0], dict) else {}
313
+ video_versions = item.get("video_versions") or []
314
+ video = video_versions[0] if video_versions and isinstance(video_versions[0], dict) else {}
315
+ video_url = str(video.get("url") or "")
316
+ return InstagramMedia(
317
+ is_video=bool(video_url),
318
+ display_url=str(candidate.get("url") or item.get("display_url") or ""),
319
+ video_url=video_url,
320
+ width=int(item.get("original_width") or candidate.get("width") or 0),
321
+ height=int(item.get("original_height") or candidate.get("height") or 0),
322
+ duration=float(item.get("video_duration") or 0),
323
+ )
324
+
325
+
326
+ def _graph_type(node: dict[str, Any]) -> InstagramMediaType:
327
+ typename = node.get("__typename")
328
+ if not isinstance(typename, str):
329
+ raise InstagramAPIError(f"不支持的 Instagram 媒体类型: {typename}")
330
+ try:
331
+ return InstagramMediaType(typename)
332
+ except ValueError as exc:
333
+ raise InstagramAPIError(f"不支持的 Instagram 媒体类型: {typename}") from exc
334
+
335
+
336
+ def _graph_media(node: dict[str, Any]) -> InstagramMedia:
337
+ video_url = str(node.get("video_url") or "")
338
+ dimensions = node.get("dimensions") or {}
339
+ return InstagramMedia(
340
+ is_video=bool(video_url),
341
+ display_url=str(node.get("display_url") or node.get("display_src") or ""),
342
+ video_url=video_url,
343
+ width=int(dimensions.get("width") or 0),
344
+ height=int(dimensions.get("height") or 0),
345
+ duration=float(node.get("video_duration") or 0),
346
+ )
347
+
348
+
349
+ __all__ = [
350
+ "InstagramAPI",
351
+ "InstagramAPIError",
352
+ "InstagramMedia",
353
+ "InstagramMediaType",
354
+ "InstagramPost",
355
+ "InstagramRestrictedError",
356
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.15
3
+ Version: 2.3.16
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -1,433 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from collections.abc import Iterator
5
- from dataclasses import dataclass
6
- from enum import StrEnum
7
- from typing import Any, cast
8
-
9
- import httpx
10
-
11
- from ..types import ContentUnavailableError
12
-
13
-
14
- class InstagramAPIError(RuntimeError):
15
- """Instagram 接口请求或响应解析失败。"""
16
-
17
-
18
- class InstagramRestrictedError(InstagramAPIError):
19
- """内容受限, 需要登录 Cookie。"""
20
-
21
-
22
- class InstagramMediaType(StrEnum):
23
- IMAGE = "GraphImage"
24
- VIDEO = "GraphVideo"
25
- SIDECAR = "GraphSidecar"
26
-
27
-
28
- @dataclass(slots=True)
29
- class InstagramSidecarNode:
30
- is_video: bool
31
- display_url: str
32
- video_url: str | None
33
- width: int
34
- height: int
35
-
36
-
37
- class InstagramPost:
38
- """轻量版 Instagram Post,只保留当前项目解析帖子需要的字段。"""
39
-
40
- _XDT_TYPES = {
41
- "XDTGraphImage": InstagramMediaType.IMAGE,
42
- "XDTGraphVideo": InstagramMediaType.VIDEO,
43
- "XDTGraphSidecar": InstagramMediaType.SIDECAR,
44
- }
45
-
46
- def __init__(self, node: dict[str, Any]):
47
- self._node = node
48
- self._normalize_typename()
49
-
50
- def _normalize_typename(self) -> None:
51
- typename = self._node.get("__typename")
52
- if typename in self._XDT_TYPES:
53
- self._node["__typename"] = self._XDT_TYPES[typename]
54
-
55
- def _field(self, *keys: str) -> Any:
56
- value: Any = self._node
57
- for key in keys:
58
- value = value[key]
59
- return value
60
-
61
- @property
62
- def shortcode(self) -> str:
63
- return str(self._node.get("shortcode") or self._node["code"])
64
-
65
- @property
66
- def typename(self) -> InstagramMediaType:
67
- return InstagramMediaType(self._field("__typename"))
68
-
69
- @property
70
- def is_video(self) -> bool:
71
- return bool(self._field("is_video"))
72
-
73
- @property
74
- def title(self) -> str:
75
- return str(self._node.get("title", ""))
76
-
77
- @property
78
- def caption(self) -> str:
79
- caption_edges = self._node.get("edge_media_to_caption", {}).get("edges") or []
80
- if caption_edges:
81
- if text := caption_edges[0].get("node", {}).get("text"):
82
- return str(text)
83
- return ""
84
- return str(self._node.get("caption", ""))
85
-
86
- @property
87
- def url(self) -> str:
88
- return str(self._node.get("display_url") or self._node["display_src"])
89
-
90
- @property
91
- def video_url(self) -> str | None:
92
- if not self.is_video:
93
- return None
94
- return self._node.get("video_url")
95
-
96
- @property
97
- def video_duration(self) -> float | None:
98
- value = self._node.get("video_duration")
99
- return float(value) if value is not None else None
100
-
101
- @property
102
- def width(self) -> int:
103
- return int(self._node.get("dimensions", {}).get("width") or 0)
104
-
105
- @property
106
- def height(self) -> int:
107
- return int(self._node.get("dimensions", {}).get("height") or 0)
108
-
109
- def get_sidecar_nodes(self, start: int = 0, end: int = -1) -> Iterator[InstagramSidecarNode]:
110
- if self.typename is not InstagramMediaType.SIDECAR:
111
- return
112
-
113
- edges = self._field("edge_sidecar_to_children", "edges")
114
- if end < 0:
115
- end = len(edges) - 1
116
- if start < 0:
117
- start = len(edges) - 1
118
-
119
- for idx, edge in enumerate(edges):
120
- if not start <= idx <= end:
121
- continue
122
-
123
- node = edge["node"]
124
- dimensions = node.get("dimensions", {})
125
- is_video = bool(node.get("is_video"))
126
- yield InstagramSidecarNode(
127
- is_video=is_video,
128
- display_url=node.get("display_url") or node.get("display_src") or "",
129
- video_url=node.get("video_url") if is_video else None,
130
- width=int(dimensions.get("width") or 0),
131
- height=int(dimensions.get("height") or 0),
132
- )
133
-
134
-
135
- class InstagramAPI:
136
- GRAPHQL_URL = "https://www.instagram.com/graphql/query"
137
- INSTAGRAM_URL = "https://www.instagram.com/"
138
- RULING_URL = "https://www.instagram.com/api/v1/web/get_ruling_for_content/"
139
- APP_ID = "936619743392459"
140
- SHORTCODE_DOC_ID = "27128499623469141"
141
-
142
- DEFAULT_COOKIES = {
143
- "sessionid": "",
144
- "mid": "",
145
- "ig_pr": "1",
146
- "ig_vw": "1920",
147
- "csrftoken": "",
148
- "s_network": "",
149
- "ds_user_id": "",
150
- }
151
-
152
- DEFAULT_USER_AGENT = (
153
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
154
- "AppleWebKit/537.36 (KHTML, like Gecko) "
155
- "Chrome/124.0.0.0 Safari/537.36"
156
- )
157
-
158
- MEDIA_TYPES = {
159
- 1: InstagramMediaType.IMAGE,
160
- 2: InstagramMediaType.VIDEO,
161
- 8: InstagramMediaType.SIDECAR,
162
- }
163
-
164
- def __init__(
165
- self,
166
- *,
167
- proxy: str | None = None,
168
- cookie: dict[str, str] | None = None,
169
- timeout: float = 30,
170
- user_agent: str | None = None,
171
- ):
172
- self.proxy = proxy
173
- self.cookie = cookie or {}
174
- self.timeout = timeout
175
- self.user_agent = user_agent or self.DEFAULT_USER_AGENT
176
-
177
- async def get_post(self, shortcode: str) -> InstagramPost:
178
- media = await self.get_shortcode_media(shortcode)
179
- return InstagramPost(media)
180
-
181
- async def get_shortcode_media(self, shortcode: str) -> dict[str, Any]:
182
- payload = await self._post_graphql(
183
- doc_id=self.SHORTCODE_DOC_ID,
184
- variables={
185
- "shortcode": shortcode,
186
- "__relay_internal__pv__PolarisAIGMMediaWebLabelEnabledrelayprovider": False,
187
- },
188
- )
189
-
190
- if payload.get("data") is None and payload.get("errors"):
191
- await self._check_ruling(shortcode)
192
-
193
- media = self._extract_shortcode_media(payload)
194
- if media is None:
195
- raise InstagramRestrictedError("受限视频无法解析: 你必须年满 18 周岁才能观看这个视频")
196
- return media
197
-
198
- async def _check_ruling(self, shortcode: str) -> None:
199
- """查询内容裁决接口, 在 GraphQL 报错时解释原因。失败时静默返回。"""
200
- try:
201
- async with self._new_client() as client:
202
- try:
203
- await self._ensure_csrf_token(client)
204
- except InstagramAPIError:
205
- return
206
- response = await client.get(
207
- self.RULING_URL,
208
- params={
209
- "content_type": "MEDIA",
210
- "target_id": str(self.shortcode_to_media_id(shortcode)),
211
- },
212
- headers={
213
- "X-IG-App-ID": self.APP_ID,
214
- "Accept-Language": "zh-CN,zh;q=0.9",
215
- },
216
- )
217
- body = response.json()
218
- except (httpx.HTTPError, ValueError):
219
- return
220
- if not isinstance(body, dict):
221
- return
222
-
223
- if body.get("message") == "Media cannot be found":
224
- raise ContentUnavailableError("帖子不存在或已被删除")
225
- if title := body.get("title"):
226
- description = body.get("description")
227
- raise InstagramRestrictedError(f"{title}: {description}" if description else title)
228
-
229
- @staticmethod
230
- def shortcode_to_media_id(shortcode: str) -> int:
231
- if len(shortcode) > 28:
232
- shortcode = shortcode[:-28]
233
- alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
234
- media_id = 0
235
- for char in shortcode:
236
- media_id = media_id * 64 + alphabet.index(char)
237
- return media_id
238
-
239
- async def _post_graphql(self, *, doc_id: str, variables: dict[str, Any]) -> dict[str, Any]:
240
- async with self._new_client() as client:
241
- await self._ensure_csrf_token(client)
242
- data = {
243
- "variables": json.dumps(variables, separators=(",", ":")),
244
- "doc_id": doc_id,
245
- "server_timestamps": "true",
246
- }
247
-
248
- try:
249
- response = await client.post(self.GRAPHQL_URL, data=data, follow_redirects=False)
250
- except httpx.HTTPError as exc:
251
- raise InstagramAPIError(f"请求 Instagram GraphQL 失败: {exc}") from exc
252
-
253
- if response.status_code != 200:
254
- raise InstagramAPIError(f"Instagram GraphQL 返回 HTTP {response.status_code}: {response.text[:500]}")
255
-
256
- try:
257
- payload = response.json()
258
- except ValueError as exc:
259
- raise InstagramAPIError(f"Instagram GraphQL 返回非 JSON 响应: {response.text[:500]}") from exc
260
-
261
- if payload.get("status") not in (None, "ok"):
262
- raise InstagramAPIError(f"Instagram GraphQL 状态异常: {payload!r}")
263
- return cast(dict[str, Any], payload)
264
-
265
- def _extract_shortcode_media(self, payload: dict[str, Any]) -> dict[str, Any] | None:
266
- data = payload.get("data")
267
- if not isinstance(data, dict):
268
- raise InstagramAPIError(f"响应缺少 data: {payload!r}")
269
-
270
- old_media = data.get("xdt_shortcode_media")
271
- if isinstance(old_media, dict):
272
- return old_media
273
- if old_media is None and "xdt_shortcode_media" in data:
274
- return None
275
-
276
- web_info = data.get("xdt_api__v1__media__shortcode__web_info")
277
- if not isinstance(web_info, dict):
278
- raise InstagramAPIError(f"响应缺少 shortcode web_info: {payload!r}")
279
-
280
- items = web_info.get("items") or []
281
- if not items:
282
- return None
283
- if not isinstance(items[0], dict):
284
- raise InstagramAPIError(f"shortcode web_info.items[0] 类型异常: {type(items[0]).__name__}")
285
- return self._convert_v1_media(items[0])
286
-
287
- def _convert_v1_media(self, media: dict[str, Any]) -> dict[str, Any]:
288
- media_type = self._media_type(media)
289
- typename = self.MEDIA_TYPES.get(media_type, InstagramMediaType.IMAGE)
290
- caption = media.get("caption")
291
- caption_text = caption.get("text") if isinstance(caption, dict) else caption
292
- image_candidate = self._first_image_candidate(media)
293
- video_version = self._first_video_version(media)
294
-
295
- node: dict[str, Any] = {
296
- "shortcode": media.get("code", ""),
297
- "id": media.get("pk", ""),
298
- "__typename": typename,
299
- "is_video": media_type == 2,
300
- "taken_at_timestamp": media.get("taken_at"),
301
- "edge_media_to_caption": {
302
- "edges": [{"node": {"text": caption_text}}] if caption_text else [],
303
- },
304
- "edge_media_preview_like": {"count": media.get("like_count") or 0},
305
- "edge_media_to_parent_comment": {
306
- "count": media.get("comment_count") or 0,
307
- "edges": [],
308
- },
309
- "owner": self._convert_owner(media.get("user")),
310
- "dimensions": self._dimensions_from_candidate(image_candidate),
311
- "display_url": image_candidate.get("url", ""),
312
- }
313
-
314
- for source_key, target_key in (
315
- ("title", "title"),
316
- ("has_liked", "viewer_has_liked"),
317
- ("accessibility_caption", "accessibility_caption"),
318
- ("location", "location"),
319
- ("video_duration", "video_duration"),
320
- ("view_count", "video_view_count"),
321
- ("play_count", "video_play_count"),
322
- ):
323
- if media.get(source_key) is not None:
324
- node[target_key] = media[source_key]
325
-
326
- if video_version.get("url"):
327
- node["video_url"] = video_version["url"]
328
-
329
- carousel_media = media.get("carousel_media") or []
330
- if carousel_media:
331
- node["edge_sidecar_to_children"] = {
332
- "edges": [{"node": self._convert_v1_sidecar_item(item)} for item in carousel_media],
333
- }
334
-
335
- return node
336
-
337
- def _convert_v1_sidecar_item(self, item: dict[str, Any]) -> dict[str, Any]:
338
- media_type = self._media_type(item)
339
- typename = self.MEDIA_TYPES.get(media_type, InstagramMediaType.IMAGE)
340
- image_candidate = self._first_image_candidate(item)
341
- video_version = self._first_video_version(item)
342
- is_video = media_type == 2
343
-
344
- node: dict[str, Any] = {
345
- "shortcode": item.get("code", ""),
346
- "__typename": typename,
347
- "is_video": is_video,
348
- "display_url": image_candidate.get("url", ""),
349
- "video_url": video_version.get("url") if is_video else None,
350
- "dimensions": self._dimensions_from_candidate(image_candidate),
351
- }
352
- if item.get("accessibility_caption") is not None:
353
- node["accessibility_caption"] = item["accessibility_caption"]
354
- return node
355
-
356
- @staticmethod
357
- def _media_type(media: dict[str, Any]) -> int:
358
- value = media.get("media_type")
359
- return value if isinstance(value, int) else 0
360
-
361
- @staticmethod
362
- def _convert_owner(user: Any) -> dict[str, Any]:
363
- if not isinstance(user, dict):
364
- return {"id": "", "username": "", "full_name": ""}
365
- return {
366
- "id": user.get("pk", ""),
367
- "username": user.get("username", ""),
368
- "full_name": user.get("full_name", ""),
369
- }
370
-
371
- @staticmethod
372
- def _first_image_candidate(media: dict[str, Any]) -> dict[str, Any]:
373
- candidates = media.get("image_versions2", {}).get("candidates") or []
374
- return candidates[0] if candidates and isinstance(candidates[0], dict) else {}
375
-
376
- @staticmethod
377
- def _first_video_version(media: dict[str, Any]) -> dict[str, Any]:
378
- versions = media.get("video_versions") or []
379
- return versions[0] if versions and isinstance(versions[0], dict) else {}
380
-
381
- @staticmethod
382
- def _dimensions_from_candidate(candidate: dict[str, Any]) -> dict[str, int]:
383
- return {
384
- "width": int(candidate.get("width") or 0),
385
- "height": int(candidate.get("height") or 0),
386
- }
387
-
388
- async def _ensure_csrf_token(self, client: httpx.AsyncClient) -> None:
389
- csrf_token = self._get_cookie_value(client, "csrftoken")
390
- if not csrf_token:
391
- try:
392
- await client.get(self.INSTAGRAM_URL, follow_redirects=True)
393
- except httpx.HTTPError as exc:
394
- raise InstagramAPIError(f"获取 Instagram csrftoken 失败: {exc}") from exc
395
- csrf_token = self._get_cookie_value(client, "csrftoken")
396
-
397
- if not csrf_token:
398
- raise InstagramAPIError("无法获取 Instagram csrftoken")
399
- client.headers["x-csrftoken"] = csrf_token
400
-
401
- @staticmethod
402
- def _get_cookie_value(client: httpx.AsyncClient, name: str) -> str:
403
- values = [cookie.value for cookie in client.cookies.jar if cookie.name == name and cookie.value]
404
- return values[-1] if values else ""
405
-
406
- def _new_client(self) -> httpx.AsyncClient:
407
- cookies = self.DEFAULT_COOKIES | self.cookie
408
- headers = {
409
- "Accept": "*/*",
410
- "Accept-Encoding": "gzip, deflate",
411
- "Accept-Language": "en-US,en;q=0.8",
412
- "Content-Type": "application/x-www-form-urlencoded",
413
- "Referer": "https://www.instagram.com/",
414
- "User-Agent": self.user_agent,
415
- "authority": "www.instagram.com",
416
- "scheme": "https",
417
- }
418
- return httpx.AsyncClient(
419
- cookies=cookies,
420
- headers=headers,
421
- proxy=self.proxy,
422
- timeout=self.timeout,
423
- )
424
-
425
-
426
- __all__ = [
427
- "InstagramAPI",
428
- "InstagramAPIError",
429
- "InstagramMediaType",
430
- "InstagramPost",
431
- "InstagramRestrictedError",
432
- "InstagramSidecarNode",
433
- ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes