Compare commits

...
17 Commits
Author SHA1 Message Date
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
17 changed files with 697 additions and 474 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: "Setup Python"
uses: actions/setup-python@v5
with:
python-version: 3.12
python-version: 3.14
cache: 'pipenv'
- name: "Install Dependencies"
run: |
+2 -1
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
@@ -23,6 +23,7 @@ jobs:
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.13"
python-version: "3.14"
cache: 'pipenv'
architecture: x64
- name: Get the tagged version
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Generated
+545 -433
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
View File
@@ -0,0 +1 @@
../.sponsor-logos
+1 -1
View File
@@ -312,7 +312,7 @@ How to Download
.. option:: --user-agent USER_AGENT
User Agent to use for HTTP requests. Per default, Instaloader pretends being
Chrome/138 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.15a1'
__version__ = '4.15.2'
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)
+14 -2
View File
@@ -32,7 +32,7 @@ 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/138.0.0.0 Safari/537.36')
'(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
def default_iphone_headers() -> Dict[str, Any]:
@@ -395,7 +395,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 +450,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))
+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)
+102 -22
View File
@@ -319,9 +319,11 @@ class Post:
def _obtain_metadata(self):
if not self._full_metadata_dict:
pic_json = self._context.doc_id_graphql_query(
response = self._context.doc_id_graphql_query(
"8845758582119845", {"shortcode": self.shortcode}
)["data"]["xdt_shortcode_media"]
)
data = (response or {}).get("data")
pic_json = data.get("xdt_shortcode_media") if data else None
if pic_json is None:
raise BadResponseException("Fetching Post metadata failed.")
try:
@@ -602,6 +604,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 +891,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 +912,13 @@ 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
data = context.doc_id_graphql_query("26347858941511777", {"hasQuery": True, "query": username})["data"]
if data:
for user in data["xdt_api__v1__fbsearch__non_profiled_serp"]["users"]:
if user["username"].lower() == username.lower():
return cls(context, user)
raise ProfileNotExistsException("Profile {} does not exist.".format(username))
@classmethod
def from_id(cls, context: InstaloaderContext, profile_id: int):
@@ -970,11 +985,23 @@ 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:
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 +1016,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 +1262,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'