Compare commits

..
Author SHA1 Message Date
Alexander Graf 21d10a7f3e Merge branch 'master' into upcoming/v4.16 2026-10-12 22:07:09 +02:00
Alexander Graf bc859a1595 Release of version 4.15.4 2026-10-12 22:00:00 +02:00
Alexander Graf b5e50aa260 Update contributing guidelines 2026-10-12 21:54:36 +02:00
Markus Magnuson 0cf00a6079 Fix profile resolution and logged-in post fetching (#2765)
- `InstaloaderContext.get_page_data()` loads a page as a logged-out visitor and returns the query results that the page embeds. Instagram only embeds them when the request looks like a page navigation, so the request sends the `Sec-Fetch-*` headers of one.
- `Profile.from_username()` reads the profile from its public page. Anonymously, `_normalize_profile_data()` completes the node.
- Logged in, `Profile.get_posts()` uses query `28975909992013618` (`PolarisProfilePostsTabContentQuery_connection`) with the variables that the web app sends. The response has the same shape as before.

When logged in, `from_username()` does not normalize the page's node. The page shows the profile to a logged-out visitor, so normalizing would set `followed_by_viewer` to `False`, and `download_profiles()` would refuse private profiles that the session follows. Instead, the GraphQL profile query in `_obtain_metadata()` fills in the missing fields when they are first read.
2026-10-12 21:39:03 +02:00
Alexander Graf c7d3e8dc0e Merge branch 'master' into upcoming/v4.16 2026-10-11 21:07:50 +02:00
Alexander Graf 91e30cf33b Support Python 3.15 2026-10-11 20:01:02 +02:00
Alexander Graf da65e697a0 Update dependencies 2026-10-10 20:53:07 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 132e2fa668 Bump actions/stale from 10 to 11 (#2751)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 20:26:16 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e3aef9dc2b Bump pyinstaller from 6.16.0 to 6.22.3 (#2755)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 20:13:48 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dd90abdb91 Bump pygments from 2.19.2 to 2.20.0 (#2758)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 20:06:46 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2e3c89dfe9 Bump requests from 2.32.5 to 2.34.2 (#2756)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 19:59:07 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c4434125e8 Bump actions/setup-python from 6 to 7 (#2752)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 19:56:46 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 75173094d6 Bump mypy from 1.18.2 to 2.4.0 (#2754)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 19:50:42 +02:00
Alexander Graf 559c7a9adf Drop Python 3.9 support, update Pipenv version used in CI 2026-10-10 19:45:43 +02:00
Andreas FehlnerandAlexander Graf 1e58dddb1f chore: add monthly Dependabot and harden checkout (#2713)
## What this changes
This PR adds Dependabot configuration and hardens GitHub Actions
checkout behavior.

## Dependabot
- adds .github/dependabot.yml
- enables grouped dependency updates
- schedules updates monthly
- configures a 7-day cooldown to avoid rapid follow-up bumps
- includes GitHub Actions updates

Configured update scopes:
- pip in /
- github-actions in /

## GitHub Actions hardening
Sets persist-credentials: false for ctions/checkout@v4 in:
- .github/workflows/windows_exe.yml
- .github/workflows/packages.yml
- .github/workflows/lint.yml
- .github/workflows/documentation.yml

## Why
- grouped updates reduce PR noise
- monthly cadence is lower maintenance
- cooldown helps avoid update churn
- disabling persisted checkout credentials follows least-privilege CI
practices

---------

Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2026-10-10 19:29:14 +02:00
Alexander Graf af898e2c31 Reference new integration tests and remove deprecated instaloader_unittests 2026-10-10 19:09:30 +02:00
Alexander Graf 8f680ae52d Add __repr__ to Instaloader and InstaloaderContext 2026-10-10 18:39:15 +02:00
agustux 7efc78de12 docs/codesnippets: Fix Firefox 147+ Linux cookie path (#2727) 2026-09-06 20:20:51 +02:00
Alexander Graf 0c143b6121 Merge branch 'master' into upcoming/v4.16 2026-07-26 11:19:13 +02:00
Alexander Graf b806153ab6 Merge branch 'master' into upcoming/v4.16 2026-03-21 22:07:52 +01:00
Luis Fernando Illapa 247bddcc9f Add progress counter to "Retrieving highlights" log line (#2634)
Introduce progress output for highlight retrieval, displaying a counter of
the current highlight index relative to the total number of highlights. This
mirrors the existing per-item download counter and provides consistent,
useful feedback when downloading multiple highlights. It uses the same
dynamic-width format as other Instaloader progress indicators.

To support this feature, the Iterator returned by get_highlights(user) is
materialized into a list so the total number of highlights can be determined
upfront. This does not alter functional behavior, since all highlights were
already iterated over, and preserves ordering while enabling fixed-width
formatted progress output.
2026-01-18 17:08:58 +01:00
20 changed files with 1256 additions and 897 deletions
+15
View File
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: monthly
cooldown:
default-days: 7
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
cooldown:
default-days: 7
+10 -4
View File
@@ -2,17 +2,23 @@
<!--
Thanks for your willingness to contribute to Instaloader! Please briefly
describe
describe:
- A motivation for this change, e.g.
- Fixes # .
- More general: What problem does the pull request solve?
- What problem does the pull request solve?
- The changes proposed in this pull request
- Keep the PR focused: avoid bundling independent fixes or refactorings.
- The completeness of this change
- Is it just a proof of concept?
- Is the documentation updated (if appropriate)?
- Do you consider it ready to be merged or is it a draft?
- How has it been tested?
- Do you consider it ready to be merged or is it a draft / proof of concept?
- Can we help you at some point?
- Further considerations
- Is it based on master (bugfix), upcoming/v4.X (new feature), or another pull request?
- Does it preserve backward compatibility and existing behavior?
-->
+5 -4
View File
@@ -8,17 +8,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout Instaloader Repository"
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0 # needed for building docs
persist-credentials: false
- name: "Setup Python"
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: 3.14
python-version: 3.15
cache: 'pipenv'
- name: "Install Dependencies"
run: |
python -m pip install pipenv==2025.0.4
python -m pip install pipenv==2026.8.0
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"
+6 -5
View File
@@ -9,21 +9,22 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
steps:
- name: Checkout Instaloader Repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: 'pipenv'
- name: Install Dependencies
run: |
python -m pip install pipenv==2025.0.4
python -m pip install pipenv==2026.8.0
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
+6 -4
View File
@@ -11,11 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v4
- name: "Setup Python"
uses: actions/setup-python@v5
uses: actions/checkout@v7
with:
python-version: 3.14
persist-credentials: false
- name: "Setup Python"
uses: actions/setup-python@v7
with:
python-version: 3.15
- name: "Get the tagged version"
id: get_version
env:
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: 'question'
@@ -21,7 +21,7 @@ jobs:
days-before-stale: 21
days-before-close: -1
remove-stale-when-updated: false
- uses: actions/stale@v9
- uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'There has been no activity on this issue for an extended period of time. This issue will be closed after further 14 days of inactivity.'
+7 -5
View File
@@ -8,11 +8,13 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout Instaloader repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/checkout@v7
with:
python-version: "3.14"
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: 3.15
cache: 'pipenv'
architecture: x64
- name: Get the tagged version
@@ -28,7 +30,7 @@ jobs:
- name: Create draft release and upload EXE as release asset
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: create_release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
Generated
+1054 -592
View File
File diff suppressed because it is too large Load Diff
+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==2025.0.4",
commands = ["pip install pipenv==2026.8.0",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
+4 -3
View File
@@ -7,9 +7,10 @@ https://instaloader.github.io/
## Build
The documentation is created with [Sphinx](http://www.sphinx-doc.org/). To build it, use
The documentation is created with [Sphinx](http://www.sphinx-doc.org/). To build it, use (in the top-level directory
of the Instaloader source tree):
```
pip3 install -r requirements.txt
make html
pipenv sync --dev
make -C docs html
```
@@ -14,8 +14,13 @@ def get_cookiefile():
default_cookiefile = {
"Windows": "~/AppData/Roaming/Mozilla/Firefox/Profiles/*/cookies.sqlite",
"Darwin": "~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite",
}.get(system(), "~/.mozilla/firefox/*/cookies.sqlite")
cookiefiles = glob(expanduser(default_cookiefile))
}.get(system(), None)
if default_cookiefile:
cookiefiles = glob(expanduser(default_cookiefile))
else:
# Check Firefox 147+ XDG path, then legacy
cookiefiles = glob(expanduser("~/.config/mozilla/firefox/*/cookies.sqlite")) or \
glob(expanduser("~/.mozilla/firefox/*/cookies.sqlite"))
if not cookiefiles:
raise SystemExit("No Firefox cookies.sqlite file found. Use -c COOKIEFILE.")
return cookiefiles[0]
+8
View File
@@ -74,6 +74,14 @@ Improvements of the Instaloader source or its documentation can be proposed as a
- a bug fix that does require thorough testing before being released to a
final version.
- Keep Pull Requests focused: address a single problem or feature in a
self-contained change. Avoid bundling multiple independent fixes, refactorings,
or architectural changes into one pull request, as this makes it hard to
review, validate, or selectively merge them.
- Be mindful of backward compatibility: changes should preserve existing intended
behavior whenever possible.
- All Pull Requests are analyzed by `Pylint <https://www.pylint.org/>`__ for
error and syntax checking of the source and
`Mypy <https://github.com/python/mypy>`__ for type checking. You can run them
-2
View File
@@ -1,2 +0,0 @@
requests
sphinx~=2.3
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.15.3'
__version__ = '4.15.4'
try:
+16 -2
View File
@@ -330,6 +330,9 @@ class Instaloader:
def __exit__(self, *args):
self.close()
def __repr__(self):
return f"<Instaloader context={self.context}>"
@_retry_on_connection_error
def download_pic(self, filename: str, url: str, mtime: datetime,
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool:
@@ -960,6 +963,11 @@ class Instaloader:
.. versionchanged:: 4.3
Also downloads and saves the Highlight's cover pictures.
.. versionchanged:: 4.16
Add progress output for highlight retrieval, displaying a counter of the current
highlight number and the total number of highlights being processed. This provides
feedback similar to the per-item download counter already present in this function.
:param user: ID or Profile of the user whose highlights should get downloaded.
:param fast_update: If true, abort when first already-downloaded picture is encountered
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
@@ -967,14 +975,20 @@ class Instaloader:
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
:raises LoginRequiredException: If called without being logged in.
"""
for user_highlight in self.get_highlights(user):
user_highlights = list(self.get_highlights(user))
hl_size = len(user_highlights)
for hl_number, user_highlight in enumerate(user_highlights, start=1):
name = user_highlight.owner_username
highlight_target: Union[str, Path] = (filename_target
if filename_target
else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) /
_PostPathFormatter.sanitize_path(user_highlight.title,
self.sanitize_paths)))
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
self.context.log(
"[{0:{w}d}/{1:{w}d}] Retrieving highlights \"{2}\" from profile {3}".format(
hl_number, hl_size, user_highlight.title, name, w=len(str(hl_size))
)
)
self.download_highlight_cover(user_highlight, highlight_target)
totalcount = user_highlight.itemcount
count = 1
+49
View File
@@ -2,6 +2,7 @@ import json
import os
import pickle
import random
import re
import shutil
import sys
import textwrap
@@ -30,6 +31,19 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
return new
def _embedded_query_data(obj: Any) -> Iterator[Dict[str, Any]]:
"""Yield the data of each GraphQL query result in JSON that a page of www.instagram.com embeds."""
if isinstance(obj, dict):
result = obj['__bbox'].get('result') if isinstance(obj.get('__bbox'), dict) else None
if isinstance(result, dict) and isinstance(result.get('data'), dict):
yield result['data']
for value in obj.values():
yield from _embedded_query_data(value)
elif isinstance(obj, list):
for value in obj:
yield from _embedded_query_data(value)
def default_user_agent() -> str:
return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
@@ -149,6 +163,9 @@ class InstaloaderContext:
if repeat_at_end:
self.error_log.append(msg)
def __repr__(self):
return f"<InstaloaderContext is_logged_in={self.is_logged_in}>"
@property
def has_stored_errors(self) -> bool:
"""Returns whether any error has been reported and stored to be repeated at program termination.
@@ -671,6 +688,38 @@ class InstaloaderContext:
return response
def get_page_data(self, path: str) -> List[Dict[str, Any]]:
"""Load a page of ``www.instagram.com`` as a logged-out visitor and return the data it embeds.
Instagram renders public profile and post pages with the results of their GraphQL queries
embedded in the HTML, even when it refuses anonymous API requests for the same data.
:param path: URL, relative to ``www.instagram.com/``
:return: The ``data`` object of each query result that the page embeds, in page order.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When the request failed.
"""
self.do_sleep()
self._rate_controller.wait_before_query('other')
url = 'https://www.instagram.com/{0}'.format(path)
# Instagram only embeds the data when the request looks like a browser's page navigation.
headers = {'Accept': 'text/html', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none'}
try:
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, headers=headers, allow_redirects=False)
except requests.exceptions.RequestException as err:
raise ConnectionException("Page request to {}: {}".format(url, err)) from err
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
data: List[Dict[str, Any]] = []
for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', resp.text, re.DOTALL):
with suppress(json.decoder.JSONDecodeError):
data.extend(_embedded_query_data(json.loads(script.group(1))))
return data
def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None:
"""Write raw response data into a file.
+57 -29
View File
@@ -999,22 +999,30 @@ class Profile:
:param username: Username
:raises: :class:`ProfileNotExistsException`
"""
# 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.
# Resolve the profile through its public web page, which Instagram renders with the
# profile embedded. The web_profile_info endpoint previously used here is refused
# with HTTP 400 for some professional profiles, and with HTTP 401 or 429 for some
# sessions, both anonymously and when logged in.
try:
data = context.get_json(
"api/v1/users/web_profile_info/", params={"username": username.lower()}
).get("data")
page = context.get_page_data("{0}/".format(username.lower()))
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))
page = []
# The page embeds two queries for the profile: one for its details and one for its
# posts.
user: Dict[str, Any] = {}
for data in page:
user.update(data.get("xig_user_by_username") or {})
if not user.get("pk"):
raise ProfileNotExistsException("Profile {} does not exist.".format(username))
user.pop("polaris_ordered_timeline_connection", None)
# The page's "id" is not the user ID, which is "pk".
node = dict(user, id=user["pk"])
if context.is_logged_in:
# The page shows the profile as a logged-out visitor sees it. Leave the missing
# fields, notably the viewer's relationship to the profile, such as
# followed_by_viewer, to the GraphQL profile query in _obtain_metadata().
return cls(context, node)
return cls(context, cls._normalize_profile_data(node))
@classmethod
def from_id(cls, context: InstaloaderContext, profile_id: int):
@@ -1131,7 +1139,8 @@ 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]:
@staticmethod
def _normalize_profile_data(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:
@@ -1377,33 +1386,52 @@ class Profile:
:rtype: NodeIterator[Post]"""
self._obtain_metadata()
logged_in = self._context.is_logged_in
if not self._context.is_logged_in:
return NodeIterator(
context=self._context,
edge_extractor=lambda d: d["data"]["user"]["edge_owner_to_timeline_media"],
node_wrapper=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,
},
"id": self.userid,
},
query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(),
doc_id="7950326061742207",
query_hash=None,
first_data=self._metadata("edge_owner_to_timeline_media"),
)
return NodeIterator(
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))
),
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),
# The query and variables that the web app sends for a profile's posts tab
# (PolarisProfilePostsTabContentQuery_connection). Instagram answers the
# previous query, 7898261790222653, with "execution error".
query_variables={
"data": {
"count": 12,
"include_reel_media_seen_timestamp": True,
"include_relationship_info": True,
"latest_besties_reel_media": True,
"latest_reel_media": True,
},
**({"username": self.username} if logged_in else {"id": self.userid}),
"first": 12,
"include_multi_captions": True,
"username": self.username,
"__relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider": True,
"__relay_internal__pv__PolarisShortDramaEnabledrelayprovider": False,
"__relay_internal__pv__PolarisReelsRecoDebugOverlayEnabledrelayprovider": False,
},
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",
doc_id="28975909992013618",
query_hash=None,
first_data=(None if logged_in else self._metadata("edge_owner_to_timeline_media")),
)
def get_saved_posts(self) -> NodeIterator[Post]:
+4 -4
View File
@@ -18,8 +18,8 @@ def get_version():
raise SystemExit("Could not find version string.")
if sys.version_info < (3, 9):
sys.exit('Instaloader requires Python >= 3.9.')
if sys.version_info < (3, 10):
sys.exit('Instaloader requires Python >= 3.10.')
requirements = ['requests>=2.25']
optional_requirements = {
@@ -46,7 +46,7 @@ setup(
'from Instagram.',
long_description=open(os.path.join(SRC, 'README.rst')).read(),
install_requires=requirements,
python_requires='>=3.9',
python_requires='>=3.10',
extras_require=optional_requirements,
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
zip_safe=False,
@@ -58,12 +58,12 @@ setup(
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14',
'Programming Language :: Python :: 3.15',
'Programming Language :: Python :: 3 :: Only',
'Topic :: Internet',
'Topic :: Multimedia :: Graphics'
+4
View File
@@ -0,0 +1,4 @@
# Instaloader Tests
Integration tests for Instaloader features that require Instagram access are maintained here:
https://github.com/instaloader/instaloader-tests
-237
View File
@@ -1,237 +0,0 @@
"""Unit Tests for Instaloader"""
import os
import shutil
import tempfile
import unittest
from itertools import islice
from typing import Optional
import instaloader
PROFILE_WITH_HIGHLIGHTS = 325732271
PUBLIC_PROFILE = "selenagomez"
PUBLIC_PROFILE_ID = 460563723
PUBLIC_PROFILE_WITH_IGTV = "natgeo"
HASHTAG = "kitten"
LOCATION = "362629379"
OWN_USERNAME = "aandergr"
NORMAL_MAX_COUNT = 2
PAGING_MAX_COUNT = 15
PRIVATE_PROFILE = "aandergr"
PRIVATE_PROFILE_ID = 1706625676
EMPTY_PROFILE = "not_public"
EMPTY_PROFILE_ID = 1928659031
ratecontroller: Optional[instaloader.RateController] = None
class TestInstaloaderAnonymously(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
print("Testing in {}".format(self.dir))
os.chdir(self.dir)
self.L = instaloader.Instaloader(download_geotags=True,
download_comments=True,
save_metadata=True)
self.L.context.raise_all_errors = True
if ratecontroller is not None:
# pylint:disable=protected-access
ratecontroller._context = self.L.context
self.L.context._rate_controller = ratecontroller
def tearDown(self):
# pylint:disable=global-statement,protected-access
global ratecontroller
ratecontroller = self.L.context._rate_controller
self.L.close()
os.chdir('/')
print("Removing {}".format(self.dir))
shutil.rmtree(self.dir)
def post_paging_test(self, iterator):
previous_post = None
for post in islice(iterator, PAGING_MAX_COUNT):
print(post)
if previous_post:
self.assertTrue(post.date_utc < previous_post.date_utc)
previous_post = post
@unittest.skip('')
def test_public_profile_download(self):
profiles = {self.L.check_profile_id(PUBLIC_PROFILE)}
self.L.download_profiles(profiles, profile_pic=False, fast_update=True, raise_errors=True)
self.L.download_profiles(profiles, profile_pic=False, fast_update=True, raise_errors=True)
def test_public_profile_paging(self):
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts())
def test_profile_pic_download(self):
self.L.download_profiles({self.L.check_profile_id(PUBLIC_PROFILE)}, posts=False, raise_errors=True)
def test_hashtag_download(self):
self.L.download_hashtag(HASHTAG, NORMAL_MAX_COUNT)
def test_hashtag_paging(self):
for count, post in enumerate(instaloader.Hashtag.from_name(self.L.context, HASHTAG).get_all_posts()):
print(post)
if count == PAGING_MAX_COUNT:
break
def test_get_id_by_username(self):
self.assertEqual(PUBLIC_PROFILE_ID,
instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid)
def test_get_username_by_id_private(self):
self.assertEqual(PRIVATE_PROFILE.lower(),
instaloader.Profile.from_id(self.L.context, PRIVATE_PROFILE_ID).username)
def test_get_username_by_id_public(self):
self.assertEqual(PUBLIC_PROFILE.lower(),
instaloader.Profile.from_id(self.L.context, PUBLIC_PROFILE_ID).username)
def test_get_username_by_id_empty(self):
self.assertEqual(EMPTY_PROFILE.lower(),
instaloader.Profile.from_id(self.L.context, EMPTY_PROFILE_ID).username)
def test_get_username_by_name_empty(self):
self.assertEqual(EMPTY_PROFILE_ID,
instaloader.Profile.from_username(self.L.context, EMPTY_PROFILE).userid)
def test_post_from_mediaid(self):
for post in instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts():
post2 = instaloader.Post.from_mediaid(self.L.context, post.mediaid)
self.assertEqual(post, post2)
break
def test_public_profile_tagged_paging(self):
for post in islice(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_tagged_posts(),
PAGING_MAX_COUNT):
print(post)
def test_public_profile_igtv(self):
for post in islice(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE_WITH_IGTV).get_igtv_posts(),
PAGING_MAX_COUNT):
print(post)
class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
def setUp(self):
super().setUp()
self.L.load_session_from_file(OWN_USERNAME)
@unittest.skip('')
def test_stories_download(self):
self.L.download_stories()
@unittest.skip('')
def test_private_profile_download(self):
self.L.download_profiles({self.L.check_profile_id(PRIVATE_PROFILE)}, stories=True, raise_errors=True)
def test_stories_paging(self):
for user_story in self.L.get_stories():
print("profile {}.".format(user_story.owner_username))
for item in user_story.get_items():
print(item)
def test_highlights_paging(self):
for user_highlight in self.L.get_highlights(PROFILE_WITH_HIGHLIGHTS):
print("Retrieving {} highlights \"{}\" from profile {}".format(user_highlight.itemcount,
user_highlight.title,
user_highlight.owner_username))
for item in user_highlight.get_items():
print(item)
def test_private_profile_paging(self):
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts())
def test_feed_download(self):
self.L.download_feed_posts(NORMAL_MAX_COUNT)
def test_feed_paging(self):
for count, post in enumerate(self.L.get_feed_posts()):
print(post)
if count == PAGING_MAX_COUNT:
break
def test_saved_download(self):
self.L.download_saved_posts(NORMAL_MAX_COUNT)
def test_saved_paging(self):
for count, post in enumerate(instaloader.Profile.from_username(self.L.context, OWN_USERNAME).get_saved_posts()):
print(post)
if count == PAGING_MAX_COUNT:
break
def test_test_login(self):
self.assertEqual(OWN_USERNAME, self.L.test_login())
def test_followees_and_stories(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
followees = set(islice(profile.get_followees(), PAGING_MAX_COUNT))
self.L.download_profiles(followees, profile_pic=False, stories=True, posts=False, raise_errors=True)
def test_get_followees(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
for f in profile.get_followees():
print(f.username)
def test_get_followers(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
for f in profile.get_followers():
print(f.username)
def test_get_similar_accounts(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
for f in profile.get_similar_accounts():
print(f.username)
def test_get_likes(self):
for post in instaloader.Profile.from_username(self.L.context, OWN_USERNAME).get_posts():
for like in post.get_likes():
print(like.username)
break
def test_explore_paging(self):
for count, post in enumerate(self.L.get_explore_posts()):
print(post)
if count == PAGING_MAX_COUNT:
break
def test_location_download(self):
self.L.download_location(LOCATION, NORMAL_MAX_COUNT)
def test_location_paging(self):
for count, post in enumerate(self.L.get_location_posts(LOCATION)):
print(post)
if count == PAGING_MAX_COUNT:
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()