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.
- {parsehub-2.3.15/src/parsehub.egg-info → parsehub-2.3.17}/PKG-INFO +1 -1
- {parsehub-2.3.15 → parsehub-2.3.17}/pyproject.toml +1 -1
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/base/ytdlp.py +1 -1
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/instagram.py +52 -21
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/zuiyou.py +1 -1
- parsehub-2.3.17/src/parsehub/provider_api/instagram.py +399 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/zuiyou.py +10 -2
- {parsehub-2.3.15 → parsehub-2.3.17/src/parsehub.egg-info}/PKG-INFO +1 -1
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/SOURCES.txt +3 -1
- parsehub-2.3.17/test/test_instagram_duration_offline.py +134 -0
- parsehub-2.3.17/test/test_zuiyou_duration_offline.py +69 -0
- parsehub-2.3.15/src/parsehub/provider_api/instagram.py +0 -433
- {parsehub-2.3.15 → parsehub-2.3.17}/LICENSE +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/README.md +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/setup.cfg +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/cli.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/cli_config.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/config/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/config/config.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/errors.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/base/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/base/base.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/bangumi.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/bilibili.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/coolapk.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/douban.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/douyin.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/facebook.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/kuaishou.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/medium.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/pipix.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/snapchat.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/threads.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/tieba.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/tiktok.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/toutiao.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/twitter.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/weibo.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/weixin.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/wxchannels.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/xhs.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/youtube.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/parsers/parser/zhihu.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/bangumi.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/bilibili.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/coolapk.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/douban.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/douyin.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/kuaishou.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/medium.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/pipix.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/threads.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/tieba.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/tiktok.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/toutiao.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/twitter.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/weibo.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/weixin.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/wxchannels.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/xhs.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/xiaoheihe.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/provider_api/zhihu.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/__init__.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/callback.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/media_file.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/media_ref.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/platform.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/post.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/types/result.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/downloader.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/fallback.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/helpers.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub/utils/media_info.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/dependency_links.txt +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/entry_points.txt +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/requires.txt +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/src/parsehub.egg-info/top_level.txt +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/test/test_bilibili_offline.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/test/test_cli.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/test/test_cli_config.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/test/test_core_offline.py +0 -0
- {parsehub-2.3.15 → parsehub-2.3.17}/test/test_downloader.py +0 -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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
match post.typename:
|
|
40
|
+
match post.type:
|
|
40
41
|
case InstagramMediaType.SIDECAR:
|
|
41
42
|
media = [
|
|
42
|
-
VideoRef(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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=
|
|
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=
|
|
56
|
-
thumb_url=
|
|
57
|
-
duration=int(
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
38
|
-
media.append(
|
|
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
|
|