Compare commits

..
101 Commits
Author SHA1 Message Date
Alexander Graf 3338260f5d Release of version 4.16 2026-10-26 22:31:03 +01:00
Alexander Graf 49b0df42b4 Merge branch 'upcoming/v4.16' 2026-10-26 22:30:31 +01:00
Alexander Graf 756f097626 Prevent user_id setter 2026-10-26 22:12:33 +01:00
Vassilis Papadimas 9e0158de54 fix get_iphone_json: send derived bearer token and correct user_id (#2771) 2026-10-26 22:06:39 +01:00
Alexander Graf d035c9b7c7 Update dependencies 2026-10-26 21:54:49 +01:00
Alexander Graf 00a6784029 Fix Profile.from_id() 2026-10-26 21:44:16 +01:00
Alexander Graf 09dea80d2b Release of version 4.15.5 2026-10-24 16:26:56 +02:00
Alexander Graf 56493a8a78 First Release Candidate for Version 4.16 2026-10-24 16:21:33 +02:00
Alexander Graf a7119ef1e3 Merge branch 'master' into upcoming/v4.16 2026-10-24 16:05:19 +02:00
André Koch-Kramer bf3f6e2bfb Remove PolarisFeedShareMenu relay provider from NodeIterator 2026-10-24 15:14:18 +02:00
Alexander Graf 5b27110476 NodeIterator: keep first_item besides first_node to spare wrapper invocations
The NodeIterator is sometimes invocated with node wrappers that are heavy to
invocate, e.g. because they trigger requests. Storing the resulting item
besides the node allows to avoid those extra requests.

In the FrozenNodeIterator, the node is still saved, rather than the item.
2026-10-24 12:03:46 +02:00
Alexander Graf 221cd7f8bc Add versionchanged directive for Profile.mediacount 2026-10-24 11:22:10 +02:00
Alexander Graf 328e93b0bc Annotate Profile.mediacount as Optional 2026-10-23 23:40:53 +02:00
Alexander Graf 653efed311 Query Post metadata through api/graphql when not logged in 2026-10-23 23:40:31 +02:00
Alexander Graf 133e5c442c Fetch clips play count only when Post.video_play_count is accessed 2026-10-23 23:40:13 +02:00
Alexander Graf 28d991f765 Fix Profile.mediacount from the logged-out profile page 2026-10-23 23:39:34 +02:00
Alexander Graf fa6baebc66 Raise LoginRequiredException in Profile.get_posts() for private profiles when not logged in 2026-10-23 23:36:53 +02:00
Alexander Graf c0e47314c2 Fix Profile.get_posts() when not logged in 2026-10-23 23:33:28 +02:00
Alexander Graf e628a8d04a Allow api/graphql queries when not logged in 2026-10-23 23:27:30 +02:00
Alexander Graf ca44660707 Fix Post.get_likes() with api/v1/media/{id}/likers endpoint 2026-10-23 22:03:54 +02:00
Alexander Graf e93795223a Add AI Policy 2026-10-23 21:16:27 +02:00
Alexander Graf 7d26ad6ab8 Minor improvements to contributing guidelines 2026-10-23 21:16:11 +02:00
Alexander Graf 5432c4cce1 Update copyright years 2026-10-21 22:56:21 +02:00
Alexander Graf 3641b44796 First Beta Release for Version 4.16 2026-10-21 22:18:21 +02:00
Alexander Graf c56552c5e9 Merge branch 'master' into upcoming/v4.16 2026-10-21 22:01:45 +02:00
Alexander Graf 6cca56ec71 Do not let MyPy analyze the untyped browser_cookie3 module 2026-10-21 21:53:24 +02:00
Alexander Graf 93ff75f51c Annotate InstaloaderContext.username as Optional[str] 2026-10-21 21:53:18 +02:00
Alexander Graf 0cce956fac Remove the suggestion-mode option from .pylintrc
PyLint 4 removed it.
2026-10-21 21:53:12 +02:00
Alexander Graf 9252370503 Raise PrivateProfileNotFollowedException in Profile.get_posts() 2026-10-21 21:53:01 +02:00
Alexander Graf d972d9a48c Query logged-in profile posts through the web app's api/graphql endpoint
graphql/query answers with feedback_required; api/graphql needs the
fb_dtsg, lsd and av tokens of the logged-in page.
2026-10-21 21:48:43 +02:00
Mayank PadiaandClaude Opus 5.5 7d543dc644 Fix Profile.get_posts() when logged in by updating the posts query
Instagram now answers doc_id 28975909992013618 with 400 "feedback_required".
Use the query the web app currently sends for a profile's posts tab
(PolarisProfilePostsTabContentQuery_connection), doc_id 29104886249145907,
with its current relay provider variables.

(cherry picked from commit 804894a0b1, #2785)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:48:43 +02:00
Alexander Graf 4948d7a63c Share the page request of get_page_data() 2026-10-21 21:48:43 +02:00
Alexander Graf 1eab8cafda Report the login redirect in get_page_data()
Instead of reporting "302 Found" for profiles that need a login.
2026-10-21 21:44:35 +02:00
Alexander Graf 54033f9ea6 Share the session setup of the two GraphQL query methods 2026-10-21 21:43:47 +02:00
Alexander Graf bafd1bf14c Turn the retry in _retry_on_connection_error() into a loop 2026-10-21 21:34:24 +02:00
Alexander Graf 309708225c Remove the dead _attempt parameter of get_raw()
Unused since a9e79e9 moved the download retry into
_retry_on_connection_error().
2026-10-21 21:31:13 +02:00
Alexander Graf ba70aec490 Share the download status-code handling of get_raw() and head()
Kept separate from _raise_for_status(), as CDN downloads follow a
different status policy than the API.
2026-10-21 21:28:08 +02:00
Alexander Graf fb018c81ca Do not re-send POST data as query arguments when following a redirect 2026-10-21 21:28:08 +02:00
Alexander Graf a6c279211d Do not crash on a fatal status response without Content-Type 2026-10-21 21:22:27 +02:00
Alexander Graf f4e1f3c95e Refactor InstaloaderContext.get_json() for readability
Split it into small helpers and retry in a loop instead of recursively.
This also fixes retried POST queries being repeated as GET.
2026-10-21 21:21:14 +02:00
Alexander Graf 7efa2de6fe Fix get_feed_posts() with the api/v1/feed/timeline endpoint
The GraphQL query used so far now returns edge_web_feed_timeline: null, which
raised a TypeError. Page through api/v1/feed/timeline/ instead and convert its
media items (iPhone format).

Fixes #2556
2026-10-14 22:03:07 +02:00
André Koch-Kramer 58c07c6393 Include licenses in windows exe zip file 2026-10-13 21:33:05 +02:00
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 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 b806153ab6 Merge branch 'master' into upcoming/v4.16 2026-03-21 22:07:52 +01: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
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
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
Alexander Graf 2e482b063a Release of version 4.14.1 2025-01-24 08:19:13 +01:00
Alexander Graf 0bc91b722e Fix download hashtags
Closes #2144.
2025-01-24 08:17:53 +01:00
tinyboxvk 4264d40f19 Fix typos (#2474)
Signed-off-by: tinyboxvk <13696594+tinyboxvk@users.noreply.github.com>
2025-01-15 08:29:32 +01:00
André Koch-Kramer b15a595083 Update .SRCINFO template for arch package 2024-11-21 08:41:30 +01:00
Alexander Graf 71ae32c85e Update PyPI and Arch packaging process 2024-11-21 08:26:05 +01:00
David Hotham f5134ff95e Publish wheels (#2430) 2024-11-21 07:58:27 +01:00
Alexander Graf 347f01b7fc Release of version 4.14 2024-11-07 08:21:03 +01:00
38 changed files with 2893 additions and 1142 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
+13 -4
View File
@@ -2,17 +2,26 @@
<!--
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?
- If generative AI was involved, ensure it follows our AI policy.
(also see https://instaloader.github.io/contributing.html)
-->
+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.12
python-version: 3.15
cache: 'pipenv'
- name: "Install Dependencies"
run: |
python -m pip install pipenv==2024.2.0
python -m pip install pipenv==2026.10.3
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"
+7 -4
View File
@@ -9,20 +9,23 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
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==2024.2.0
python -m pip install pipenv==2026.10.3
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint
if: matrix.python-version != '3.10'
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
+9 -7
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.12
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:
@@ -23,11 +25,11 @@ jobs:
run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
shell: bash
- name: "Install Dependencies"
run: python -m pip install setuptools
run: python -m pip install -U setuptools wheel build
- name: "Generate Python Package"
run: python setup.py sdist
run: python -m build
- name: "Publish Distribution to PyPI"
uses: pypa/gh-action-pypi-publish@master
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_PASSWORD }}
- name: "Publish Distribution to AUR"
+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.12"
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:
+1 -4
View File
@@ -93,10 +93,6 @@ py-version=3.8
# Discover python modules and packages in the file system subtree.
recursive=no
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
@@ -433,6 +429,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

+2 -1
View File
@@ -1,6 +1,7 @@
The MIT License (MIT)
Copyright (c) 2016-2020 Alexander Graf and André Koch-Kramer.
Copyright (c) 2016-2026 Alexander Graf, André Koch-Kramer and the Instaloader
contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1
View File
@@ -16,6 +16,7 @@ tomli = "*" # Needed for Pylint for "python_version < '3.11'"
dill = "*" # Needed for Pylint for "python_version < '3.11'"
pywin32-ctypes = "*" # Needed for pyinstaller on Windows
pefile = "*" # Needed for pyinstaller on Windows
pip-licenses = "*"
[packages]
requests = "*"
Generated
+1266 -577
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
+6 -2
View File
@@ -5,9 +5,13 @@ pkgbase = instaloader
url = https://instaloader.github.io/
arch = any
license = MIT
makedepends = python-build
makedepends = python-installer
makedepends = python-setuptools
depends = python>=3.8
depends = python-requests>=2.4
makedepends = python-wheel
depends = python
depends = python-requests
optdepends = python-browser-cookie3: Import session cookies from browser
options = !emptydirs
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
sha512sums = {{hash}}
+15 -5
View File
@@ -8,20 +8,30 @@ arch=('any')
url="https://instaloader.github.io/"
license=('MIT')
groups=()
depends=('python>=3.8' 'python-requests>=2.4')
makedepends=('python-setuptools')
depends=(
python
python-requests
)
makedepends=(
python-build
python-installer
python-setuptools
python-wheel
)
optdepends=(
'python-browser-cookie3: Import session cookies from browser'
)
options=('!emptydirs')
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
sha512sums=('{{hash}}')
build() {
cd "$srcdir/$pkgname-$pkgver"
python setup.py clean --all
python setup.py build
python -m build --wheel --no-isolation
}
package() {
cd "$srcdir/$pkgname-$pkgver"
install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE
python setup.py install --root "$pkgdir/" --skip-build
python -m installer --destdir="$pkgdir" dist/*.whl
}
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+16
View File
@@ -0,0 +1,16 @@
instaloader.exe is a standalone executable built with PyInstaller. Besides Instaloader itself
(see ../LICENSE.txt), it contains the following third-party software:
- Python, its standard library and the libraries it is built with, see Python.txt and
Python-incorporated-software.txt.
- The Python packages listed in third-party.txt, along with their license texts.
browser-cookie3 is licensed under the GNU Lesser General Public License version 3, whose text is
included in third-party.txt. It refers to the GNU General Public License version 3, see GPL-3.0.txt.
certifi is licensed under the Mozilla Public License 2.0, whose text is included in third-party.txt.
Their source code, in the versions listed in third-party.txt, is available at
https://pypi.org/project/browser-cookie3/#history and https://pypi.org/project/certifi/#history.
You may build instaloader.exe with modified versions of these packages: the source code of
Instaloader, including the script used to build instaloader.exe (deploy/windows/create_exe.py),
is published along with each release at https://github.com/instaloader/instaloader/releases.
+22 -3
View File
@@ -1,9 +1,12 @@
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import urllib.request
# copy the required files into repo root
shutil.copy('docs/favicon.ico', '.')
@@ -43,10 +46,18 @@ with open('instaloader/__main__.py', 'r') as f:
with open('__main__.py', 'w+') as f:
f.writelines(lines)
# install dependencies and invoke PyInstaller
commands = ["pip install pipenv==2024.2.0",
# packages bundled into the executable, whose licenses have to be shipped along with it
with open('Pipfile.lock') as f:
locked_packages = json.load(f)['default']
bundled_packages = ' '.join(list(locked_packages) + ['psutil', 'pyinstaller'])
os.makedirs('dist/licenses', exist_ok=True)
# install dependencies, invoke PyInstaller and collect licenses
commands = ["pip install pipenv==2026.10.3",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec",
f"pipenv run pip-licenses --packages {bundled_packages} --with-license-file "
"--with-notice-file --no-license-path --format=plain-vertical --output-file dist/licenses/third-party.txt"]
for command in commands:
print()
@@ -67,5 +78,13 @@ with open('dist/instaloader.exe', 'rb') as f:
with open('dist/instaloader.exe.md5', 'w+') as f:
f.write('{} *instaloader.exe\n'.format(hash_md5.hexdigest()))
# include the remaining license texts
shutil.copy('LICENSE', 'dist/LICENSE.txt')
shutil.copy('deploy/windows/NOTICE.txt', 'dist/licenses/NOTICE.txt')
shutil.copy('deploy/windows/GPL-3.0.txt', 'dist/licenses/GPL-3.0.txt')
shutil.copy(os.path.join(sys.base_prefix, 'LICENSE.txt'), 'dist/licenses/Python.txt')
urllib.request.urlretrieve('https://raw.githubusercontent.com/python/cpython/v{}/Doc/license.rst'
.format(platform.python_version()), 'dist/licenses/Python-incorporated-software.txt')
# Create ZIP file
shutil.make_archive('instaloader-{}-windows-standalone'.format(os.getenv('VERSION_TAG')), 'zip', 'dist')
+1 -1
View File
@@ -9,7 +9,7 @@ a = Analysis(['__main__.py'],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
excludes=['setuptools', 'pkg_resources', '_distutils_hack', 'packaging'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher)
+1
View File
@@ -0,0 +1 @@
../.sponsor-logos
+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
```
+1 -1
View File
@@ -99,7 +99,7 @@ Instaloader supports the following targets:
to **download IGTV videos**.
- ``"#hashtag"``
Posts with a certain **hashtag** (the quotes are usually necessary).
Posts with a certain **hashtag** (the quotes are usually necessary). Requires :ref:`login<login>`.
- ``%location id``
Posts tagged with a given location; the location ID is the numerical ID
+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()
@@ -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]
@@ -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
@@ -15,7 +15,7 @@ sys.path.insert(0, os.path.abspath('..'))
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Instaloader'
copyright = '2023, Alexander Graf and Andre Koch-Kramer'
copyright = '2016-2026, Alexander Graf, Andre Koch-Kramer and the Instaloader contributors'
author = 'Alexander Graf and Andre Koch-Kramer'
# -- General configuration ---------------------------------------------------
+21
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 feasible.
- 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
@@ -92,6 +100,19 @@ Improvements of the Instaloader source or its documentation can be proposed as a
agrees that the change might be an improvement, or if you want to discuss
your basic proposal, before working on a pull request.
AI Policy
---------
- If generative AI was used to create notable portions of a contribution, the
author must disclose which parts were AI-assisted (e.g. research, testing,
coding, or documentation).
- The author must carefully review and understand the contribution before
submitting it.
- If the maintainers need more effort to review the contribution than it took
to create it, please do not submit it.
Proposing Features
------------------
-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.14rc3'
__version__ = '4.16'
try:
+5 -8
View File
@@ -16,7 +16,7 @@ from .instaloader import (get_default_session_filename, get_default_stamps_filen
from .instaloadercontext import default_user_agent
from .lateststamps import LatestStamps
try:
import browser_cookie3
import browser_cookie3 # type: ignore[import-untyped]
bc3_library = True
except ImportError:
bc3_library = False
@@ -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,
@@ -112,15 +112,12 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''):
print(f"Cookies loaded successfully from {browser}")
else:
raise LoginException(f"No cookies found for Instagram in {browser}, "
f"Are you logged in succesfully 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)
+60 -43
View File
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
from functools import wraps
from io import BytesIO
from pathlib import Path
from typing import Any, Callable, IO, Iterator, List, Optional, Set, Union, cast
from typing import Any, Callable, Dict, IO, Iterator, List, Optional, Set, Union, cast
from urllib.parse import urlparse
import requests
@@ -83,33 +83,38 @@ def _requires_login(func: Callable) -> Callable:
def _retry_on_connection_error(func: Callable) -> Callable:
"""Decorator to retry the function max_connection_attemps number of times.
Herewith-decorated functions need an ``_attempt`` keyword argument.
"""Decorator to retry the function max_connection_attempts number of times.
This is to decorate functions that do network requests that may fail. Note that
:meth:`.get_json`, :meth:`.get_iphone_json`, :meth:`.graphql_query` and :meth:`.graphql_node_list` already have
their own logic for retrying, hence functions that only use these for network access must not be decorated with this
decorator."""
decorator.
.. versionchanged:: 4.16
Decorated functions no longer need to accept an ``_attempt`` keyword argument.
"""
@wraps(func)
def call(instaloader, *args, **kwargs):
try:
return func(instaloader, *args, **kwargs)
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err)
if (kwargs.get('_attempt') or 1) == instaloader.context.max_connection_attempts:
raise ConnectionException(error_string) from None
instaloader.context.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
def call(instaloader, *args, **kwargs) -> Any:
# The failure we are currently retrying, if any. It tells us whether a ^C means "skip this
# download" rather than "abort everything", and whether to sleep before the next attempt.
pending_error_string: Optional[str] = None
for attempt in range(1, instaloader.context.max_connection_attempts + 1):
try:
if kwargs.get('_attempt'):
kwargs['_attempt'] += 1
else:
kwargs['_attempt'] = 2
instaloader.context.do_sleep()
return call(instaloader, *args, **kwargs)
if pending_error_string is not None:
instaloader.context.do_sleep()
return func(instaloader, *args, **kwargs)
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err)
if attempt == instaloader.context.max_connection_attempts:
raise ConnectionException(error_string) from None
instaloader.context.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
pending_error_string = error_string
except KeyboardInterrupt:
if pending_error_string is None:
raise
instaloader.context.error("[skipped by user]", repeat_at_end=False)
raise ConnectionException(error_string) from None
raise ConnectionException(pending_error_string) from None
raise AssertionError("_retry_on_connection_error() retry loop exited without result")
return call
@@ -330,9 +335,12 @@ 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:
filename_suffix: Optional[str] = None) -> bool:
"""Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated."""
if filename_suffix is not None:
@@ -516,8 +524,9 @@ class Instaloader:
'{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension))
@_retry_on_connection_error
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile],
_attempt: int = 1) -> None:
def download_title_pic(
self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile]
) -> None:
"""Downloads and saves a picture that does not have an association with a Post or StoryItem, such as a
Profile picture or a Highlight cover picture. Modification time is taken from the HTTP response headers.
@@ -737,19 +746,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
@@ -959,6 +969,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
@@ -966,14 +981,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
@@ -1091,22 +1112,16 @@ class Instaloader:
:raises LoginRequiredException: If called without being logged in.
"""
data = self.context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]
params: Dict[str, Any] = {}
while True:
feed = data["user"]["edge_web_feed_timeline"]
for edge in feed["edges"]:
node = edge["node"]
if node.get("__typename") in Post.supported_graphql_types() and node.get("shortcode") is not None:
yield Post(self.context, node)
if not feed["page_info"]["has_next_page"]:
data = self.context.get_json("api/v1/feed/timeline/", params=params)
for item in data.get("feed_items", []):
media = item.get("media_or_ad")
if media and media.get("code") and media.get("ad_id") is None:
yield Post.from_iphone_struct(self.context, media)
if not (data.get("more_available") and data.get("next_max_id")):
break
data = self.context.graphql_query("d6f4427fbe92d846298cf93df0b937d3",
{'fetch_media_item_count': 12,
'fetch_media_item_cursor': feed["page_info"]["end_cursor"],
'fetch_comment_count': 4,
'fetch_like': 10,
'has_stories': False})["data"]
params = {"max_id": data["next_max_id"]}
@_requires_login
def download_feed_posts(self, max_count: Optional[int] = None, fast_update: bool = False,
@@ -1215,6 +1230,7 @@ class Instaloader:
Use :meth:`Hashtag.get_posts_resumable`."""
return Hashtag.from_name(self.context, hashtag).get_posts_resumable()
@_requires_login
def download_hashtag(self, hashtag: Union[Hashtag, str],
max_count: Optional[int] = None,
post_filter: Optional[Callable[[Post], bool]] = None,
@@ -1302,6 +1318,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)
+336 -147
View File
@@ -2,12 +2,14 @@ import json
import os
import pickle
import random
import re
import shutil
import sys
import textwrap
import time
import urllib.parse
import uuid
from base64 import b64encode
from contextlib import contextmanager, suppress
from datetime import datetime, timedelta
from functools import partial
@@ -30,17 +32,30 @@ 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/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',
@@ -87,8 +102,7 @@ class InstaloaderContext:
self.user_agent = user_agent if user_agent is not None else default_user_agent()
self.request_timeout = request_timeout
self._session = self.get_anonymous_session()
self.username = None
self.user_id = None
self.username: Optional[str] = None
self.sleep = sleep
self.quiet = quiet
self.max_connection_attempts = max_connection_attempts
@@ -102,7 +116,7 @@ class InstaloaderContext:
self._rate_controller = rate_controller(self) if rate_controller is not None else RateController(self)
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
# Can be set to True for testing, disables suppression of InstaloaderContext._error_catcher
self.raise_all_errors = False
# HTTP status codes that should cause an AbortDownloadException
@@ -111,15 +125,16 @@ class InstaloaderContext:
# Cache profile from id (mapping from id to Profile)
self.profile_id_cache: Dict[int, Any] = dict()
# Tokens of the logged-in web page for api/graphql queries, by sessionid cookie
self._web_api_tokens_cache: Dict[str, Dict[str, str]] = dict()
@contextmanager
def anonymous_copy(self):
session = self._session
username = self.username
user_id = self.user_id
iphone_headers = self.iphone_headers
self._session = self.get_anonymous_session()
self.username = None
self.user_id = None
self.iphone_headers = default_iphone_headers()
try:
yield self
@@ -127,9 +142,22 @@ class InstaloaderContext:
self._session.close()
self.username = username
self._session = session
self.user_id = user_id
self.iphone_headers = iphone_headers
@property
def user_id(self) -> Optional[str]:
"""ID of the user this session is logged in as.
Derived from the ``ds_user_id`` cookie of the current session, so it is set for every
logged-in session, and ``None`` if not logged in."""
return self._session.cookies.get_dict().get('ds_user_id') or None
@user_id.setter
def user_id(self, user_id: Optional[str]) -> None:
# The setter is only provided for backwards-compatibility with pre-4.16.
if user_id != self.user_id:
raise AttributeError("user_id cannot be set to a different value.")
@property
def is_logged_in(self) -> bool:
"""True, if this Instaloader instance is logged in."""
@@ -149,6 +177,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.
@@ -191,7 +222,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']
@@ -333,7 +365,6 @@ class InstaloaderContext:
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session
self.username = user
self.user_id = resp_json['userId']
def two_factor_login(self, two_factor_code):
"""Second step of login if 2FA is enabled.
@@ -383,8 +414,102 @@ class InstaloaderContext:
f" when accessing {resp.url}"
)
@staticmethod
def _rate_limit_key(path: str, params: Dict[str, Any], host: str) -> str:
"""Determine the :class:`RateController` bucket that a request is accounted to."""
if 'graphql/query' in path or path == 'api/graphql':
if 'query_hash' in params:
return params['query_hash']
if 'doc_id' in params:
return params['doc_id']
if host == 'i.instagram.com':
return 'iphone'
return 'other'
def _raise_if_fatal_status(self, resp: requests.Response, host: str, path: str) -> None:
if resp.status_code not in self.fatal_status_codes:
return
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
body = ""
if resp.headers.get('Content-Type', '').startswith('application/json'):
body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '')
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format(
host, path, resp.status_code, resp.reason, redirect, body
))
def _raise_if_login_redirect(self, resp: requests.Response) -> None:
"""Turn a redirect to the login page into a meaningful exception.
Instagram answers requests it will not serve anonymously with a redirect to the login page
rather than with an error status, so this needs to be checked wherever we do not follow
redirects automatically.
"""
if not resp.is_redirect:
return
redirect_url = resp.headers.get('location', '')
if not (redirect_url.startswith('https://www.instagram.com/accounts/login') or
redirect_url.startswith('https://i.instagram.com/accounts/login')):
return
if not self.is_logged_in:
raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.")
raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " +
"some time, recreate the session and try again")
def _follow_redirects(self, sess: requests.Session, resp: requests.Response,
host: str, path: str, params: Dict[str, Any], use_post: bool) -> requests.Response:
"""Follow redirects that stay on *host*, and reject those to the login page."""
# Redirects are followed with GET. For a POST request, `params` is the request body and must not be
# turned into query arguments.
redirect_params = None if use_post else params
while resp.is_redirect:
redirect_url = resp.headers['location']
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
self._raise_if_login_redirect(resp)
if not redirect_url.startswith('https://{}/'.format(host)):
break
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
params=redirect_params, allow_redirects=False)
return resp
def _raise_for_status(self, resp: requests.Response) -> None:
"""Translate an unsuccessful HTTP status code into the corresponding exception."""
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))
if resp.status_code == 429:
raise TooManyRequestsException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
def _raise_for_download_status(self, resp: requests.Response) -> None:
"""Translate an unsuccessful status code of an anonymous download request into an exception.
This is deliberately a different policy from :meth:`_raise_for_status`, which applies to the
JSON API: a download may be answered with 403 for an invalid URL signature, and is never
answered with the 400 or 429 that the API uses.
"""
if resp.status_code == 200:
return
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com',
session: Optional[requests.Session] = None, _attempt=1,
session: Optional[requests.Session] = None,
response_headers: Optional[Dict[str, Any]] = None,
use_post: bool = False) -> Dict[str, Any]:
"""JSON request to Instagram.
@@ -395,93 +520,121 @@ 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.
.. versionchanged:: 4.13
Added `use_post` parameter.
.. versionchanged:: 4.16
Removed the internal `_attempt` parameter; retries are no longer implemented by recursion.
"""
is_graphql_query = 'query_hash' in params and 'graphql/query' in path
is_doc_id_query = 'doc_id' in params and 'graphql/query' in path
is_iphone_query = host == 'i.instagram.com'
is_other_query = not is_graphql_query and not is_doc_id_query and host == "www.instagram.com"
sess = session if session else self._session
try:
self.do_sleep()
if is_graphql_query:
self._rate_controller.wait_before_query(params['query_hash'])
if is_doc_id_query:
self._rate_controller.wait_before_query(params['doc_id'])
if is_iphone_query:
self._rate_controller.wait_before_query('iphone')
if is_other_query:
self._rate_controller.wait_before_query('other')
if use_post:
resp = sess.post('https://{0}/{1}'.format(host, path), data=params, allow_redirects=False)
else:
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
if resp.status_code in self.fatal_status_codes:
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
body = ""
if resp.headers['Content-Type'].startswith('application/json'):
body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '')
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format(
host, path, resp.status_code, resp.reason, redirect, body
))
while resp.is_redirect:
redirect_url = resp.headers['location']
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
if (redirect_url.startswith('https://www.instagram.com/accounts/login') or
redirect_url.startswith('https://i.instagram.com/accounts/login')):
if not self.is_logged_in:
raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.")
raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " +
"some time, recreate the session and try again")
if redirect_url.startswith('https://{}/'.format(host)):
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
params=params, allow_redirects=False)
else:
break
if response_headers is not None:
response_headers.clear()
response_headers.update(resp.headers)
if resp.status_code == 400:
raise QueryReturnedBadRequestException(self._response_error(resp))
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code == 429:
raise TooManyRequestsException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
else:
resp_json = resp.json()
if 'status' in resp_json and resp_json['status'] != "ok":
raise ConnectionException(self._response_error(resp))
return resp_json
except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err:
error_string = "JSON Query to {}: {}".format(path, err)
if _attempt == self.max_connection_attempts:
if isinstance(err, QueryReturnedNotFoundException):
raise QueryReturnedNotFoundException(error_string) from err
else:
raise ConnectionException(error_string) from err
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
sess = session or self._session
rate_limit_key = self._rate_limit_key(path, params, host)
url = 'https://{0}/{1}'.format(host, path)
# The failure we are currently retrying, if any. It drives the 429 backoff performed before the
# next attempt, and tells us whether a ^C means "skip this query" rather than "abort everything".
pending_error: Optional[BaseException] = None
pending_error_string = ""
for attempt in range(1, self.max_connection_attempts + 1):
try:
if isinstance(err, TooManyRequestsException):
if is_graphql_query:
self._rate_controller.handle_429(params['query_hash'])
if is_doc_id_query:
self._rate_controller.handle_429(params['doc_id'])
if is_iphone_query:
self._rate_controller.handle_429('iphone')
if is_other_query:
self._rate_controller.handle_429('other')
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1,
response_headers=response_headers)
if isinstance(pending_error, TooManyRequestsException):
self._rate_controller.handle_429(rate_limit_key)
self.do_sleep()
self._rate_controller.wait_before_query(rate_limit_key)
if use_post:
resp = sess.post(url, data=params, allow_redirects=False)
else:
resp = sess.get(url, params=params, allow_redirects=False)
self._raise_if_fatal_status(resp, host, path)
resp = self._follow_redirects(sess, resp, host, path, params, use_post)
if response_headers is not None:
response_headers.clear()
response_headers.update(resp.headers)
self._raise_for_status(resp)
resp_json = resp.json()
if 'status' in resp_json and resp_json['status'] != "ok":
raise ConnectionException(self._response_error(resp))
return resp_json
except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err:
error_string = "JSON Query to {}: {}".format(path, err)
if attempt == self.max_connection_attempts:
if isinstance(err, QueryReturnedNotFoundException):
raise QueryReturnedNotFoundException(error_string) from err
raise ConnectionException(error_string) from err
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
pending_error, pending_error_string = err, error_string
except KeyboardInterrupt:
if pending_error is None:
raise
self.error("[skipped by user]", repeat_at_end=False)
raise ConnectionException(error_string) from err
raise ConnectionException(pending_error_string) from pending_error
raise AssertionError("get_json() retry loop exited without result")
@contextmanager
def _graphql_session(self, referer: Optional[str], csrf: Optional[str] = None,
lsd: Optional[str] = None) -> Iterator[requests.Session]:
"""Short-lived session carrying the headers that the GraphQL endpoint expects.
Pass the page's `lsd` token for the web app's ``api/graphql`` endpoint."""
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']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
if csrf is not None:
tmpsession.headers['x-csrftoken'] = csrf
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
if lsd is not None:
tmpsession.headers.update({'x-fb-lsd': lsd, 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin'})
yield tmpsession
def _csrf_token(self) -> str:
"""The csrftoken cookie value, requesting the start page once if we do not have one yet."""
def token_from_cookies() -> Optional[str]:
return next((c.value for c in self._session.cookies if c.name == 'csrftoken' and c.value), None)
csrf = token_from_cookies()
if csrf:
return csrf
self._session.get('https://www.instagram.com/', timeout=self.request_timeout)
return token_from_cookies() or ''
def _web_api_tokens(self) -> Dict[str, str]:
"""The tokens that the web app sends along with its api/graphql queries, from the start page."""
sessionid = self._session.cookies.get_dict().get("sessionid", "")
if sessionid not in self._web_api_tokens_cache:
html = self._get_page(self._session, "")
tokens = {}
for name, pattern in [
("fb_dtsg", r'"DTSGInitialData",\[\],\{[^{}]*"token":"([^"]+)"'),
("lsd", r'"LSD",\[\],\{[^{}]*"token":"([^"]+)"'),
("av", r'"IG_USER_EIMU":"?(\d+)'),
]:
match = re.search(pattern, html)
if match is not None:
tokens[name] = match.group(1)
# Logged out, the start page has no fb_dtsg, and the web app sends its queries without.
elif name != "fb_dtsg" or self.is_logged_in:
raise BadResponseException(
"The start page did not contain the token {}.".format(name)
)
self._web_api_tokens_cache[sessionid] = tokens
return self._web_api_tokens_cache[sessionid]
def graphql_query(self, query_hash: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]:
@@ -496,18 +649,8 @@ class InstaloaderContext:
.. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter.
"""
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']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
variables_json = json.dumps(variables, separators=(',', ':'))
variables_json = json.dumps(variables, separators=(',', ':'))
with self._graphql_session(referer) as tmpsession:
resp_json = self.get_json('graphql/query',
params={'query_hash': query_hash,
'variables': variables_json},
@@ -517,7 +660,7 @@ class InstaloaderContext:
return resp_json
def doc_id_graphql_query(self, doc_id: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]:
referer: Optional[str] = None, web_api: bool = False) -> Dict[str, Any]:
"""
Do a doc_id-based GraphQL Query using method POST.
@@ -526,27 +669,23 @@ class InstaloaderContext:
:param doc_id: doc_id for the query.
:param variables: Variables for the Query.
:param referer: HTTP Referer, or None.
:param web_api: Query the web app's ``api/graphql`` endpoint, as the web app does, rather than
``graphql/query``.
:return: The server's response dictionary.
.. versionchanged:: 4.16
Added `web_api` parameter.
"""
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']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
variables_json = json.dumps(variables, separators=(',', ':'))
resp_json = self.get_json('graphql/query',
params={'variables': variables_json,
'doc_id': doc_id,
'server_timestamps': 'true'},
session=tmpsession,
use_post=True)
if 'status' not in resp_json:
variables_json = json.dumps(variables, separators=(',', ':'))
params = {'variables': variables_json,
'doc_id': doc_id,
'server_timestamps': 'true',
**(self._web_api_tokens() if web_api else {})}
with self._graphql_session(referer, csrf=self._csrf_token(), lsd=params.get('lsd')) as tmpsession:
resp_json = self.get_json('api/graphql' if web_api else 'graphql/query', params=params,
session=tmpsession, use_post=True)
# api/graphql does not respond with a "status" field.
if not web_api and 'status' not in resp_json:
self.error("GraphQL response did not contain a \"status\" field.")
return resp_json
@@ -599,12 +738,33 @@ class InstaloaderContext:
.. versionadded:: 4.2.1"""
with copy_session(self._session, self.request_timeout) as tempsession:
# Set headers to simulate an API request from iPad
tempsession.headers['ig-intended-user-id'] = str(self.user_id)
# ig-intended-user-id should name the logged-in user; self.user_id is derived
# from the ds_user_id cookie, so it is set for any logged-in session. Without a
# logged-in user, omit the header rather than sending the literal string "None".
if self.user_id is not None:
tempsession.headers['ig-intended-user-id'] = self.user_id
tempsession.headers['x-pigeon-rawclienttime'] = '{:.6f}'.format(time.time())
# Add headers obtained from previous iPad request
tempsession.headers.update(self.iphone_headers)
# The mobile API requires an Authorization bearer token for most
# endpoints (reels_media, users/info, ...); without it they answer
# with empty data or a "fail" status. A token is handed out via
# the ig-set-authorization response header, but only by the few
# endpoints that still accept cookie-only requests (e.g.
# media/<id>/info/), so relying on having seen one makes success
# depend on request order. The token is deterministically derived
# from the session cookies, so construct it if none is known yet.
if 'authorization' not in tempsession.headers and self.user_id is not None:
sessionid = tempsession.cookies.get_dict().get('sessionid')
if sessionid:
token = b64encode(json.dumps(
{'ds_user_id': self.user_id,
'sessionid': sessionid},
separators=(',', ':')).encode()).decode()
tempsession.headers['authorization'] = 'Bearer IGT:2:{}'.format(token)
# Extract key information from cookies if we haven't got it already from a previous request
header_cookies_mapping = {'x-mid': 'mid',
'ig-u-ds-user-id': 'ds_user_id',
@@ -650,6 +810,44 @@ class InstaloaderContext:
return response
def _get_page(self, session: requests.Session, path: str) -> str:
"""Load a page of ``www.instagram.com`` and return its HTML."""
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:
resp = session.get(url, headers=headers, allow_redirects=False)
except requests.exceptions.RequestException as err:
raise ConnectionException("Page request to {}: {}".format(url, err)) from err
self._raise_if_login_redirect(resp)
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
return resp.text
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.
"""
with self.get_anonymous_session() as anonymous_session:
html = self._get_page(anonymous_session, path)
data: List[Dict[str, Any]] = []
for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', html, 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.
@@ -662,27 +860,26 @@ class InstaloaderContext:
file.write(resp)
os.replace(filename + '.temp', filename)
def get_raw(self, url: str, _attempt=1) -> requests.Response:
def get_raw(self, url: str) -> requests.Response:
"""Downloads a file anonymously.
This does not retry on its own; callers that need retrying are decorated with
``_retry_on_connection_error``.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises QueryReturnedForbiddenException: When the server responds with a 403.
:raises ConnectionException: When download failed.
.. versionadded:: 4.2.1"""
.. versionadded:: 4.2.1
.. versionchanged:: 4.16
Removed the `_attempt` parameter.
"""
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, stream=True)
if resp.status_code == 200:
resp.raw.decode_content = True
return resp
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
self._raise_for_download_status(resp)
resp.raw.decode_content = True
return resp
def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file.
@@ -703,16 +900,8 @@ class InstaloaderContext:
"""
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.head(url, allow_redirects=allow_redirects)
if resp.status_code == 200:
return resp
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
self._raise_for_download_status(resp)
return resp
class RateController:
+23 -9
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):
@@ -69,6 +69,9 @@ class NodeIterator(Iterator[T]):
.. versionchanged: 4.13
Included support for `doc_id`-based queries (using POST method).
.. versionchanged: 4.16
Added `web_api` parameter, see :meth:`InstaloaderContext.doc_id_graphql_query`.
"""
_graphql_page_length = 12
@@ -83,10 +86,12 @@ class NodeIterator(Iterator[T]):
query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T, Optional[T]], bool]] = None,
doc_id: Optional[str] = None):
doc_id: Optional[str] = None,
web_api: bool = False):
self._context = context
self._query_hash = query_hash
self._doc_id = doc_id
self._web_api = web_api
self._edge_extractor = edge_extractor
self._node_wrapper = node_wrapper
self._query_variables = query_variables if query_variables is not None else {}
@@ -99,6 +104,7 @@ class NodeIterator(Iterator[T]):
else:
self._data = self._query()
self._first_node: Optional[Dict] = None
self._first_item: Optional[T] = None
self._is_first = is_first
def _query(self, after: Optional[str] = None) -> Dict:
@@ -109,15 +115,15 @@ class NodeIterator(Iterator[T]):
return self._query_query_hash(self._query_hash, after)
def _query_doc_id(self, doc_id: str, after: Optional[str] = None) -> Dict:
pagination_variables: Dict[str, Any] = {'__relay_internal__pv__PolarisFeedShareMenurelayprovider': False}
pagination_variables: Dict[str, Any] = {}
if after is not None:
pagination_variables['after'] = after
pagination_variables['before'] = None
pagination_variables['first'] = 12
pagination_variables['first'] = NodeIterator._graphql_page_length
pagination_variables['last'] = None
data = self._edge_extractor(
self._context.doc_id_graphql_query(
doc_id, {**self._query_variables, **pagination_variables}, self._query_referer
doc_id, {**self._query_variables, **pagination_variables}, self._query_referer, self._web_api
)
)
self._best_before = datetime.now() + NodeIterator._shelf_life
@@ -151,10 +157,10 @@ class NodeIterator(Iterator[T]):
item = self._node_wrapper(node)
if self._is_first is not None:
if self._is_first(item, self.first_item):
self._first_node = node
self._first_node, self._first_item = node, item
else:
if self._first_node is None:
self._first_node = node
self._first_node, self._first_item = node, item
return item
if self._data.get('page_info', {}).get('has_next_page'):
query_response = self._query(self._data['page_info']['end_cursor'])
@@ -201,7 +207,15 @@ class NodeIterator(Iterator[T]):
.. versionchanged:: 4.9.2
What is considered the first item can be overridden.
"""
return self._node_wrapper(self._first_node) if self._first_node is not None else None
if self._first_item is None:
# Restore _first_item from _first_node. Needed after thawing, since
# FrozenNodeIterator stores the node, not the item.
self._first_item = (
self._node_wrapper(self._first_node)
if self._first_node is not None
else None
)
return self._first_item
@staticmethod
def page_length() -> int:
@@ -318,7 +332,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)
+328 -75
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]:
@@ -180,7 +180,7 @@ class Post:
This class unifies access to the properties associated with a post. It implements == and is
hashable.
:param context: :attr:`Instaloader.context` used for additional queries if neccessary..
:param context: :attr:`Instaloader.context` used for additional queries if necessary..
:param node: Node structure, as returned by Instagram.
:param owner_profile: The Profile of the owner, if already known at creation.
"""
@@ -258,6 +258,96 @@ 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]) -> 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")
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 +409,20 @@ 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,
},
# As the logged-out web app does.
web_api=not self._context.is_logged_in,
)
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])
if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict)
raise PostChangedException
@@ -512,14 +599,14 @@ class Post:
@property
def caption_hashtags(self) -> List[str]:
"""List of all lowercased hashtags (without preceeding #) that occur in the Post's caption."""
"""List of all lowercased hashtags (without preceding #) that occur in the Post's caption."""
if not self.caption:
return []
return _hashtag_regex.findall(self.caption.lower())
@property
def caption_mentions(self) -> List[str]:
"""List of all lowercased profiles that are mentioned in the Post's caption, without preceeding @."""
"""List of all lowercased profiles that are mentioned in the Post's caption, without preceding @."""
if not self.caption:
return []
return _mention_regex.findall(self.caption.lower())
@@ -602,6 +689,19 @@ 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:
play_count = self._field('video_play_count')
# Not logged in, the clips connection is null.
if play_count is None and self.video_view_count is None and self._context.is_logged_in:
play_count = Post._fetch_play_count_from_clips(self._context, str(self.owner_id), self.shortcode)
return play_count
return None
@property
def video_duration(self) -> Optional[float]:
"""Duration of the video in seconds, or None.
@@ -781,19 +881,9 @@ class Post:
if self.likes == 0:
# Avoid doing additional requests if there are no comments
return
likes_edges = self._field('edge_media_preview_like', 'edges')
if self.likes == len(likes_edges):
# If the Post's metadata already contains all likes, don't do GraphQL requests to obtain them
yield from (Profile(self._context, like['node']) for like in likes_edges)
return
yield from NodeIterator(
self._context,
'1cb6ec562846122743b61e492c85999f',
lambda d: d['data']['shortcode_media']['edge_liked_by'],
lambda n: Profile(self._context, n),
{'shortcode': self.shortcode},
'https://www.instagram.com/p/{0}/'.format(self.shortcode),
)
data = self._context.get_json(f"api/v1/media/{self.mediaid}/likers/", {})
for user in data["users"]:
yield Profile(self._context, user)
@property
def is_sponsored(self) -> bool:
@@ -880,7 +970,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 +991,30 @@ 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 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:
page = context.get_page_data("{0}/".format(username.lower()))
except QueryReturnedNotFoundException:
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):
@@ -916,15 +1027,24 @@ 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'])
try:
user = context.doc_id_graphql_query(
'27937681195819736',
{
"id": str(profile_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,
},
web_api=True,
).get('data', {}).get('user')
except QueryReturnedNotFoundException:
user = None
if user and user.get('username'):
profile = cls(context, cls._normalize_profile_data(user))
else:
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
str(profile_id) + ").")
@@ -970,11 +1090,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 +1139,48 @@ class Profile:
', '.join(similar_profiles[0:5]))) from err
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) from err
@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:
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']}
# The logged-out profile page has all_media_count instead.
if 'edge_owner_to_timeline_media' not in normalized and 'all_media_count' in normalized:
normalized['edge_owner_to_timeline_media'] = {'count': normalized['all_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
@@ -1043,7 +1235,11 @@ class Profile:
return self._metadata('followed_by_viewer')
@property
def mediacount(self) -> int:
def mediacount(self) -> Optional[int]:
"""Number of posts, or None if unavailable (e.g. when not logged in).
.. versionchanged:: 4.16
Returns None if unavailable (e.g. when not logged in)."""
return self._metadata('edge_owner_to_timeline_media', 'count')
@property
@@ -1079,7 +1275,7 @@ class Profile:
@property
def biography_hashtags(self) -> List[str]:
"""
List of all lowercased hashtags (without preceeding #) that occur in the Profile's biography.
List of all lowercased hashtags (without preceding #) that occur in the Profile's biography.
.. versionadded:: 4.10
"""
@@ -1090,7 +1286,7 @@ class Profile:
@property
def biography_mentions(self) -> List[str]:
"""
List of all lowercased profiles that are mentioned in the Profile's biography, without preceeding @.
List of all lowercased profiles that are mentioned in the Profile's biography, without preceding @.
.. versionadded:: 4.10
"""
@@ -1195,20 +1391,75 @@ class Profile:
def get_posts(self) -> NodeIterator[Post]:
"""Retrieve all posts from a profile.
:rtype: NodeIterator[Post]"""
:rtype: NodeIterator[Post]
:raises PrivateProfileNotFollowedException: When logged in and the profile is private and not followed.
:raises LoginRequiredException: When not logged in and the profile is private."""
if not self._context.is_logged_in:
# As the logged-out web app, take the first posts from the profile page, and query further ones
# (PolarisLoggedOutDesktopWWWProfilePostsTabContentQuery_connection) by the page's "id" of the
# profile, which is not its user ID.
page = self._context.get_page_data(f"{self.username}/")
# The page shows no posts of a private profile, rather than an error.
if any((data.get("xig_user_by_username") or {}).get("is_private") for data in page):
raise LoginRequiredException(f"Login required to access the posts of private profile {self.username}.")
user = next((data["xig_user_by_username"] for data in page
if "polaris_ordered_timeline_connection" in (data.get("xig_user_by_username") or {})),
None)
if user is None:
raise BadResponseException(f"The page of profile {self.username} did not contain its posts.")
return NodeIterator(
context=self._context,
edge_extractor=lambda d: d["data"]["node"]["polaris_ordered_timeline_connection"],
# The nodes lack most of the post's metadata, as in get_reels().
node_wrapper=lambda n: Post.from_shortcode(self._context, n["code"]),
query_variables={
"id": user["id"],
"__relay_internal__pv__PolarisSEOIsCrawlerrelayprovider": False,
},
query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(),
doc_id="28904005159232689",
query_hash=None,
first_data=user["polaris_ordered_timeline_connection"],
web_api=True,
)
self._obtain_metadata()
# Instagram answers with an empty timeline rather than an error in that case. Unlike followed_by_viewer,
# which defaults to False, friendship_status tells whether the follow state is actually known.
if (self.is_private and self.username != (self._context.username or '').lower() and
(self._node.get('friendship_status') or {}).get('following') is False):
raise PrivateProfileNotFollowedException("Profile {} is private but not followed.".format(self.username))
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"],
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 queries, 7898261790222653 ("execution error") and 28975909992013618
# (400 "feedback_required"), with errors.
# It must be sent to the web app's api/graphql endpoint.
query_variables={
"data": {
"count": 12,
"include_reel_media_seen_timestamp": True,
"include_relationship_info": True,
"latest_besties_reel_media": True,
"latest_reel_media": True,
},
"first": 12,
"include_multi_captions": True,
"username": self.username,
"__relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider": True,
"__relay_internal__pv__PolarisShortDramaEnabledrelayprovider": True,
"__relay_internal__pv__PolarisSEOIsCrawlerrelayprovider": False,
"__relay_internal__pv__PolarisThreadsStyleCommentsEnabledrelayprovider": 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="29104886249145907",
query_hash=None,
web_api=True,
)
def get_saved_posts(self) -> NodeIterator[Post]:
@@ -1534,7 +1785,7 @@ class StoryItem:
@property
def caption_hashtags(self) -> List[str]:
"""
List of all lowercased hashtags (without preceeding #) that occur in the StoryItem's caption.
List of all lowercased hashtags (without preceding #) that occur in the StoryItem's caption.
.. versionadded:: 4.10
"""
@@ -1545,7 +1796,7 @@ class StoryItem:
@property
def caption_mentions(self) -> List[str]:
"""
List of all lowercased profiles that are mentioned in the StoryItem's caption, without preceeding @.
List of all lowercased profiles that are mentioned in the StoryItem's caption, without preceding @.
.. versionadded:: 4.10
"""
@@ -1844,11 +2095,11 @@ class Hashtag:
@classmethod
def from_name(cls, context: InstaloaderContext, name: str):
"""
Create a Hashtag instance from a given hashtag name, without preceeding '#'. Raises an Exception if there is no
Create a Hashtag instance from a given hashtag name, without preceding '#'. Raises an Exception if there is no
hashtag with the given name.
:param context: :attr:`Instaloader.context`
:param name: Hashtag, without preceeding '#'
:param name: Hashtag, without preceding '#'
:raises: :class:`QueryReturnedNotFoundException`
"""
# pylint:disable=protected-access
@@ -1858,11 +2109,13 @@ class Hashtag:
@property
def name(self):
"""Hashtag name lowercased, without preceeding '#'"""
"""Hashtag name lowercased, without preceding '#'"""
return self._node["name"].lower()
def _query(self, params):
json_response = self._context.get_json("explore/tags/{0}/".format(self.name), params)
json_response = self._context.get_iphone_json(
"api/v1/tags/web_info/", {**params, "tag_name": self.name}
)
return json_response["graphql"]["hashtag"] if "graphql" in json_response else json_response["data"]
def _obtain_metadata(self):
@@ -2185,7 +2438,7 @@ def load_structure(context: InstaloaderContext, json_structure: dict) -> JsonExp
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
a json structure.
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary.
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if necessary.
:param json_structure: Instaloader JSON structure
.. versionadded:: 4.8
@@ -2215,7 +2468,7 @@ def load_structure_from_file(context: InstaloaderContext, filename: str) -> Json
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
a '.json' or '.json.xz' file that has been saved by :func:`save_structure_to_file`.
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary.
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if necessary.
:param filename: Filename, ends in '.json' or '.json.xz'
"""
compressed = filename.endswith('.xz')
+5 -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,11 +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
-214
View File
@@ -1,214 +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
if __name__ == '__main__':
unittest.main()