Compare commits

...
31 Commits
Author SHA1 Message Date
Alexander Graf 543469296e Release of version 4.15.3 2026-07-26 11:01:52 +02:00
Elias 6dd77e7b56 Fix Post metadata fetching (#2706)
Use doc_id 27128499623469141 (PolarisPostRootQuery /
xdt_api__v1__media__shortcode__web_info) which returns v1/iPhone API format. Converts the new format to the legacy GraphQL field names that the rest of the codebase expects.
2026-07-26 10:47:46 +02:00
Markus Magnuson a4adeb0835 Fix profile resolution (#2701)
Fix Profile.from_id(), Profile.from_username() and Profile._obtain_metadata() by using `users/web_profile_info/` and `users/{profile_id}/info` queries.
2026-07-26 10:18:00 +02:00
Joseph May ad03b392db Fix caption mentions being missed after a newline
_mention_regex required the character before '@' to be a non-word
character other than '\n', so a mention at the start of a new line was
never matched. The '\n' exclusion was introduced in 8784ac7 when the
prefix went from '\W' to '[^\w\n]'; restoring '\W' matches mentions
after a newline while still keeping email addresses out of the results,
since the character before '@' in an address is a word character.

Also affects Profile.biography_mentions and StoryItem.caption_mentions,
which share the same regex.

Fixes #2709
2026-07-13 20:48:06 +02:00
André Koch-Kramer 297a88f252 Release of version 4.15.2 2026-07-05 21:16:53 +02:00
Mike Holzinger db2ae98928 Guard against null data in Post._obtain_metadata (#2705)
When Instagram returns {"data": null} for a shortcode metadata query (e.g. under
rate limiting, or for an unavailable post), response["data"]["xdt_shortcode_media"]
raised a raw TypeError before the existing `if pic_json is None` check could run.
Guard the intermediate "data" so the clean BadResponseException is raised instead,
which the download loop already handles.
2026-07-05 21:04:29 +02:00
npiriou b79a0a1e8b Fix profile metadata GraphQL query flags (#2696) 2026-07-05 20:49:03 +02:00
Alexander Graf d3cd639789 Remove unneeded symlink 2026-04-15 21:21:12 +02:00
Alexander Graf 618c23144e Welcome SocialAPIs as sponsor 2026-04-15 21:17:02 +02:00
Alexander Graf 66165a9d5d Improve typing on import_session() 2026-04-11 12:26:09 +02:00
Alexander Graf 27e42f661e Release of version 4.15.1 2026-03-21 22:01:12 +01:00
Xyfer 78bbd697cd Fix obtaining Profiles (when logged in) by migrating to new GraphQL endpoints (#2652)
Use doc_id 26347858941511777 (search query) in from_username() to get user_id
Use doc_id 25980296051578533 (PolarisProfilePageContentQuery) in _obtain_metadata()
Add _normalize_profile_data() to map new API fields to legacy format
2026-03-21 22:00:18 +01:00
Grant Nestor e4524e119a Save resume information on exception (#2600) 2026-01-18 16:46:03 +01:00
Alexander Graf 520b69d6ef Release of version 4.15 2025-11-08 22:27:37 +01:00
Alexander Graf 25c4daa861 Merge branch 'upcoming/v4.15' 2025-11-08 22:19:37 +01:00
Alexander Graf 7d256c36c1 Avoid using deprecated Hashtag.get_posts in codesnippets 2025-11-08 22:14:36 +01:00
Samuele MazziandAlexander Graf 64908788d3 Fix anonymous fetch of profile posts (#2533)
Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2025-11-08 22:07:58 +01:00
DenysandAlexander Graf b7ef306374 Abort download on {feedback,checkpoint,challenge}_required (#2612)
Handle Instagram feedback_required/checkpoint_required/challenge_required responses with a AbortDownloadException, causing the resumable iteration to be interrupted.

Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2025-11-08 18:10:47 +01:00
Alexander Graf 813621026c Update default user agent 2025-11-08 17:36:41 +01:00
Alexander Graf 44bd4d173b Update dependency versions used in CI 2025-11-08 17:33:16 +01:00
Naitri Doshi 2393a1a7a4 Add Post.video_play_count property (#2595) 2025-10-23 11:02:21 +00:00
Alexander Graf 5304e9794b First Alpha Release for Version 4.15 2025-07-18 08:06:07 +02:00
Alexander Graf ee3106814e Update default user agent 2025-07-18 08:05:11 +02:00
Alexander Graf 4c6d6e50af Update dependency versions used in CI 2025-07-18 08:05:11 +02:00
Alexander Graf af287c3fdd Merge branch 'master' into upcoming/v4.15 2025-07-18 07:51:08 +02:00
Alexander Graf 0bc9ab55dd Release of version 4.14.2 2025-07-18 07:47:19 +02:00
Ivan Semkin e64394fd25 Add possibly_pinned to download reels loop (#2539)
Add `possibly_pinned` to reels loop.
2025-07-04 07:45:07 +02:00
Alexander Graf 43cc15d1b3 Update paragraph explaining --load-cookies and --cookiefile 2025-01-29 08:09:38 +01:00
Alexander Graf a263ec85a6 Update default user agent 2025-01-29 07:52:35 +01:00
Alexander Graf 487d2c366d Update dependency versions used in CI
Changes to browser_cookie3: https://github.com/borisbabic/browser_cookie3/compare/0.19.1...0.20.1
2025-01-29 07:50:01 +01:00
master131 e493fa9c2d Update headers for iOS (#2472) 2025-01-24 08:23:49 +01:00
21 changed files with 1088 additions and 650 deletions
+2 -2
View File
@@ -14,11 +14,11 @@ jobs:
- name: "Setup Python"
uses: actions/setup-python@v5
with:
python-version: 3.12
python-version: 3.14
cache: 'pipenv'
- name: "Install Dependencies"
run: |
python -m pip install pipenv==2024.2.0
python -m pip install pipenv==2025.0.4
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: "Build Documentation"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
+3 -2
View File
@@ -9,7 +9,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout Instaloader Repository
uses: actions/checkout@v4
@@ -20,9 +20,10 @@ jobs:
cache: 'pipenv'
- name: Install Dependencies
run: |
python -m pip install pipenv==2024.2.0
python -m pip install pipenv==2025.0.4
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint
if: matrix.python-version != '3.9'
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
- name: MyPy
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: "Setup Python"
uses: actions/setup-python@v5
with:
python-version: 3.12
python-version: 3.14
- name: "Get the tagged version"
id: get_version
env:
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
cache: 'pipenv'
architecture: x64
- name: Get the tagged version
+1
View File
@@ -433,6 +433,7 @@ disable=bad-inline-option,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-positional-arguments,
too-many-public-methods,
too-many-statements,
unnecessary-lambda-assignment,
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Generated
+739 -564
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -124,11 +124,30 @@ Supporters
.. current-sponsors-start
| Instaloader is proudly sponsored by
| `@rocketapi-io <https://github.com/rocketapi-io>`__
.. list-table::
:align: center
* - |socialapis|
* - `SocialAPIs <https://socialapis.io/?utm_source=github&utm_medium=sponsor&utm_campaign=instaloader>`__
|
.. list-table::
:align: center
* - `@rocketapi-io <https://github.com/rocketapi-io>`__
|
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
how you can sponsor the development of Instaloader!
.. |socialapis| image:: ./.sponsor-logos/socialapis.png
:alt: SocialAPIs
:target: https://socialapis.io/?utm_source=github&utm_medium=sponsor&utm_campaign=instaloader
:height: 96px
.. current-sponsors-end
It is a pleasure for us to share our Instaloader to the world, and we are proud
+1 -1
View File
@@ -44,7 +44,7 @@ with open('__main__.py', 'w+') as f:
f.writelines(lines)
# install dependencies and invoke PyInstaller
commands = ["pip install pipenv==2024.2.0",
commands = ["pip install pipenv==2025.0.4",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
+1
View File
@@ -0,0 +1 @@
../.sponsor-logos
+12 -10
View File
@@ -208,22 +208,24 @@ Instead of :option:`--login`, it is possible to use
.. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME
Use Instagram cookie in your browser to login.
This feature requires the browser_cookie3 library.
Compatible with :option:`--cookiefile` if you want to load cookies from browser profiles.
Incompatible with :option:`--login` due to potential username mismatch between user input and browser login.
Supported browsers: Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, Opera, Opera_GX, Safari and Vivaldi.
Load Instagram session cookies from a browser instead of using Instaloader's own login mechanism.
This method requires the `browser_cookie3 <https://github.com/borisbabic/browser_cookie3>`__ library.
By default, cookies are loaded from the browser's main profile, but you can specify a custom cookie file using :option:`--cookiefile`.
In subsequent runs, you can just use :option:`--login` to reuse the
same session, which is saved by Instaloader.
Since :option:`--load-cookies` and :option:`--login` are alternative ways to acquire a session, they cannot be used together when initially acquiring a session.
Supported browsers: Arc, Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, Opera, Opera_GX, Safari, and Vivaldi.
Once logged in via browser cookies, Instaloader saves the session, allowing you to use :option:`--login` in subsequent runs.
.. versionadded:: 4.11
.. option:: --cookiefile COOKIE-FILE, -B COOKIE-FILE
Cookie file path of a browser profile to load cookies from.
Specifies a cookie file path for :option:`--load-cookies` to load cookies from a particular browser profile.
This option must be used together with :option:`--load-cookies` and does not work on its own.
.. versionadded:: 4.11
.. versionadded:: 4.11
.. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
@@ -310,7 +312,7 @@ How to Download
.. option:: --user-agent USER_AGENT
User Agent to use for HTTP requests. Per default, Instaloader pretends being
Chrome/127 on Linux.
Chrome/142 on Linux.
.. option:: --max-connection-attempts N
+1 -1
View File
@@ -2,7 +2,7 @@ import instaloader
L = instaloader.Instaloader()
posts = instaloader.Hashtag.from_name(L.context, 'urbanphotography').get_posts()
posts = instaloader.Hashtag.from_name(L.context, 'urbanphotography').get_posts_resumable()
users = set()
@@ -3,7 +3,7 @@ import instaloader
L = instaloader.Instaloader()
posts = instaloader.Hashtag.from_name(L.context, "urbanphotography").get_posts()
posts = instaloader.Hashtag.from_name(L.context, "urbanphotography").get_posts_resumable()
SINCE = datetime(2020, 5, 10) # further from today, inclusive
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.14.1'
__version__ = '4.15.3'
try:
+3 -6
View File
@@ -82,7 +82,7 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
return filterfunc
def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''):
def get_cookies_from_instagram(domain: str, browser: str, cookie_file: Optional[str] = None):
supported_browsers = {
"brave": browser_cookie3.brave,
"chrome": browser_cookie3.chrome,
@@ -114,13 +114,10 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''):
raise LoginException(f"No cookies found for Instagram in {browser}, "
f"Are you logged in successfully in {browser}?")
if cookie_name:
return cookies.get(cookie_name, {})
else:
return cookies
return cookies
def import_session(browser, instaloader, cookiefile):
def import_session(browser: str, instaloader: Instaloader, cookiefile: Optional[str]):
cookie = get_cookies_from_instagram('instagram', browser, cookiefile)
if cookie is not None:
instaloader.context.update_cookies(cookie)
+6 -4
View File
@@ -737,19 +737,20 @@ class Instaloader:
suffix: Optional[str] = str(edge_number)
if '{filename}' in self.filename_pattern:
suffix = None
if self.download_pictures and (not sidecar_node.is_video or self.download_video_thumbnails):
video_url = sidecar_node.video_url
if self.download_pictures and (video_url is None or self.download_video_thumbnails):
# pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.display_url)
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url,
mtime=post.date_local, filename_suffix=suffix)
if sidecar_node.is_video and self.download_videos:
if video_url is not None and self.download_videos:
# pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.video_url)
lambda: video_url)
# Download sidecar video if desired
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.video_url,
downloaded &= self.download_pic(filename=sidecar_filename, url=video_url,
mtime=post.date_local, filename_suffix=suffix)
else:
downloaded = False
@@ -1303,6 +1304,7 @@ class Instaloader:
post_filter,
owner_profile=profile,
takewhile=posts_takewhile,
possibly_pinned=3,
)
if latest_stamps is not None and reels.first_item is not None:
latest_stamps.set_last_reels_timestamp(profile.username, reels.first_item.date_local)
+27 -6
View File
@@ -32,15 +32,15 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
def default_user_agent() -> str:
return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36')
'(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
def default_iphone_headers() -> Dict[str, Any]:
return {'User-Agent': 'Instagram 273.0.0.16.70 (iPad13,8; iOS 16_3; en_US; en-US; ' \
'scale=2.00; 2048x2732; 452417278) AppleWebKit/420+',
return {'User-Agent': 'Instagram 361.0.0.35.82 (iPad13,8; iOS 18_0; en_US; en-US; ' \
'scale=2.00; 2048x2732; 674117118) AppleWebKit/420+',
'x-ads-opt-out': '1',
'x-bloks-is-panorama-enabled': 'true',
'x-bloks-version-id': '01507c21540f73e2216b6f62a11a5b5e51aa85491b72475c080da35b1228ddd6',
'x-bloks-version-id': '16b7bd25c6c06886d57c4d455265669345a2d96625385b8ee30026ac2dc5ed97',
'x-fb-client-ip': 'True',
'x-fb-connection-type': 'wifi',
'x-fb-http-engine': 'Liger',
@@ -191,7 +191,8 @@ class InstaloaderContext:
'Referer': 'https://www.instagram.com/',
'User-Agent': self.user_agent,
'X-Instagram-AJAX': '1',
'X-Requested-With': 'XMLHttpRequest'}
'X-Requested-With': 'XMLHttpRequest',
'x-ig-app-id': '936619743392459'}
if empty_session_only:
del header['Host']
del header['Origin']
@@ -395,7 +396,10 @@ class InstaloaderContext:
:param session: Session to use, or None to use self.session
:param use_post: Use POST instead of GET to make the request
:return: Decoded response dictionary
:raises QueryReturnedBadRequestException: When the server responds with a 400.
:raises AbortDownloadException: When the server responds with
'feedback_required'/'checkpoint_required'/'challenge_required'
:raises QueryReturnedBadRequestException: When the server responds with a 400 (and not
'feedback_required'/'checkpoint_required'/'challenge_required').
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When query repeatedly failed.
@@ -447,6 +451,15 @@ class InstaloaderContext:
response_headers.clear()
response_headers.update(resp.headers)
if resp.status_code == 400:
with suppress(json.decoder.JSONDecodeError):
if resp.json().get("message") in [
"feedback_required",
"checkpoint_required",
"challenge_required",
]:
# Raise AbortDownloadException in case of substantial Instagram
# requirements to stop producing more requests
raise AbortDownloadException(self._response_error(resp))
raise QueryReturnedBadRequestException(self._response_error(resp))
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
@@ -528,6 +541,13 @@ class InstaloaderContext:
:param referer: HTTP Referer, or None.
:return: The server's response dictionary.
"""
csrf = next((c.value for c in self._session.cookies
if c.name == 'csrftoken' and c.value), None)
if not csrf:
self._session.get('https://www.instagram.com/', timeout=self.request_timeout)
csrf = next((c.value for c in self._session.cookies
if c.name == 'csrftoken' and c.value), '')
with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True))
del tmpsession.headers['Connection']
@@ -535,6 +555,7 @@ class InstaloaderContext:
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
tmpsession.headers['x-csrftoken'] = csrf
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
+2 -2
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from lzma import LZMAError
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar
from .exceptions import AbortDownloadException, InvalidArgumentException
from .exceptions import InvalidArgumentException
from .instaloadercontext import InstaloaderContext
class FrozenNodeIterator(NamedTuple):
@@ -318,7 +318,7 @@ def resumable_iteration(context: InstaloaderContext,
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
try:
yield is_resuming, start_index
except (KeyboardInterrupt, AbortDownloadException):
except (Exception, KeyboardInterrupt):
if os.path.dirname(resume_file_path):
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
save(iterator.freeze(), resume_file_path)
+242 -47
View File
@@ -20,7 +20,7 @@ class PostSidecarNode(NamedTuple):
"""Item of a Sidecar Post."""
is_video: bool
display_url: str
video_url: str
video_url: Optional[str]
PostSidecarNode.is_video.__doc__ = "Whether this node is a video."
@@ -152,7 +152,7 @@ _hashtag_regex = re.compile(r"(?:#)((?:\w){1,150})")
# support Unicode and a word/beginning of string delimiter at the beginning to ensure
# that no email addresses join the list of mentions.
# http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/
_mention_regex = re.compile(r"(?:^|[^\w\n]|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)", re.ASCII)
_mention_regex = re.compile(r"(?:^|\W|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)", re.ASCII)
def _optional_normalize(string: Optional[str]) -> Optional[str]:
@@ -258,6 +258,100 @@ class Post:
fake_node["video_url"] = iphone_node["video_versions"][0]["url"]
return fake_node
@staticmethod
def _fetch_play_count_from_clips(context: 'InstaloaderContext', user_id: str,
shortcode: str) -> Optional[int]:
"""Fetch play_count for a reel via the clips connection endpoint as a fallback."""
try:
resp = context.doc_id_graphql_query(
"27234427476213202",
{"data": {"include_feed_video": True, "page_size": 12,
"target_user_id": str(user_id)}},
)
edges = ((resp.get("data") or {})
.get("xdt_api__v1__clips__user__connection_v2") or {})
for edge in edges.get("edges") or []:
media = (edge.get("node") or {}).get("media") or {}
if media.get("code") == shortcode:
return media.get("play_count")
except (ConnectionException, BadResponseException):
pass
return None
@staticmethod
def _normalize_post_data(media: Dict[str, Any], context: 'InstaloaderContext') -> Dict[str, Any]:
"""Normalize a Polaris media item to a legacy-compatible node, preserving the source structure."""
media_types = {1: "GraphImage", 2: "GraphVideo", 8: "GraphSidecar"}
media_type = media.get("media_type")
typename = None
if isinstance(media_type, int):
typename = media_types.get(media_type)
if not typename:
raise BadResponseException(f"Unknown media_type in metadata: {media_type}.")
pic_json: Dict[str, Any] = media.copy()
pic_json["shortcode"] = media["code"]
pic_json["id"] = media["pk"]
pic_json["__typename"] = typename
pic_json["is_video"] = media_type == 2
pic_json["taken_at_timestamp"] = media["taken_at"]
pic_json["owner"] = {
"id": media["user"]["pk"],
"username": media["user"].get("username", ""),
"full_name": media["user"].get("full_name", ""),
}
candidates = (media.get("image_versions2") or {}).get("candidates") or []
pic_json["display_url"] = candidates[0]["url"] if candidates else None
video_versions = media.get("video_versions") or []
pic_json["video_url"] = video_versions[0]["url"] if video_versions else None
pic_json["video_duration"] = media.get("video_duration")
pic_json["video_view_count"] = media.get("view_count")
pic_json["video_play_count"] = media.get("play_count")
if media_type == 2 and pic_json["video_view_count"] is None:
pic_json["video_play_count"] = Post._fetch_play_count_from_clips(
context, media["user"]["pk"], media["code"]
)
caption = media.get("caption")
caption_text = caption.get("text") if isinstance(caption, dict) else None
pic_json["edge_media_to_caption"] = (
{"edges": [{"node": {"text": caption_text}}]} if caption_text is not None
else {"edges": []}
)
pic_json["edge_media_preview_like"] = {"count": media.get("like_count") or 0}
pic_json["edge_media_to_parent_comment"] = {
"count": media.get("comment_count") or 0,
"edges": [],
}
if media.get("has_liked") is not None:
pic_json["viewer_has_liked"] = media["has_liked"]
carousel = media.get("carousel_media") or []
if carousel:
carousel_nodes = []
for item in carousel:
item_type = item.get("media_type", 1)
node: Dict[str, Any] = {
"shortcode": item.get("code", ""),
"__typename": media_types.get(item_type, "GraphImage"),
"is_video": item_type == 2,
}
item_candidates = (item.get("image_versions2") or {}).get("candidates") or []
node["display_url"] = item_candidates[0]["url"] if item_candidates else ""
item_videos = item.get("video_versions") or []
node["video_url"] = item_videos[0]["url"] if item_videos else None
if item.get("accessibility_caption") is not None:
node["accessibility_caption"] = item["accessibility_caption"]
carousel_nodes.append({"node": node})
pic_json["edge_sidecar_to_children"] = {"edges": carousel_nodes}
tagged = (media.get("usertags") or {}).get("in") or []
if tagged:
pic_json["edge_media_to_tagged_user"] = {
"edges": [
{"node": {"user": {"username": t["user"]["username"].lower()}}}
for t in tagged
if (t.get("user") or {}).get("username")
]
}
return pic_json
@staticmethod
def shortcode_to_mediaid(code: str) -> int:
if len(code) > 11:
@@ -319,23 +413,18 @@ class Post:
def _obtain_metadata(self):
if not self._full_metadata_dict:
pic_json = self._context.doc_id_graphql_query(
"8845758582119845", {"shortcode": self.shortcode}
)["data"]["xdt_shortcode_media"]
if pic_json is None:
resp = self._context.doc_id_graphql_query(
"27128499623469141",
{
"shortcode": self.shortcode,
"__relay_internal__pv__PolarisAIGMMediaWebLabelEnabledrelayprovider": False,
},
)
web_info = (resp.get("data") or {}).get("xdt_api__v1__media__shortcode__web_info") or {}
items = web_info.get("items")
if not items:
raise BadResponseException("Fetching Post metadata failed.")
try:
xdt_types = {
"XDTGraphImage": "GraphImage",
"XDTGraphVideo": "GraphVideo",
"XDTGraphSidecar": "GraphSidecar",
}
pic_json["__typename"] = xdt_types[pic_json["__typename"]]
except KeyError as exc:
raise BadResponseException(
f"Unknown __typename in metadata: {pic_json['__typename']}."
) from exc
self._full_metadata_dict = pic_json
self._full_metadata_dict = Post._normalize_post_data(items[0], self._context)
if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict)
raise PostChangedException
@@ -602,6 +691,15 @@ class Post:
return self._field('video_view_count')
return None
@property
def video_play_count(self) -> Optional[int]:
"""Play count of the video, or None.
.. versionadded:: 4.14.3"""
if self.is_video:
return self._field('video_play_count')
return None
@property
def video_duration(self) -> Optional[float]:
"""Duration of the video in seconds, or None.
@@ -880,7 +978,8 @@ class Profile:
Also, this class implements == and is hashable.
"""
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
assert 'username' in node
assert "username" in node
assert "id" in node or "pk" in node
self._context = context
self._has_public_story: Optional[bool] = None
self._node = node
@@ -900,10 +999,22 @@ class Profile:
:param username: Username
:raises: :class:`ProfileNotExistsException`
"""
# pylint:disable=protected-access
profile = cls(context, {'username': username.lower()})
profile._obtain_metadata() # to raise ProfileNotExistsException now in case username is invalid
return profile
# Resolve the profile through the web_profile_info endpoint, which works both
# anonymously and when logged in and returns the complete profile node
# (including the first page of posts). The GraphQL fbsearch query previously
# used here started responding with HTTP 400.
try:
data = context.get_json(
"api/v1/users/web_profile_info/", params={"username": username.lower()}
).get("data")
except QueryReturnedNotFoundException:
data = None
if data and data.get("user"):
profile = cls(context, data["user"])
profile._has_full_metadata = True
return profile
raise ProfileNotExistsException("Profile {} does not exist.".format(username))
@classmethod
def from_id(cls, context: InstaloaderContext, profile_id: int):
@@ -916,15 +1027,16 @@ class Profile:
"""
if profile_id in context.profile_id_cache:
return context.profile_id_cache[profile_id]
data = context.graphql_query('7c16654f22c819fb63d1183034a5162f',
{'user_id': str(profile_id),
'include_chaining': False,
'include_reel': True,
'include_suggested_users': False,
'include_logged_out_extras': False,
'include_highlight_reels': False})['data']['user']
if data:
profile = cls(context, data['reel']['owner'])
# Resolve the current username from the user id, then load the full profile.
# The GraphQL user query previously used here started responding with HTTP 400.
try:
user = context.get_json(
"api/v1/users/{0}/info/".format(profile_id), params={}
).get('user')
except QueryReturnedNotFoundException:
user = None
if user and user.get('username'):
profile = cls.from_username(context, user['username'])
else:
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
str(profile_id) + ").")
@@ -970,11 +1082,41 @@ class Profile:
def _obtain_metadata(self):
try:
if not self._has_full_metadata:
metadata = self._context.get_iphone_json(f'api/v1/users/web_profile_info/?username={self.username}',
params={})
if metadata['data']['user'] is None:
if not self._context.is_logged_in:
# Anonymous access: the web_profile_info endpoint returns the full
# node in the legacy format and still works, unlike the GraphQL
# profile query.
try:
data = self._context.get_json(
"api/v1/users/web_profile_info/",
params={"username": self.username},
).get('data')
except QueryReturnedNotFoundException as err:
raise ProfileNotExistsException(
'Profile {} does not exist.'.format(self.username)) from err
user_data = data.get('user') if data else None
if user_data is None:
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username))
self._node = user_data
self._has_full_metadata = True
return
user_id = self._node.get('id') or self._node.get('pk')
variables = {
"id": str(user_id),
"render_surface": "PROFILE",
"__relay_internal__pv__PolarisCannesGuardianExperienceEnabledrelayprovider": True,
"__relay_internal__pv__PolarisCASB976ProfileEnabledrelayprovider": False,
"__relay_internal__pv__PolarisRepostsConsumptionEnabledrelayprovider": False,
"__relay_internal__pv__PolarisWebSchoolsEnabledrelayprovider": False,
"enable_integrity_filters": True,
}
data = self._context.doc_id_graphql_query('27937681195819736', variables)
if data is None:
raise QueryReturnedNotFoundException('GraphQL query returned None')
user_data = data.get('data', {}).get('user')
if user_data is None:
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username))
self._node = metadata['data']['user']
self._node = self._normalize_profile_data(user_data)
self._has_full_metadata = True
except (QueryReturnedNotFoundException, KeyError) as err:
top_search_results = TopSearchResults(self._context, self.username)
@@ -989,6 +1131,44 @@ class Profile:
', '.join(similar_profiles[0:5]))) from err
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) from err
def _normalize_profile_data(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize PolarisProfilePageContentQuery response to match legacy format."""
normalized = user_data.copy()
if 'id' not in normalized and 'pk' in normalized:
normalized['id'] = normalized['pk']
if 'edge_owner_to_timeline_media' not in normalized and 'media_count' in normalized:
normalized['edge_owner_to_timeline_media'] = {'count': normalized['media_count']}
if 'edge_felix_video_timeline' not in normalized:
normalized['edge_felix_video_timeline'] = {'count': 0}
if 'edge_followed_by' not in normalized and 'follower_count' in normalized:
normalized['edge_followed_by'] = {'count': normalized['follower_count']}
if 'edge_follow' not in normalized and 'following_count' in normalized:
normalized['edge_follow'] = {'count': normalized['following_count']}
if 'is_business_account' not in normalized and 'is_business' in normalized:
normalized['is_business_account'] = normalized['is_business']
if 'business_category_name' not in normalized and 'category' in normalized:
normalized['business_category_name'] = normalized['category']
friendship = normalized.get('friendship_status', {}) or {}
if 'followed_by_viewer' not in normalized:
normalized['followed_by_viewer'] = friendship.get('following', False)
if 'follows_viewer' not in normalized:
normalized['follows_viewer'] = friendship.get('followed_by', False)
if 'blocked_by_viewer' not in normalized:
normalized['blocked_by_viewer'] = friendship.get('blocking', False)
if 'has_blocked_viewer' not in normalized:
normalized['has_blocked_viewer'] = False
if 'has_requested_viewer' not in normalized:
normalized['has_requested_viewer'] = friendship.get('incoming_request', False)
if 'requested_by_viewer' not in normalized:
normalized['requested_by_viewer'] = friendship.get('outgoing_request', False)
if 'profile_pic_url_hd' not in normalized:
hd_info = normalized.get('hd_profile_pic_url_info')
if hd_info and 'url' in hd_info:
normalized['profile_pic_url_hd'] = hd_info['url']
elif 'profile_pic_url' in normalized:
normalized['profile_pic_url_hd'] = normalized['profile_pic_url']
return normalized
def _metadata(self, *keys) -> Any:
try:
d = self._node
@@ -1197,18 +1377,33 @@ class Profile:
:rtype: NodeIterator[Post]"""
self._obtain_metadata()
logged_in = self._context.is_logged_in
return NodeIterator(
context = self._context,
edge_extractor = lambda d: d['data']['xdt_api__v1__feed__user_timeline_graphql_connection'],
node_wrapper = lambda n: Post.from_iphone_struct(self._context, n),
query_variables = {'data': {
'count': 12, 'include_relationship_info': True,
'latest_besties_reel_media': True, 'latest_reel_media': True},
'username': self.username},
query_referer = 'https://www.instagram.com/{0}/'.format(self.username),
is_first = Profile._make_is_newest_checker(),
doc_id = '7898261790222653',
query_hash = None,
context=self._context,
edge_extractor=(
(lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"])
if logged_in
else (lambda d: d["data"]["user"]["edge_owner_to_timeline_media"])
),
node_wrapper=(
(lambda n: Post.from_iphone_struct(self._context, n))
if logged_in
else (lambda n: Post(self._context, n, self))
),
query_variables={
"data": {
"count": 12,
"include_relationship_info": True,
"latest_besties_reel_media": True,
"latest_reel_media": True,
},
**({"username": self.username} if logged_in else {"id": self.userid}),
},
query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(),
doc_id="7898261790222653" if logged_in else "7950326061742207",
query_hash=None,
first_data=(None if logged_in else self._metadata("edge_owner_to_timeline_media")),
)
def get_saved_posts(self) -> NodeIterator[Post]:
+1
View File
@@ -63,6 +63,7 @@ setup(
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14',
'Programming Language :: Python :: 3 :: Only',
'Topic :: Internet',
'Topic :: Multimedia :: Graphics'
+23
View File
@@ -210,5 +210,28 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
break
class TestCaptionMentions(unittest.TestCase):
"""Caption parsing, which requires no network access."""
@staticmethod
def _post_with_caption(caption: str) -> instaloader.Post:
return instaloader.Post(None, {"shortcode": "BhrEy4nBFVU", "caption": caption})
def test_caption_mentions(self):
for caption, expected in [
("@alice at the beginning", ["alice"]),
("preceded by a space @bob", ["bob"]),
("preceded by a newline\n@carol", ["carol"]),
("multiple lines\n@dave\n@erin", ["dave", "erin"]),
("no mentions here", []),
]:
with self.subTest(caption=caption):
self.assertEqual(self._post_with_caption(caption).caption_mentions, expected)
def test_caption_mentions_ignores_email_addresses(self):
post = self._post_with_caption("write to alice@example.com\ncontact @bob instead")
self.assertEqual(post.caption_mentions, ["bob"])
if __name__ == '__main__':
unittest.main()