Compare commits

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

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

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

Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2025-11-08 18:10:47 +01:00
Alexander Graf 813621026c Update default user agent 2025-11-08 17:36:41 +01:00
Alexander Graf 44bd4d173b Update dependency versions used in CI 2025-11-08 17:33:16 +01:00
Naitri Doshi 2393a1a7a4 Add Post.video_play_count property (#2595) 2025-10-23 11:02:21 +00:00
Alexander Graf 5304e9794b First Alpha Release for Version 4.15 2025-07-18 08:06:07 +02:00
Alexander Graf ee3106814e Update default user agent 2025-07-18 08:05:11 +02:00
Alexander Graf 4c6d6e50af Update dependency versions used in CI 2025-07-18 08:05:11 +02:00
Alexander Graf af287c3fdd Merge branch 'master' into upcoming/v4.15 2025-07-18 07:51:08 +02:00
Alexander Graf 0bc9ab55dd Release of version 4.14.2 2025-07-18 07:47:19 +02:00
Ivan Semkin e64394fd25 Add possibly_pinned to download reels loop (#2539)
Add `possibly_pinned` to reels loop.
2025-07-04 07:45:07 +02:00
Alexander Graf 43cc15d1b3 Update paragraph explaining --load-cookies and --cookiefile 2025-01-29 08:09:38 +01:00
Alexander Graf a263ec85a6 Update default user agent 2025-01-29 07:52:35 +01:00
Alexander Graf 487d2c366d Update dependency versions used in CI
Changes to browser_cookie3: https://github.com/borisbabic/browser_cookie3/compare/0.19.1...0.20.1
2025-01-29 07:50:01 +01:00
master131 e493fa9c2d Update headers for iOS (#2472) 2025-01-24 08:23:49 +01:00
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
Alexander Graf 981eec9182 Third release candidate for version 4.14 2024-11-07 08:12:40 +01:00
Alexander Graf c54b61346e Merge branch 'master' into upcoming/v4.14 2024-11-07 08:12:00 +01:00
tinyboxvk fe9c8e1604 Bump pipenv, remove Python 3.8, add Python 3.13 (#2413) 2024-10-26 22:07:53 +02:00
tinyboxvk 524fb21b80 Update windows_exe.yml to address deprecation warnings (#2412) 2024-10-26 18:40:55 +02:00
Alexander Graf ed4add951f Second release candidate for version 4.14 2024-10-26 18:16:31 +02:00
Alexander Graf 8e6b2d83d3 Merge branch 'master' into upcoming/v4.14 2024-10-26 18:15:04 +02:00
Alexander Graf ba8faec6fe Release of version 4.13.2 2024-10-26 18:08:19 +02:00
Alexander Graf 9a74d9e109 Post._obtain_metadata: Use new XDT doc_id graphql query
Fixes #2384.
2024-10-26 18:08:19 +02:00
Alexander Graf 08f805482c First release candidate for version 4.14 2024-10-02 08:07:40 +02:00
Alexander Graf 308244b809 Merge branch 'master' into upcoming/v4.14 2024-10-02 08:06:35 +02:00
exwm b0c42a2662 feat: add --reels flag for downloading Reels videos from profiles (#2355) 2024-10-02 08:03:53 +02:00
Alexander Graf c5dacb5f6d Release of version 4.13.1 2024-08-28 08:29:57 +02:00
Eduardo Kalinowski 3a314de191 Remove rhx_gis header support (#2340)
The query used to obtain it does not work anymore.

The parameter was only used when obtaining a profile by its id, when
not logged in. This query is working fine without the special header,
so the whole functionality concerning rhx_gis was removed.

This also fixes this misleading messages logged when trying to obtain
a profile anonymously:
JSON Query to : Expecting value: line 1 column 1 (char 0)
2024-08-28 08:28:35 +02:00
Alex Chan 3a95250265 Clarify that post short codes should be preceded by a hyphen (#2343) 2024-08-28 08:27:58 +02:00
Yash Malik 2a3e5980ff minor doc update in basic-usage.rst (#2336)
(almost) nobody says "Id est". Even I, being a native English speaker, had to search for it. It should've been "i.e." in the first place. But, even that is not needed. Let's keep it simple.
2024-08-15 13:20:09 +02:00
Alexander Graf 8159229203 Update default user agent 2024-08-10 18:17:42 +02:00
Alexander Graf ae9a5efa85 Update dependencies 2024-08-10 18:17:42 +02:00
Alexander Graf e159065799 Release of Version 4.13 2024-08-10 17:36:41 +02:00
Eduardo Kalinowski ed21752365 Use new doc_id based query to get posts (#2330)
Fixes #2319.
2024-08-10 17:27:07 +02:00
Alexander Graf b0157d9bc1 Merge branch 'master' into upcoming/v4.13 2024-08-10 17:13:54 +02:00
Alexander Graf 754c3845d7 Release of Version 4.12.2 2024-08-10 17:00:58 +02:00
Caramel7 e3b8c38f8c Reduce GraphQL page length to 12 (#2332)
Fixes #2319.
2024-08-10 17:00:19 +02:00
Alexander Graf 7dac8ad1e5 Release of Version 4.12.1 2024-07-31 08:47:41 +02:00
Liponex b4cb9ea83a PEP 561 compliance enhancement (#2314) 2024-07-31 08:46:00 +02:00
Michel Ledig eb2accc088 feat: add max_count parameter to limit posts on download (#2276) 2024-07-17 08:03:57 +02:00
Seamus MullanandAlexander Graf 66c257362d Fix for issue #1085 (#2214)
Implement check for post.mediacount before checking if posts have been downloaded.

---------

Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2024-07-03 08:19:16 +02:00
Alexander Graf a0169b7094 Update default user agent 2024-06-29 19:48:19 +02:00
Alexander Graf 0a9fccaf26 Update dependencies 2024-06-29 19:48:19 +02:00
Alexander Graf 36d9f66e8e Release of Version 4.12 2024-06-29 19:20:24 +02:00
Alexander Graf 5e04063002 Merge branch 'master' into upcoming/v4.12 2024-06-29 19:19:30 +02:00
Alexander Graf 76e8154b58 Release of Version 4.11.1 2024-06-29 19:13:16 +02:00
Alexander Graf 4a969882b5 Produce more verbose HTTP error messages 2024-06-29 17:52:12 +02:00
Alexander Graf dc1eb2b193 Catch KeyboardInterrupt when asking for password 2024-05-30 10:22:45 +02:00
Alexander Graf 2a95589a1d Fix checkpoint URL output 2024-05-30 10:15:46 +02:00
Eduardo M KALINOWSKI 0c7398882b Detect errors when checking if the user is logged in 2024-05-30 08:04:51 +00:00
tinyboxvk 3b705fe5d1 Update actions versions (#2260)
Fix GitHub Actions Node.js deprecation warnings.

Use actions/setup-python cache where appropriate.
2024-05-20 20:07:44 +02:00
Alexander Graf 3ac790d30f Fix actions/stale 'leave open' handling 2024-05-20 09:50:48 +02:00
Alexander Graf fbfcd5df18 Close stale issues as "not planned"
Fixes #2168.
2024-05-18 16:32:35 +02:00
Alexander Graf b44a006344 Replace deprecated 'set-output' command in packages workflow 2024-05-18 16:17:59 +02:00
Eduardo Kalinowski 253144203b Download stories when --load-cookies is used (#2263) 2024-05-18 16:13:44 +02:00
Eduardo Kalinowski 7d0e46798e Add support for more browsers (#2258) 2024-05-18 16:11:12 +02:00
Alexander Graf fd8245a216 Fix workflow that deploys the documentation 2024-05-18 15:54:38 +02:00
tinyboxvk 3e335984f2 Replace deprecated 'set-output' command (#2261) 2024-05-18 15:44:36 +02:00
Hector Oliveros 98791fb0aa Add login required message when getting comments (#2237)
The comment function in the command line options documentation and the main program file has been edited to state that it now requires login. This change was crucial as the additional requests to Instagram server made for each post necessitate user authentication.
2024-05-12 18:16:36 +00:00
Eduardo Kalinowski 65b12d650c Use different exit codes for different failures (#2243) 2024-05-12 18:10:23 +00:00
Henry Mai 1fb98b066a docs: fix wording and grammar usage (#2203) 2024-03-29 10:20:13 +01:00
Alexander Graf 14f1c3cb82 Release of version 4.11 2024-03-18 08:08:09 +01:00
Alexander Graf 7a36512093 Third release candidate of version 4.11 2024-03-17 21:08:38 +01:00
Alexander Graf f423aa1507 Update dependencies 2024-03-17 21:04:56 +01:00
Alexander Graf 1875aede82 Merge branch 'master' into upcoming/v4.11 2024-03-17 20:49:37 +01:00
Alexander Graf 2cae933d66 Fix downloading comments
Closes issue #2125 by using an iphone endpoint for posts that have more
than 50 comments.
2024-03-17 20:42:26 +01:00
Alexander Graf 5509ea7daf Update sponsors in README 2024-01-12 08:29:09 +01:00
Alexander Graf 2b594909bb Release of version 4.10.3 2024-01-12 08:15:56 +01:00
Alexander Graf 1852447825 Fix downloading comments
Closes issue #2125 by using an iphone endpoint for posts that have more
than 50 comments.
2024-01-12 08:13:45 +01:00
Eduardo Kalinowski baaa2c2998 Add myself to AUTHORS.md (#2160) 2024-01-08 12:15:13 +01:00
Eduardo Kalinowski 47235bf2b8 Change pinned post detection, fixes #2142 (#2147)
The property that indicated whether a post is pinned is not available
anymore. As a workaround, the first five posts are treated differently
(as pinned posts were): if they were already downloaded, they are
skipped, but they do not cause the iteration to stop.
2024-01-08 07:46:27 +01:00
42 changed files with 2380 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
+10 -4
View File
@@ -2,17 +2,23 @@
<!-- <!--
Thanks for your willingness to contribute to Instaloader! Please briefly Thanks for your willingness to contribute to Instaloader! Please briefly
describe describe:
- A motivation for this change, e.g. - A motivation for this change, e.g.
- Fixes # . - Fixes # .
- More general: What problem does the pull request solve? - What problem does the pull request solve?
- The changes proposed in this pull request - The changes proposed in this pull request
- Keep the PR focused: avoid bundling independent fixes or refactorings.
- The completeness of this change - The completeness of this change
- Is it just a proof of concept?
- Is the documentation updated (if appropriate)? - 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? - 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?
--> -->
+7 -5
View File
@@ -8,22 +8,24 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout Instaloader Repository" - name: "Checkout Instaloader Repository"
uses: actions/checkout@v2 uses: actions/checkout@v7
with: with:
fetch-depth: 0 # needed for building docs fetch-depth: 0 # needed for building docs
persist-credentials: false
- name: "Setup Python" - name: "Setup Python"
uses: actions/setup-python@v2 uses: actions/setup-python@v7
with: with:
python-version: 3.12 python-version: 3.15
cache: 'pipenv'
- name: "Install Dependencies" - name: "Install Dependencies"
run: | run: |
python -m pip install pipenv==2023.10.3 python -m pip install pipenv==2026.8.0
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: "Build Documentation" - name: "Build Documentation"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n" run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
- name: "Deploy Documentation" - name: "Deploy Documentation"
if: github.event_name == 'push' && github.ref == 'refs/heads/master' if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: JamesIves/github-pages-deploy-action@v4.2.5 uses: JamesIves/github-pages-deploy-action@v4
with: with:
branch: master branch: master
repository-name: instaloader/instaloader.github.io repository-name: instaloader/instaloader.github.io
+7 -4
View File
@@ -9,17 +9,20 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
steps: steps:
- name: Checkout Instaloader Repository - name: Checkout Instaloader Repository
uses: actions/checkout@v2 uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v2 uses: actions/setup-python@v7
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: 'pipenv'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
python -m pip install pipenv==2023.10.3 python -m pip install pipenv==2026.8.0
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint - name: PyLint
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
+10 -8
View File
@@ -11,23 +11,25 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout Repository" - name: "Checkout Repository"
uses: actions/checkout@v2 uses: actions/checkout@v7
- name: "Setup Python"
uses: actions/setup-python@v2
with: 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" - name: "Get the tagged version"
id: get_version id: get_version
env: env:
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/} run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
shell: bash shell: bash
- name: "Install Dependencies" - name: "Install Dependencies"
run: python -m pip install setuptools run: python -m pip install -U setuptools wheel build
- name: "Generate Python Package" - name: "Generate Python Package"
run: python setup.py sdist run: python -m build
- name: "Publish Distribution to PyPI" - name: "Publish Distribution to PyPI"
uses: pypa/gh-action-pypi-publish@master uses: pypa/gh-action-pypi-publish@release/v1
with: with:
password: ${{ secrets.PYPI_PASSWORD }} password: ${{ secrets.PYPI_PASSWORD }}
- name: "Publish Distribution to AUR" - name: "Publish Distribution to AUR"
+5 -5
View File
@@ -2,7 +2,7 @@ name: Mark stale issues and pull requests
on: on:
schedule: schedule:
- cron: "10 14 * * *" - cron: "55 7 * * *"
jobs: jobs:
stale: stale:
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v3 - uses: actions/stale@v11
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: 'question' only-labels: 'question'
@@ -21,14 +21,14 @@ jobs:
days-before-stale: 21 days-before-stale: 21
days-before-close: -1 days-before-close: -1
remove-stale-when-updated: false remove-stale-when-updated: false
- uses: actions/stale@v1 - uses: actions/stale@v11
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} 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.' 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.'
stale-pr-message: 'There has been no activity on this Pull Request for an extended period of time. This Pull Request will be closed after further 14 days of inactivity.' stale-pr-message: 'There has been no activity on this Pull Request for an extended period of time. This Pull Request will be closed after further 14 days of inactivity.'
stale-issue-label: 'stale' stale-issue-label: 'stale'
stale-pr-label: 'stale' stale-pr-label: 'stale'
exempt-issue-label: 'leave open' exempt-issue-labels: 'leave open'
exempt-pr-label: 'leave open' exempt-pr-labels: 'leave open'
days-before-stale: 189 days-before-stale: 189
days-before-close: 14 days-before-close: 14
+12 -19
View File
@@ -8,41 +8,34 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout Instaloader repository - name: Checkout Instaloader repository
uses: actions/checkout@v2 uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v2
with: 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 architecture: x64
- name: Get the tagged version - name: Get the tagged version
id: get_version id: get_version
env: env:
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/} run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
shell: bash shell: bash
- name: Run custom python script for EXE creation - name: Run custom python script for EXE creation
env: env:
VERSION_TAG: v${{ steps.get_version.outputs.VERSION }} VERSION_TAG: v${{ steps.get_version.outputs.VERSION }}
run: python deploy/windows/create_exe.py run: python deploy/windows/create_exe.py
- name: Create draft release - name: Create draft release and upload EXE as release asset
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: create_release id: create_release
uses: actions/create-release@v1 uses: softprops/action-gh-release@v3
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: v${{ steps.get_version.outputs.VERSION }} tag_name: v${{ steps.get_version.outputs.VERSION }}
release_name: Version ${{ steps.get_version.outputs.VERSION }} name: Version ${{ steps.get_version.outputs.VERSION }}
draft: true draft: true
prerelease: contains(steps.get_version.outputs.VERSION, 'a') || contains(steps.get_version.outputs.VERSION, 'b') || contains(steps.get_version.outputs.VERSION, 'c') prerelease: contains(steps.get_version.outputs.VERSION, 'a') || contains(steps.get_version.outputs.VERSION, 'b') || contains(steps.get_version.outputs.VERSION, 'c')
- name: Upload EXE as release asset files: ./instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
asset_name: instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
asset_content_type: application/zip
+2 -1
View File
@@ -351,7 +351,7 @@ single-line-if-stmt=no
allow-any-import-level= allow-any-import-level=
# Allow explicit reexports by alias from a package __init__. # Allow explicit reexports by alias from a package __init__.
allow-reexport-from-package=no allow-reexport-from-package=yes
# Allow wildcard imports from modules that define __all__. # Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no allow-wildcard-with-all=no
@@ -433,6 +433,7 @@ disable=bad-inline-option,
too-many-instance-attributes, too-many-instance-attributes,
too-many-lines, too-many-lines,
too-many-locals, too-many-locals,
too-many-positional-arguments,
too-many-public-methods, too-many-public-methods,
too-many-statements, too-many-statements,
unnecessary-lambda-assignment, unnecessary-lambda-assignment,
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+1
View File
@@ -6,4 +6,5 @@ Instaloader is written by
- Alexander Graf (@aandergr) - Alexander Graf (@aandergr)
- André Koch-Kramer (@Thammus) - André Koch-Kramer (@Thammus)
- Lars Lindqvist (@e5150) - Lars Lindqvist (@e5150)
- Eduardo M Kalinowksi (@ekalin)
- ... and many more, see https://github.com/instaloader/instaloader/graphs/contributors - ... and many more, see https://github.com/instaloader/instaloader/graphs/contributors
Generated
+1193 -554
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -53,7 +53,7 @@
:: ::
instaloader [--comments] [--geotags] instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--igtv] [--stories] [--highlights] [--tagged] [--reels] [--igtv]
[--login YOUR-USERNAME] [--fast-update] [--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | :stories | :feed | :saved profile | "#hashtag" | :stories | :feed | :saved
@@ -124,12 +124,30 @@ Supporters
.. current-sponsors-start .. current-sponsors-start
| Instaloader is proudly sponsored by | Instaloader is proudly sponsored by
| `@rocketapi-io <https://github.com/rocketapi-io>`__
| `@socialmethod <https://github.com/socialmethod>`__ .. 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 See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
how you can sponsor the development of Instaloader! 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 .. current-sponsors-end
It is a pleasure for us to share our Instaloader to the world, and we are proud 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/ url = https://instaloader.github.io/
arch = any arch = any
license = MIT license = MIT
makedepends = python-build
makedepends = python-installer
makedepends = python-setuptools makedepends = python-setuptools
depends = python>=3.8 makedepends = python-wheel
depends = python-requests>=2.4 depends = python
depends = python-requests
optdepends = python-browser-cookie3: Import session cookies from browser
options = !emptydirs options = !emptydirs
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}} source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
sha512sums = {{hash}} sha512sums = {{hash}}
+15 -5
View File
@@ -8,20 +8,30 @@ arch=('any')
url="https://instaloader.github.io/" url="https://instaloader.github.io/"
license=('MIT') license=('MIT')
groups=() groups=()
depends=('python>=3.8' 'python-requests>=2.4') depends=(
makedepends=('python-setuptools') python
python-requests
)
makedepends=(
python-build
python-installer
python-setuptools
python-wheel
)
optdepends=(
'python-browser-cookie3: Import session cookies from browser'
)
options=('!emptydirs') options=('!emptydirs')
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver) source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
sha512sums=('{{hash}}') sha512sums=('{{hash}}')
build() { build() {
cd "$srcdir/$pkgname-$pkgver" cd "$srcdir/$pkgname-$pkgver"
python setup.py clean --all python -m build --wheel --no-isolation
python setup.py build
} }
package() { package() {
cd "$srcdir/$pkgname-$pkgver" cd "$srcdir/$pkgname-$pkgver"
install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE
python setup.py install --root "$pkgdir/" --skip-build python -m installer --destdir="$pkgdir" dist/*.whl
} }
+1 -1
View File
@@ -44,7 +44,7 @@ with open('__main__.py', 'w+') as f:
f.writelines(lines) f.writelines(lines)
# install dependencies and invoke PyInstaller # install dependencies and invoke PyInstaller
commands = ["pip install pipenv==2023.10.3", commands = ["pip install pipenv==2026.8.0",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev", 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"]
+1
View File
@@ -0,0 +1 @@
../.sponsor-logos
+4 -3
View File
@@ -7,9 +7,10 @@ https://instaloader.github.io/
## Build ## 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 pipenv sync --dev
make html make -C docs html
``` ```
+50 -12
View File
@@ -75,7 +75,7 @@ What to Download
Instaloader supports the following targets: Instaloader supports the following targets:
- ``profile`` - ``profile``
Public profile, or private profile with :option:`--login`. Public profile, or private profile with :ref:`login<login>`.
If an already-downloaded profile has been renamed, Instaloader automatically If an already-downloaded profile has been renamed, Instaloader automatically
finds it by its unique ID and renames the folder accordingly. finds it by its unique ID and renames the folder accordingly.
@@ -92,37 +92,41 @@ Instaloader supports the following targets:
- :option:`--tagged` - :option:`--tagged`
to **download posts where the user is tagged**, and to **download posts where the user is tagged**, and
- :option:`--reels`
to **download Reels videos**.
- :option:`--igtv` - :option:`--igtv`
to **download IGTV videos**. to **download IGTV videos**.
- ``"#hashtag"`` - ``"#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`` - ``%location id``
Posts tagged with a given location; the location ID is the numerical ID Posts tagged with a given location; the location ID is the numerical ID
Instagram labels a location with (e.g. Instagram labels a location with (e.g.
\https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/). \https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/).
Requires :option:`--login`. Requires :ref:`login<login>`.
.. versionadded:: 4.2 .. versionadded:: 4.2
- ``:stories`` - ``:stories``
The currently-visible **stories** of your followees (requires The currently-visible **stories** of your followees (requires
:option:`--login`). :ref:`login<login>`).
- ``:feed`` - ``:feed``
Your **feed** (requires :option:`--login`). Your **feed** (requires :ref:`login<login>`).
- ``:saved`` - ``:saved``
Posts which are marked as **saved** (requires :option:`--login`). Posts which are marked as **saved** (requires :ref:`login<login>`).
- ``@profile`` - ``@profile``
All profiles that are followed by ``profile``, i.e. the *followees* of All profiles that are followed by ``profile``, i.e. the *followees* of
``profile`` (requires :option:`--login`). ``profile`` (requires :ref:`login<login>`).
- ``-post`` - ``-post``
Replace **post** with the post's shortcode to download single post. Must be preceded by ``--`` in Replace **post** with the post's shortcode to download single post. Must be preceded by ``--`` in
the argument list to not be mistaken as an option flag:: the argument list to not be mistaken as an option flag. For example, to download the post
https://www.instagram.com/p/**B_K4CykAOtf**, run the command::
instaloader -- -B_K4CykAOtf instaloader -- -B_K4CykAOtf
@@ -140,7 +144,7 @@ downloads the pictures and videos and their captions. You can specify
- :option:`--geotags` - :option:`--geotags`
**download geotags** of each post and save them as **download geotags** of each post and save them as
Google Maps link (requires :option:`--login`), Google Maps link (requires :ref:`login<login>`),
For a reference of all supported command line options, see For a reference of all supported command line options, see
:ref:`command-line-options`. :ref:`command-line-options`.
@@ -231,7 +235,7 @@ The filter string must be a
where the attributes from :class:`Post` or where the attributes from :class:`Post` or
:class:`StoryItem` respectively are defined. :class:`StoryItem` respectively are defined.
Id est, the following attributes can be used with both The following attributes can be used with both
:option:`--post-filter` and :option:`--storyitem-filter`: :option:`--post-filter` and :option:`--storyitem-filter`:
- :attr:`~Post.owner_username` (str), :attr:`~Post.owner_id` (int) - :attr:`~Post.owner_username` (str), :attr:`~Post.owner_id` (int)
@@ -255,7 +259,7 @@ Id est, the following attributes can be used with both
As :option:`--post-filter`, the following attributes can be used additionally: As :option:`--post-filter`, the following attributes can be used additionally:
- :attr:`~Post.viewer_has_liked` (bool) - :attr:`~Post.viewer_has_liked` (bool)
Whether user (with :option:`--login`) has liked given post. To download the Whether user (with :ref:`login<login>`) has liked given post. To download the
pictures from your feed that you have liked:: pictures from your feed that you have liked::
instaloader --login=your_username --post-filter=viewer_has_liked :feed instaloader --login=your_username --post-filter=viewer_has_liked :feed
@@ -307,13 +311,47 @@ the post's caption::
instaloader --post-metadata-txt="{likes} likes." <target> instaloader --post-metadata-txt="{likes} likes." <target>
Note that with this feature, it is possible to easily and fastly extract Note that with this feature, it is possible to easily and quickly extract
additional metadata of already-downloaded posts, by reimporting their JSON additional metadata of already-downloaded posts, by reimporting their JSON
files. Say, you now also want to export the number of comments the Posts had files. Say, you now also want to export the number of comments the Posts had
when they were downloaded:: when they were downloaded::
instaloader --post-metadata-txt="{likes} likes, {comments} comments." <target>/*.json.xz instaloader --post-metadata-txt="{likes} likes, {comments} comments." <target>/*.json.xz
.. _exit_codes:
Exit codes
^^^^^^^^^^
Different exit codes are used to indicate different kinds of error:
0
No error, all downloads were successful.
1
A non-fatal error happened. One or more posts, or even one or more
profiles could not be downloaded, but execution was not stopped. The
errors are repeated at the end of the log for easy access.
2
Command-line error. An unrecognized option was passed, or an invalid
combination of options, for example. No interaction with Instagram
was made.
3
Login error. It was not possible to login. Downloads were not
attempted.
4
Fatal download error. Downloads were interrupted and no further
attempts were made. Happens when a response with one of the status
codes in the :option:`--abort-on` option were passed, or when
Instagram logs the user out during downloads.
5
Interrupted by the user. Happens when the user presses Control-C or
sends SIGINT to the process.
.. _instaloader-as-cronjob: .. _instaloader-as-cronjob:
Instaloader as Cronjob Instaloader as Cronjob
+27 -15
View File
@@ -26,7 +26,7 @@ Targets
^^^^^^^ ^^^^^^^
Specify a list of targets. For each of these, Instaloader creates a folder and Specify a list of targets. For each of these, Instaloader creates a folder and
stores all posts along with the pictures's captions there. stores all posts along with the pictures' captions there.
.. include:: basic-usage.rst .. include:: basic-usage.rst
:start-after: targets-start :start-after: targets-start
@@ -61,13 +61,13 @@ What to Download of each Post
Download geotags when available. Geotags are stored as a text file with Download geotags when available. Geotags are stored as a text file with
the location's name and a Google Maps link. This requires an additional the location's name and a Google Maps link. This requires an additional
request to the Instagram server for each picture. Requires :option:`--login`. request to the Instagram server for each picture. Requires :ref:`login<login>`.
.. option:: --comments, -C .. option:: --comments, -C
Download and update comments for each post. This requires an additional Download and update comments for each post. This requires an additional
request to the Instagram server for each post, which is why it is disabled by request to the Instagram server for each post, which is why it is disabled by
default. default. Requires :ref:`login<login>`.
.. option:: --no-captions .. option:: --no-captions
@@ -116,12 +116,12 @@ What to Download of each Profile
.. option:: --stories, -s .. option:: --stories, -s
Also download stories of each profile that is downloaded. Requires Also download stories of each profile that is downloaded. Requires
:option:`--login`. :ref:`login<login>`.
.. option:: --highlights .. option:: --highlights
Also download highlights of each profile that is downloaded. Requires Also download highlights of each profile that is downloaded. Requires
:option:`--login`. :ref:`login<login>`.
.. versionadded:: 4.1 .. versionadded:: 4.1
@@ -131,6 +131,12 @@ What to Download of each Profile
.. versionadded:: 4.1 .. versionadded:: 4.1
.. option:: --reels
Also download Reels videos.
.. versionadded:: 4.14
.. option:: --igtv .. option:: --igtv
Also download IGTV videos. Also download IGTV videos.
@@ -183,6 +189,7 @@ Which Posts to Download
Do not attempt to download more than COUNT posts. Applies to Do not attempt to download more than COUNT posts. Applies to
``#hashtag``, ``%location_id``, ``:feed``, and ``:saved``. ``#hashtag``, ``%location_id``, ``:feed``, and ``:saved``.
.. _login:
Login (Download Private Profiles) Login (Download Private Profiles)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -192,28 +199,33 @@ profiles. To login, pass the :option:`--login` option. Your session cookie (not
password!) will be saved to a local file to be reused next time you want password!) will be saved to a local file to be reused next time you want
Instaloader to login. Instaloader to login.
Instead of :option:`--login`, it is possible to use
:option:`--load-cookies` to import a session from a browser.
.. option:: --login YOUR-USERNAME, -l YOUR-USERNAME .. option:: --login YOUR-USERNAME, -l YOUR-USERNAME
Login name (profile name) for your Instagram account. Login name (profile name) for your Instagram account.
.. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME .. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME
Use Instagram cookie in your browser to login. Load Instagram session cookies from a browser instead of using Instaloader's own login mechanism.
This feature requires the browser_cookie3 library. This method requires the `browser_cookie3 <https://github.com/borisbabic/browser_cookie3>`__ library.
Compatible with :option:`--cookiefile` if you want to load cookies from browser profiles. By default, cookies are loaded from the browser's main profile, but you can specify a custom cookie file using :option:`--cookiefile`.
Incompatible with :option:`--login` due to potential username mismatch between user input and browser login.
Supported browsers: Chrome, Firefox, Edge, Opera, Safari, Brave.
After loading the cookies run the :option:`--login` option as it is required to download high quality media Since :option:`--load-cookies` and :option:`--login` are alternative ways to acquire a session, they cannot be used together when initially acquiring a session.
and to make full use of Instaloader's features.
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 .. versionadded:: 4.11
.. option:: --cookiefile COOKIE-FILE, -B COOKIE-FILE .. 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 .. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
@@ -300,7 +312,7 @@ How to Download
.. option:: --user-agent USER_AGENT .. option:: --user-agent USER_AGENT
User Agent to use for HTTP requests. Per default, Instaloader pretends being User Agent to use for HTTP requests. Per default, Instaloader pretends being
Chrome/120 on Linux. Chrome/142 on Linux.
.. option:: --max-connection-attempts N .. option:: --max-connection-attempts N
+1 -1
View File
@@ -2,7 +2,7 @@ import instaloader
L = instaloader.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() users = set()
@@ -14,8 +14,13 @@ def get_cookiefile():
default_cookiefile = { default_cookiefile = {
"Windows": "~/AppData/Roaming/Mozilla/Firefox/Profiles/*/cookies.sqlite", "Windows": "~/AppData/Roaming/Mozilla/Firefox/Profiles/*/cookies.sqlite",
"Darwin": "~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite", "Darwin": "~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite",
}.get(system(), "~/.mozilla/firefox/*/cookies.sqlite") }.get(system(), None)
cookiefiles = glob(expanduser(default_cookiefile)) 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: if not cookiefiles:
raise SystemExit("No Firefox cookies.sqlite file found. Use -c COOKIEFILE.") raise SystemExit("No Firefox cookies.sqlite file found. Use -c COOKIEFILE.")
return cookiefiles[0] return cookiefiles[0]
@@ -3,7 +3,7 @@ import instaloader
L = instaloader.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 SINCE = datetime(2020, 5, 10) # further from today, inclusive
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
+8
View File
@@ -74,6 +74,14 @@ Improvements of the Instaloader source or its documentation can be proposed as a
- a bug fix that does require thorough testing before being released to a - a bug fix that does require thorough testing before being released to a
final version. final version.
- Keep Pull Requests focused: address a single problem or feature in a
self-contained change. Avoid bundling multiple independent fixes, refactorings,
or architectural changes into one pull request, as this makes it hard to
review, validate, or selectively merge them.
- Be mindful of backward compatibility: changes should preserve existing intended
behavior whenever possible.
- All Pull Requests are analyzed by `Pylint <https://www.pylint.org/>`__ for - All Pull Requests are analyzed by `Pylint <https://www.pylint.org/>`__ for
error and syntax checking of the source and error and syntax checking of the source and
`Mypy <https://github.com/python/mypy>`__ for type checking. You can run them `Mypy <https://github.com/python/mypy>`__ for type checking. You can run them
+1 -1
View File
@@ -48,7 +48,7 @@ See :ref:`install` for more options on how to install Instaloader.
:: ::
instaloader [--comments] [--geotags] instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--igtv] [--stories] [--highlights] [--tagged] [--reels] [--igtv]
[--login YOUR-USERNAME] [--fast-update] [--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | %location_id | profile | "#hashtag" | %location_id |
:stories | :feed | :saved :stories | :feed | :saved
+11 -1
View File
@@ -2,7 +2,7 @@ Exceptions
^^^^^^^^^^ ^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
@@ -27,16 +27,26 @@ Exceptions
.. autoexception:: LoginRequiredException .. autoexception:: LoginRequiredException
.. autoexception:: LoginException
.. versionadded:: 4.12
.. autoexception:: TwoFactorAuthRequiredException .. autoexception:: TwoFactorAuthRequiredException
.. versionadded:: 4.2 .. versionadded:: 4.2
.. versionchanged:: 4.12
Inherits LoginException
.. autoexception:: InvalidArgumentException .. autoexception:: InvalidArgumentException
.. autoexception:: BadResponseException .. autoexception:: BadResponseException
.. autoexception:: BadCredentialsException .. autoexception:: BadCredentialsException
.. versionchanged:: 4.12
Inherits LoginException
.. autoexception:: PostChangedException .. autoexception:: PostChangedException
.. autoexception:: QueryReturnedNotFoundException .. autoexception:: QueryReturnedNotFoundException
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@ Resumable Iterations
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@ Instagram Structures
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
-2
View File
@@ -1,2 +0,0 @@
requests
sphinx~=2.3
+1 -1
View File
@@ -33,7 +33,7 @@ happen under normal conditions, consider adjusting the
There have been observations that services, that in their nature offer There have been observations that services, that in their nature offer
promiscuous IP addresses, such as cloud, VPN and public proxy services, might be promiscuous IP addresses, such as cloud, VPN and public proxy services, might be
subject to significantly stricter limits for anonymous access. However, subject to significantly stricter limits for anonymous access. However,
logged-in accesses (see :option:`--login`) do not seem to be affected. :ref:`logged-in accesses<login>` do not seem to be affected.
Instaloader allows to adjust the rate controlling behavior by overriding Instaloader allows to adjust the rate controlling behavior by overriding
:class:`instaloader.RateController`. :class:`instaloader.RateController`.
+24 -8
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram.""" """Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.11rc2' __version__ = '4.15.4'
try: try:
@@ -13,10 +13,26 @@ else:
win_unicode_console.enable() win_unicode_console.enable()
from .exceptions import * from .exceptions import *
from .instaloader import Instaloader from .instaloader import Instaloader as Instaloader
from .instaloadercontext import InstaloaderContext, RateController from .instaloadercontext import (InstaloaderContext as InstaloaderContext,
from .lateststamps import LatestStamps RateController as RateController)
from .nodeiterator import NodeIterator, FrozenNodeIterator, resumable_iteration from .lateststamps import LatestStamps as LatestStamps
from .structures import (Hashtag, Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation, from .nodeiterator import (NodeIterator as NodeIterator,
Profile, Story, StoryItem, TopSearchResults, TitlePic, FrozenNodeIterator as FrozenNodeIterator,
load_structure_from_file, save_structure_to_file, load_structure, get_json_structure) resumable_iteration as resumable_iteration)
from .structures import (Hashtag as Hashtag,
Highlight as Highlight,
Post as Post,
PostSidecarNode as PostSidecarNode,
PostComment as PostComment,
PostCommentAnswer as PostCommentAnswer,
PostLocation as PostLocation,
Profile as Profile,
Story as Story,
StoryItem as StoryItem,
TopSearchResults as TopSearchResults,
TitlePic as TitlePic,
load_structure_from_file as load_structure_from_file,
save_structure_to_file as save_structure_to_file,
load_structure as load_structure,
get_json_structure as get_json_structure)
+128 -77
View File
@@ -5,12 +5,12 @@ import datetime
import os import os
import re import re
import sys import sys
import textwrap
from argparse import ArgumentParser, ArgumentTypeError, SUPPRESS from argparse import ArgumentParser, ArgumentTypeError, SUPPRESS
from enum import IntEnum
from typing import List, Optional from typing import List, Optional
from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException, from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException,
InvalidArgumentException, Post, Profile, ProfileNotExistsException, StoryItem, InvalidArgumentException, LoginException, Post, Profile, ProfileNotExistsException, StoryItem,
TwoFactorAuthRequiredException, __version__, load_structure_from_file) TwoFactorAuthRequiredException, __version__, load_structure_from_file)
from .instaloader import (get_default_session_filename, get_default_stamps_filename) from .instaloader import (get_default_session_filename, get_default_stamps_filename)
from .instaloadercontext import default_user_agent from .instaloadercontext import default_user_agent
@@ -22,13 +22,22 @@ except ImportError:
bc3_library = False bc3_library = False
class ExitCode(IntEnum):
SUCCESS = 0
NON_FATAL_ERROR = 1
INIT_FAILURE = 2
LOGIN_FAILURE = 3
DOWNLOAD_ABORTED = 4
USER_ABORTED = 5
UNEXPECTED_ERROR = 99
def usage_string(): def usage_string():
# NOTE: duplicated in README.rst and docs/index.rst # NOTE: duplicated in README.rst and docs/index.rst
argv0 = os.path.basename(sys.argv[0]) argv0 = os.path.basename(sys.argv[0])
argv0 = "instaloader" if argv0 == "__main__.py" else argv0 argv0 = "instaloader" if argv0 == "__main__.py" else argv0
return """ return """
{0} [--comments] [--geotags] {0} [--comments] [--geotags]
{2:{1}} [--stories] [--highlights] [--tagged] [--igtv] {2:{1}} [--stories] [--highlights] [--tagged] [--reels] [--igtv]
{2:{1}} [--login YOUR-USERNAME] [--fast-update] {2:{1}} [--login YOUR-USERNAME] [--fast-update]
{2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved {2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved
{0} --help""".format(argv0, len(argv0), '') {0} --help""".format(argv0, len(argv0), '')
@@ -73,20 +82,24 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
return filterfunc 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 = { supported_browsers = {
"chrome": browser_cookie3.chrome,
"firefox": browser_cookie3.firefox,
"edge": browser_cookie3.edge,
"brave": browser_cookie3.brave, "brave": browser_cookie3.brave,
"chrome": browser_cookie3.chrome,
"chromium": browser_cookie3.chromium,
"edge": browser_cookie3.edge,
"firefox": browser_cookie3.firefox,
"librewolf": browser_cookie3.librewolf,
"opera": browser_cookie3.opera, "opera": browser_cookie3.opera,
"safari": browser_cookie3.safari "opera_gx": browser_cookie3.opera_gx,
"safari": browser_cookie3.safari,
"vivaldi": browser_cookie3.vivaldi,
} }
if browser not in supported_browsers: if browser not in supported_browsers:
print("Loading cookies from the specified browser failed") raise InvalidArgumentException("Loading cookies from the specified browser failed\n"
print("Supported browsers are Chrome, Firefox, Edge, Brave, Opera and Safari") "Supported browsers are Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, "
return {} "Opera, Opera_GX, Safari and Vivaldi")
cookies = {} cookies = {}
browser_cookies = list(supported_browsers[browser](cookie_file=cookie_file)) browser_cookies = list(supported_browsers[browser](cookie_file=cookie_file))
@@ -98,26 +111,22 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''):
if cookies: if cookies:
print(f"Cookies loaded successfully from {browser}") print(f"Cookies loaded successfully from {browser}")
else: else:
print(f"No cookies found for Instagram in {browser}, Are you logged in succesfully in {browser}?") raise LoginException(f"No cookies found for Instagram in {browser}, "
f"Are you logged in successfully in {browser}?")
if cookie_name: return cookies
return cookies.get(cookie_name, {})
else:
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) cookie = get_cookies_from_instagram('instagram', browser, cookiefile)
if cookie is not None: if cookie is not None:
instaloader.context.update_cookies(cookie) instaloader.context.update_cookies(cookie)
username = instaloader.test_login() username = instaloader.test_login()
if not username: if not username:
raise SystemExit(f"Not logged in. Are you logged in successfully in {browser}?") raise LoginException(f"Not logged in. Are you logged in successfully in {browser}?")
instaloader.context.username = username instaloader.context.username = username
print(f"{username} has been successfully logged in.") print(f"{username} has been successfully logged in.")
next_step_text = (f"Next: Run instaloader --login={username} as it is required to download high quality media " print(f"Next time use --login={username} to reuse the same session.")
"and to make full use of instaloader's features.")
print(textwrap.fill(next_step_text))
def _main(instaloader: Instaloader, targetlist: List[str], def _main(instaloader: Instaloader, targetlist: List[str],
@@ -127,13 +136,14 @@ def _main(instaloader: Instaloader, targetlist: List[str],
download_stories: bool = False, download_stories: bool = False,
download_highlights: bool = False, download_highlights: bool = False,
download_tagged: bool = False, download_tagged: bool = False,
download_reels: bool = False,
download_igtv: bool = False, download_igtv: bool = False,
fast_update: bool = False, fast_update: bool = False,
latest_stamps_file: Optional[str] = None, latest_stamps_file: Optional[str] = None,
max_count: Optional[int] = None, post_filter_str: Optional[str] = None, max_count: Optional[int] = None, post_filter_str: Optional[str] = None,
storyitem_filter_str: Optional[str] = None, storyitem_filter_str: Optional[str] = None,
browser: Optional[str] = None, browser: Optional[str] = None,
cookiefile: Optional[str] = None) -> None: cookiefile: Optional[str] = None) -> ExitCode:
"""Download set of profiles, hashtags etc. and handle logging in and session files if desired.""" """Download set of profiles, hashtags etc. and handle logging in and session files if desired."""
# Parse and generate filter function # Parse and generate filter function
post_filter = None post_filter = None
@@ -152,7 +162,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
if browser and bc3_library: if browser and bc3_library:
import_session(browser.lower(), instaloader, cookiefile) import_session(browser.lower(), instaloader, cookiefile)
elif browser and not bc3_library: elif browser and not bc3_library:
raise SystemExit("browser_cookie3 library is needed to load cookies from browsers") raise InvalidArgumentException("browser_cookie3 library is needed to load cookies from browsers")
# Login, if desired # Login, if desired
if username is not None: if username is not None:
if not re.match(r"^[A-Za-z0-9._]+$", username): if not re.match(r"^[A-Za-z0-9._]+$", username):
@@ -181,14 +191,19 @@ def _main(instaloader: Instaloader, targetlist: List[str],
print(err, file=sys.stderr) print(err, file=sys.stderr)
pass pass
else: else:
instaloader.interactive_login(username) try:
instaloader.interactive_login(username)
except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr)
return ExitCode.USER_ABORTED
instaloader.context.log("Logged in as %s." % username) instaloader.context.log("Logged in as %s." % username)
# since 4.2.9 login is required for geotags # since 4.2.9 login is required for geotags
if instaloader.download_geotags and not instaloader.context.is_logged_in: if instaloader.download_geotags and not instaloader.context.is_logged_in:
instaloader.context.error("Warning: Use --login to download geotags of posts.") instaloader.context.error("Warning: Login is required to download geotags of posts.")
# Try block for KeyboardInterrupt (save session on ^C) # Try block for KeyboardInterrupt (save session on ^C)
profiles = set() profiles = set()
anonymous_retry_profiles = set() anonymous_retry_profiles = set()
exit_code = ExitCode.SUCCESS
try: try:
# Generate set of profiles, already downloading non-profile targets # Generate set of profiles, already downloading non-profile targets
for target in targetlist: for target in targetlist:
@@ -241,11 +256,13 @@ def _main(instaloader: Instaloader, targetlist: List[str],
instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count, instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count,
post_filter=post_filter) post_filter=post_filter)
elif re.match(r"^[A-Za-z0-9._]+$", target): elif re.match(r"^[A-Za-z0-9._]+$", target):
download_profile_content = download_posts or download_tagged or download_reels or download_igtv
try: try:
profile = instaloader.check_profile_id(target, latest_stamps) profile = instaloader.check_profile_id(target, latest_stamps)
if instaloader.context.is_logged_in and profile.has_blocked_viewer: if instaloader.context.is_logged_in and profile.has_blocked_viewer:
if download_profile_pic or ((download_posts or download_tagged or download_igtv) if download_profile_pic or (
and not profile.is_private): download_profile_content and not profile.is_private
):
raise ProfileNotExistsException("{} blocked you; But we download her anonymously." raise ProfileNotExistsException("{} blocked you; But we download her anonymously."
.format(target)) .format(target))
else: else:
@@ -255,8 +272,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
except ProfileNotExistsException as err: except ProfileNotExistsException as err:
# Not only our profile.has_blocked_viewer condition raises ProfileNotExistsException, # Not only our profile.has_blocked_viewer condition raises ProfileNotExistsException,
# check_profile_id() also does, since access to blocked profile may be responded with 404. # check_profile_id() also does, since access to blocked profile may be responded with 404.
if instaloader.context.is_logged_in and (download_profile_pic or download_posts or if instaloader.context.is_logged_in and (download_profile_pic or download_profile_content):
download_tagged or download_igtv):
instaloader.context.log(err) instaloader.context.log(err)
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.") instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
with instaloader.anonymous_copy() as anonymous_loader: with instaloader.anonymous_copy() as anonymous_loader:
@@ -279,23 +295,42 @@ def _main(instaloader: Instaloader, targetlist: List[str],
' '.join([p.username for p in profiles]))) ' '.join([p.username for p in profiles])))
if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \ if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \
not instaloader.context.is_logged_in: not instaloader.context.is_logged_in:
instaloader.context.log("Hint: Use --login to download higher-quality versions of pictures.") instaloader.context.log("Hint: Login to download higher-quality versions of pictures.")
instaloader.download_profiles(profiles, instaloader.download_profiles(
download_profile_pic, download_posts, download_tagged, download_igtv, profiles,
download_highlights, download_stories, download_profile_pic,
fast_update, post_filter, storyitem_filter, latest_stamps=latest_stamps) download_posts,
download_tagged,
download_igtv,
download_highlights,
download_stories,
fast_update,
post_filter,
storyitem_filter,
latest_stamps=latest_stamps,
reels=download_reels,
)
if anonymous_retry_profiles: if anonymous_retry_profiles:
instaloader.context.log("Downloading anonymously: {}" instaloader.context.log("Downloading anonymously: {}"
.format(' '.join([p.username for p in anonymous_retry_profiles]))) .format(' '.join([p.username for p in anonymous_retry_profiles])))
with instaloader.anonymous_copy() as anonymous_loader: with instaloader.anonymous_copy() as anonymous_loader:
anonymous_loader.download_profiles(anonymous_retry_profiles, anonymous_loader.download_profiles(
download_profile_pic, download_posts, download_tagged, download_igtv, anonymous_retry_profiles,
fast_update=fast_update, post_filter=post_filter, download_profile_pic,
latest_stamps=latest_stamps) download_posts,
download_tagged,
download_igtv,
fast_update=fast_update,
post_filter=post_filter,
latest_stamps=latest_stamps,
reels=download_reels
)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr) print("\nInterrupted by user.", file=sys.stderr)
exit_code = ExitCode.USER_ABORTED
except AbortDownloadException as exc: except AbortDownloadException as exc:
print("\nDownload aborted: {}.".format(exc), file=sys.stderr) print("\nDownload aborted: {}.".format(exc), file=sys.stderr)
exit_code = ExitCode.DOWNLOAD_ABORTED
# Save session if it is useful # Save session if it is useful
if instaloader.context.is_logged_in: if instaloader.context.is_logged_in:
instaloader.save_session_to_file(sessionfile) instaloader.save_session_to_file(sessionfile)
@@ -307,6 +342,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
else: else:
# Instaloader did not do anything # Instaloader did not do anything
instaloader.context.log("usage:" + usage_string()) instaloader.context.log("usage:" + usage_string())
exit_code = ExitCode.INIT_FAILURE
return exit_code
def main(): def main():
@@ -322,17 +359,17 @@ def main():
help="Download profile. If an already-downloaded profile has been renamed, Instaloader " help="Download profile. If an already-downloaded profile has been renamed, Instaloader "
"automatically finds it by its unique ID and renames the folder likewise.") "automatically finds it by its unique ID and renames the folder likewise.")
g_targets.add_argument('_at_profile', nargs='*', metavar="@profile", g_targets.add_argument('_at_profile', nargs='*', metavar="@profile",
help="Download all followees of profile. Requires --login. " help="Download all followees of profile. Requires login. "
"Consider using :feed rather than @yourself.") "Consider using :feed rather than @yourself.")
g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.") g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.")
g_targets.add_argument('_location', nargs='*', metavar='%location_id', g_targets.add_argument('_location', nargs='*', metavar='%location_id',
help="Download %%location_id. Requires --login.") help="Download %%location_id. Requires login.")
g_targets.add_argument('_feed', nargs='*', metavar=":feed", g_targets.add_argument('_feed', nargs='*', metavar=":feed",
help="Download pictures from your feed. Requires --login.") help="Download pictures from your feed. Requires login.")
g_targets.add_argument('_stories', nargs='*', metavar=":stories", g_targets.add_argument('_stories', nargs='*', metavar=":stories",
help="Download the stories of your followees. Requires --login.") help="Download the stories of your followees. Requires login.")
g_targets.add_argument('_saved', nargs='*', metavar=":saved", g_targets.add_argument('_saved', nargs='*', metavar=":saved",
help="Download the posts that you marked as saved. Requires --login.") help="Download the posts that you marked as saved. Requires login.")
g_targets.add_argument('_singlepost', nargs='*', metavar="-- -shortcode", g_targets.add_argument('_singlepost', nargs='*', metavar="-- -shortcode",
help="Download the post with the given shortcode") help="Download the post with the given shortcode")
g_targets.add_argument('_json', nargs='*', metavar="filename.json[.xz]", g_targets.add_argument('_json', nargs='*', metavar="filename.json[.xz]",
@@ -363,11 +400,11 @@ def main():
help='Download geotags when available. Geotags are stored as a ' help='Download geotags when available. Geotags are stored as a '
'text file with the location\'s name and a Google Maps link. ' 'text file with the location\'s name and a Google Maps link. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
'server for each picture. Requires --login.') 'server for each picture. Requires login.')
g_post.add_argument('-C', '--comments', action='store_true', g_post.add_argument('-C', '--comments', action='store_true',
help='Download and update comments for each post. ' help='Download and update comments for each post. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
'server for each post, which is why it is disabled by default.') 'server for each post, which is why it is disabled by default. Requires login.')
g_post.add_argument('--no-captions', action='store_true', g_post.add_argument('--no-captions', action='store_true',
help='Do not create txt files.') help='Do not create txt files.')
g_post.add_argument('--post-metadata-txt', action='append', g_post.add_argument('--post-metadata-txt', action='append',
@@ -381,13 +418,15 @@ def main():
g_post.add_argument('--no-compress-json', action='store_true', g_post.add_argument('--no-compress-json', action='store_true',
help='Do not xz compress JSON files, rather create pretty formatted JSONs.') help='Do not xz compress JSON files, rather create pretty formatted JSONs.')
g_prof.add_argument('-s', '--stories', action='store_true', g_prof.add_argument('-s', '--stories', action='store_true',
help='Also download stories of each profile that is downloaded. Requires --login.') help='Also download stories of each profile that is downloaded. Requires login.')
g_prof.add_argument('--stories-only', action='store_true', g_prof.add_argument('--stories-only', action='store_true',
help=SUPPRESS) help=SUPPRESS)
g_prof.add_argument('--highlights', action='store_true', g_prof.add_argument('--highlights', action='store_true',
help='Also download highlights of each profile that is downloaded. Requires --login.') help='Also download highlights of each profile that is downloaded. Requires login.')
g_prof.add_argument('--tagged', action='store_true', g_prof.add_argument('--tagged', action='store_true',
help='Also download posts where each profile is tagged.') help='Also download posts where each profile is tagged.')
g_prof.add_argument('--reels', action='store_true',
help='Also download Reels videos.')
g_prof.add_argument('--igtv', action='store_true', g_prof.add_argument('--igtv', action='store_true',
help='Also download IGTV videos.') help='Also download IGTV videos.')
@@ -417,7 +456,8 @@ def main():
'Instaloader can login to Instagram. This allows downloading private profiles. ' 'Instaloader can login to Instagram. This allows downloading private profiles. '
'To login, pass the --login option. Your session cookie (not your password!) ' 'To login, pass the --login option. Your session cookie (not your password!) '
'will be saved to a local file to be reused next time you want Instaloader ' 'will be saved to a local file to be reused next time you want Instaloader '
'to login.') 'to login. Instead of --login, the --load-cookies option can be used to '
'import a session from a browser.')
g_login.add_argument('-l', '--login', metavar='YOUR-USERNAME', g_login.add_argument('-l', '--login', metavar='YOUR-USERNAME',
help='Login name (profile name) for your Instagram account.') help='Login name (profile name) for your Instagram account.')
g_login.add_argument('-b', '--load-cookies', metavar='BROWSER-NAME', g_login.add_argument('-b', '--load-cookies', metavar='BROWSER-NAME',
@@ -484,15 +524,15 @@ def main():
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.login is None and (args.stories or args.stories_only): if (args.login is None and args.load_cookies is None) and (args.stories or args.stories_only):
print("--login=USERNAME required to download stories.", file=sys.stderr) print("Login is required to download stories.", file=sys.stderr)
args.stories = False args.stories = False
if args.stories_only: if args.stories_only:
raise SystemExit(1) raise InvalidArgumentException()
if ':feed-all' in args.profile or ':feed-liked' in args.profile: if ':feed-all' in args.profile or ':feed-liked' in args.profile:
raise SystemExit(":feed-all and :feed-liked were removed. Use :feed as target and " raise InvalidArgumentException(":feed-all and :feed-liked were removed. Use :feed as target and "
"eventually --post-filter=viewer_has_liked.") "eventually --post-filter=viewer_has_liked.")
post_metadata_txt_pattern = '\n'.join(args.post_metadata_txt) if args.post_metadata_txt else None post_metadata_txt_pattern = '\n'.join(args.post_metadata_txt) if args.post_metadata_txt else None
storyitem_metadata_txt_pattern = '\n'.join(args.storyitem_metadata_txt) if args.storyitem_metadata_txt else None storyitem_metadata_txt_pattern = '\n'.join(args.storyitem_metadata_txt) if args.storyitem_metadata_txt else None
@@ -502,18 +542,18 @@ def main():
post_metadata_txt_pattern = '' post_metadata_txt_pattern = ''
storyitem_metadata_txt_pattern = '' storyitem_metadata_txt_pattern = ''
else: else:
raise SystemExit("--no-captions and --post-metadata-txt or --storyitem-metadata-txt given; " raise InvalidArgumentException("--no-captions and --post-metadata-txt or --storyitem-metadata-txt "
"That contradicts.") "given; That contradicts.")
if args.no_resume and args.resume_prefix: if args.no_resume and args.resume_prefix:
raise SystemExit("--no-resume and --resume-prefix given; That contradicts.") raise InvalidArgumentException("--no-resume and --resume-prefix given; That contradicts.")
resume_prefix = (args.resume_prefix if args.resume_prefix else 'iterator') if not args.no_resume else None resume_prefix = (args.resume_prefix if args.resume_prefix else 'iterator') if not args.no_resume else None
if args.no_pictures and args.fast_update: if args.no_pictures and args.fast_update:
raise SystemExit('--no-pictures and --fast-update cannot be used together.') raise InvalidArgumentException('--no-pictures and --fast-update cannot be used together.')
if args.login and args.load_cookies: if args.login and args.load_cookies:
raise SystemExit('--load-cookies and --login cannot be used together.') raise InvalidArgumentException('--load-cookies and --login cannot be used together.')
# Determine what to download # Determine what to download
download_profile_pic = not args.no_profile_pic or args.profile_pic_only download_profile_pic = not args.no_profile_pic or args.profile_pic_only
@@ -538,27 +578,38 @@ def main():
iphone_support=not args.no_iphone, iphone_support=not args.no_iphone,
title_pattern=args.title_pattern, title_pattern=args.title_pattern,
sanitize_paths=args.sanitize_paths) sanitize_paths=args.sanitize_paths)
_main(loader, exit_code = _main(loader,
args.profile, args.profile,
username=args.login.lower() if args.login is not None else None, username=args.login.lower() if args.login is not None else None,
password=args.password, password=args.password,
sessionfile=args.sessionfile, sessionfile=args.sessionfile,
download_profile_pic=download_profile_pic, download_profile_pic=download_profile_pic,
download_posts=download_posts, download_posts=download_posts,
download_stories=download_stories, download_stories=download_stories,
download_highlights=args.highlights, download_highlights=args.highlights,
download_tagged=args.tagged, download_tagged=args.tagged,
download_igtv=args.igtv, download_reels=args.reels,
fast_update=args.fast_update, download_igtv=args.igtv,
latest_stamps_file=args.latest_stamps, fast_update=args.fast_update,
max_count=int(args.count) if args.count is not None else None, latest_stamps_file=args.latest_stamps,
post_filter_str=args.post_filter, max_count=int(args.count) if args.count is not None else None,
storyitem_filter_str=args.storyitem_filter, post_filter_str=args.post_filter,
browser=args.load_cookies, storyitem_filter_str=args.storyitem_filter,
cookiefile=args.cookiefile) browser=args.load_cookies,
cookiefile=args.cookiefile)
loader.close() loader.close()
if loader.has_stored_errors:
exit_code = ExitCode.NON_FATAL_ERROR
except InvalidArgumentException as err:
print(err, file=sys.stderr)
exit_code = ExitCode.INIT_FAILURE
except LoginException as err:
print(err, file=sys.stderr)
exit_code = ExitCode.LOGIN_FAILURE
except InstaloaderException as err: except InstaloaderException as err:
raise SystemExit("Fatal error: %s" % err) from err print("Fatal error: %s" % err)
exit_code = ExitCode.UNEXPECTED_ERROR
sys.exit(exit_code)
if __name__ == "__main__": if __name__ == "__main__":
+6 -2
View File
@@ -33,7 +33,11 @@ class LoginRequiredException(InstaloaderException):
pass pass
class TwoFactorAuthRequiredException(InstaloaderException): class LoginException(InstaloaderException):
pass
class TwoFactorAuthRequiredException(LoginException):
pass pass
@@ -45,7 +49,7 @@ class BadResponseException(InstaloaderException):
pass pass
class BadCredentialsException(InstaloaderException): class BadCredentialsException(LoginException):
pass pass
+93 -22
View File
@@ -77,13 +77,13 @@ def _requires_login(func: Callable) -> Callable:
@wraps(func) @wraps(func)
def call(instaloader, *args, **kwargs): def call(instaloader, *args, **kwargs):
if not instaloader.context.is_logged_in: if not instaloader.context.is_logged_in:
raise LoginRequiredException("--login=USERNAME required.") raise LoginRequiredException("Login required.")
return func(instaloader, *args, **kwargs) return func(instaloader, *args, **kwargs)
return call return call
def _retry_on_connection_error(func: Callable) -> Callable: def _retry_on_connection_error(func: Callable) -> Callable:
"""Decorator to retry the function max_connection_attemps number of times. """Decorator to retry the function max_connection_attempts number of times.
Herewith-decorated functions need an ``_attempt`` keyword argument. Herewith-decorated functions need an ``_attempt`` keyword argument.
@@ -330,6 +330,9 @@ class Instaloader:
def __exit__(self, *args): def __exit__(self, *args):
self.close() self.close()
def __repr__(self):
return f"<Instaloader context={self.context}>"
@_retry_on_connection_error @_retry_on_connection_error
def download_pic(self, filename: str, url: str, mtime: datetime, def download_pic(self, filename: str, url: str, mtime: datetime,
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool: filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool:
@@ -643,11 +646,16 @@ class Instaloader:
def login(self, user: str, passwd: str) -> None: def login(self, user: str, passwd: str) -> None:
"""Log in to instagram with given username and password and internally store session object. """Log in to instagram with given username and password and internally store session object.
:raises InvalidArgumentException: If the provided username does not exist.
:raises BadCredentialsException: If the provided password is wrong. :raises BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed.
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call
:meth:`Instaloader.two_factor_login`.""" :meth:`Instaloader.two_factor_login`.
:raises LoginException: An error happened during login (for example, an invalid response was received).
Or if the provided username does not exist.
.. versionchanged:: 4.12
Raises LoginException instead of ConnectionException when an error happens.
Raises LoginException instead of InvalidArgumentException when the username does not exist.
"""
self.context.login(user, passwd) self.context.login(user, passwd)
def two_factor_login(self, two_factor_code) -> None: def two_factor_login(self, two_factor_code) -> None:
@@ -717,7 +725,7 @@ class Instaloader:
# Download the image(s) / video thumbnail and videos within sidecars if desired # Download the image(s) / video thumbnail and videos within sidecars if desired
downloaded = True downloaded = True
if post.typename == 'GraphSidecar': if post.typename == 'GraphSidecar':
if self.download_pictures or self.download_videos: if (self.download_pictures or self.download_videos) and post.mediacount > 0:
if not _all_already_downloaded( if not _all_already_downloaded(
filename_template, enumerate( filename_template, enumerate(
(post.get_is_videos()[i] (post.get_is_videos()[i]
@@ -732,19 +740,20 @@ class Instaloader:
suffix: Optional[str] = str(edge_number) suffix: Optional[str] = str(edge_number)
if '{filename}' in self.filename_pattern: if '{filename}' in self.filename_pattern:
suffix = None 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 # pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template, sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.display_url) lambda: sidecar_node.display_url)
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails) # Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url, downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url,
mtime=post.date_local, filename_suffix=suffix) 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 # pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template, sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.video_url) lambda: video_url)
# Download sidecar video if desired # 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) mtime=post.date_local, filename_suffix=suffix)
else: else:
downloaded = False downloaded = False
@@ -861,8 +870,9 @@ class Instaloader:
last_scraped = latest_stamps.get_last_story_timestamp(name) last_scraped = latest_stamps.get_last_story_timestamp(name)
scraped_timestamp = datetime.now().astimezone() scraped_timestamp = datetime.now().astimezone()
for item in user_story.get_items(): for item in user_story.get_items():
if latest_stamps is not None and item.date_local <= last_scraped: if latest_stamps is not None:
break if item.date_local <= last_scraped:
break
if storyitem_filter is not None and not storyitem_filter(item): if storyitem_filter is not None and not storyitem_filter(item):
self.context.log("<{} skipped>".format(item), flush=True) self.context.log("<{} skipped>".format(item), flush=True)
continue continue
@@ -991,7 +1001,8 @@ class Instaloader:
max_count: Optional[int] = None, max_count: Optional[int] = None,
total_count: Optional[int] = None, total_count: Optional[int] = None,
owner_profile: Optional[Profile] = None, owner_profile: Optional[Profile] = None,
takewhile: Optional[Callable[[Post], bool]] = None) -> None: takewhile: Optional[Callable[[Post], bool]] = None,
possibly_pinned: int = 0) -> None:
""" """
Download the Posts returned by given Post Iterator. Download the Posts returned by given Post Iterator.
@@ -1003,6 +1014,9 @@ class Instaloader:
.. versionchanged:: 4.8 .. versionchanged:: 4.8
Add `takewhile` parameter. Add `takewhile` parameter.
.. versionchanged:: 4.10.3
Add `possibly_pinned` parameter.
:param posts: Post Iterator to loop through. :param posts: Post Iterator to loop through.
:param target: Target name. :param target: Target name.
:param fast_update: :option:`--fast-update`. :param fast_update: :option:`--fast-update`.
@@ -1011,6 +1025,8 @@ class Instaloader:
:param total_count: Total number of posts returned by given iterator. :param total_count: Total number of posts returned by given iterator.
:param owner_profile: Associated profile, if any. :param owner_profile: Associated profile, if any.
:param takewhile: Expression evaluated for each post. Once it returns false, downloading stops. :param takewhile: Expression evaluated for each post. Once it returns false, downloading stops.
:param possibly_pinned: Number of posts that might be pinned. These posts do not cause download
to stop even if they've already been downloaded.
""" """
displayed_count = (max_count if total_count is None or max_count is not None and max_count < total_count displayed_count = (max_count if total_count is None or max_count is not None and max_count < total_count
else total_count) else total_count)
@@ -1032,7 +1048,7 @@ class Instaloader:
) as (is_resuming, start_index): ) as (is_resuming, start_index):
for number, post in enumerate(posts, start=start_index + 1): for number, post in enumerate(posts, start=start_index + 1):
should_stop = not takewhile(post) should_stop = not takewhile(post)
if should_stop and post.is_pinned: if should_stop and number <= possibly_pinned:
continue continue
if (max_count is not None and number > max_count) or should_stop: if (max_count is not None and number > max_count) or should_stop:
break break
@@ -1066,7 +1082,7 @@ class Instaloader:
except PostChangedException: except PostChangedException:
post_changed = True post_changed = True
continue continue
if fast_update and not downloaded and not post_changed and not post.is_pinned: if fast_update and not downloaded and not post_changed and number > possibly_pinned:
# disengage fast_update for first post when resuming # disengage fast_update for first post when resuming
if not is_resuming or number > 0: if not is_resuming or number > 0:
break break
@@ -1203,6 +1219,7 @@ class Instaloader:
Use :meth:`Hashtag.get_posts_resumable`.""" Use :meth:`Hashtag.get_posts_resumable`."""
return Hashtag.from_name(self.context, hashtag).get_posts_resumable() return Hashtag.from_name(self.context, hashtag).get_posts_resumable()
@_requires_login
def download_hashtag(self, hashtag: Union[Hashtag, str], def download_hashtag(self, hashtag: Union[Hashtag, str],
max_count: Optional[int] = None, max_count: Optional[int] = None,
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
@@ -1269,6 +1286,32 @@ class Instaloader:
if latest_stamps is not None and tagged_posts.first_item is not None: if latest_stamps is not None and tagged_posts.first_item is not None:
latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local) latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local)
def download_reels(self, profile: Profile, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None:
"""Download reels videos of a profile.
.. versionadded:: 4.14.0
"""
self.context.log("Retrieving reels videos for profile {}.".format(profile.username))
posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None:
last_scraped = latest_stamps.get_last_reels_timestamp(profile.username)
posts_takewhile = lambda p: p.date_local > last_scraped
reels = profile.get_reels()
self.posts_download_loop(
reels,
profile.username,
fast_update,
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)
def download_igtv(self, profile: Profile, fast_update: bool = False, def download_igtv(self, profile: Profile, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None: latest_stamps: Optional[LatestStamps] = None) -> None:
@@ -1398,7 +1441,9 @@ class Instaloader:
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
storyitem_filter: Optional[Callable[[Post], bool]] = None, storyitem_filter: Optional[Callable[[Post], bool]] = None,
raise_errors: bool = False, raise_errors: bool = False,
latest_stamps: Optional[LatestStamps] = None): latest_stamps: Optional[LatestStamps] = None,
max_count: Optional[int] = None,
reels: bool = False):
"""High-level method to download set of profiles. """High-level method to download set of profiles.
:param profiles: Set of profiles to download. :param profiles: Set of profiles to download.
@@ -1415,6 +1460,8 @@ class Instaloader:
Whether :exc:`LoginRequiredException` and :exc:`PrivateProfileNotFollowedException` should be raised or Whether :exc:`LoginRequiredException` and :exc:`PrivateProfileNotFollowedException` should be raised or
catched and printed with :meth:`InstaloaderContext.error_catcher`. catched and printed with :meth:`InstaloaderContext.error_catcher`.
:param latest_stamps: :option:`--latest-stamps`. :param latest_stamps: :option:`--latest-stamps`.
:param max_count: Maximum count of posts to download.
:param reels: :option:`--reels`.
.. versionadded:: 4.1 .. versionadded:: 4.1
@@ -1423,6 +1470,12 @@ class Instaloader:
.. versionchanged:: 4.8 .. versionchanged:: 4.8
Add `latest_stamps` parameter. Add `latest_stamps` parameter.
.. versionchanged:: 4.13
Add `max_count` parameter.
.. versionchanged:: 4.14
Add `reels` parameter.
""" """
@contextmanager @contextmanager
@@ -1454,7 +1507,7 @@ class Instaloader:
if tagged or igtv or highlights or posts: if tagged or igtv or highlights or posts:
if (not self.context.is_logged_in and if (not self.context.is_logged_in and
profile.is_private): profile.is_private):
raise LoginRequiredException("--login=USERNAME required.") raise LoginRequiredException("Login required.")
if (self.context.username != profile.username and if (self.context.username != profile.username and
profile.is_private and profile.is_private and
not profile.followed_by_viewer): not profile.followed_by_viewer):
@@ -1466,6 +1519,12 @@ class Instaloader:
self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter, self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter,
latest_stamps=latest_stamps) latest_stamps=latest_stamps)
# Download reels, if requested
if reels:
with self.context.error_catcher('Download reels of {}'.format(profile_name)):
self.download_reels(profile, fast_update=fast_update, post_filter=post_filter,
latest_stamps=latest_stamps)
# Download IGTV, if requested # Download IGTV, if requested
if igtv: if igtv:
with self.context.error_catcher('Download IGTV of {}'.format(profile_name)): with self.context.error_catcher('Download IGTV of {}'.format(profile_name)):
@@ -1488,7 +1547,7 @@ class Instaloader:
posts_to_download = profile.get_posts() posts_to_download = profile.get_posts()
self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter, self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter,
total_count=profile.mediacount, owner_profile=profile, total_count=profile.mediacount, owner_profile=profile,
takewhile=posts_takewhile) takewhile=posts_takewhile, possibly_pinned=3, max_count=max_count)
if latest_stamps is not None and posts_to_download.first_item is not None: if latest_stamps is not None and posts_to_download.first_item is not None:
latest_stamps.set_last_post_timestamp(profile_name, latest_stamps.set_last_post_timestamp(profile_name,
posts_to_download.first_item.date_local) posts_to_download.first_item.date_local)
@@ -1576,11 +1635,16 @@ class Instaloader:
def interactive_login(self, username: str) -> None: def interactive_login(self, username: str) -> None:
"""Logs in and internally stores session, asking user for password interactively. """Logs in and internally stores session, asking user for password interactively.
:raises LoginRequiredException: when in quiet mode. :raises InvalidArgumentException: when in quiet mode.
:raises InvalidArgumentException: If the provided username does not exist. :raises LoginException: If the provided username does not exist.
:raises ConnectionException: If connection to Instagram failed.""" :raises ConnectionException: If connection to Instagram failed.
.. versionchanged:: 4.12
Raises InvalidArgumentException instead of LoginRequiredException when in quiet mode.
Raises LoginException instead of InvalidArgumentException when the username does not exist.
"""
if self.context.quiet: if self.context.quiet:
raise LoginRequiredException("Quiet mode requires given password or valid session file.") raise InvalidArgumentException("Quiet mode requires given password or valid session file.")
try: try:
password = None password = None
while password is None: while password is None:
@@ -1599,3 +1663,10 @@ class Instaloader:
except BadCredentialsException as err: except BadCredentialsException as err:
print(err, file=sys.stderr) print(err, file=sys.stderr)
pass pass
@property
def has_stored_errors(self) -> bool:
"""Returns whether any error has been reported and stored to be repeated at program termination.
.. versionadded: 4.12"""
return self.context.has_stored_errors
+197 -67
View File
@@ -1,15 +1,15 @@
import hashlib
import json import json
import os import os
import pickle import pickle
import random import random
import re
import shutil import shutil
import sys import sys
import textwrap import textwrap
import time import time
import urllib.parse import urllib.parse
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager, suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import partial from functools import partial
from typing import Any, Callable, Dict, Iterator, List, Optional, Union from typing import Any, Callable, Dict, Iterator, List, Optional, Union
@@ -31,17 +31,30 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
return new 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: def default_user_agent() -> str:
return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') '(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
def default_iphone_headers() -> Dict[str, Any]: 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; ' \ return {'User-Agent': 'Instagram 361.0.0.35.82 (iPad13,8; iOS 18_0; en_US; en-US; ' \
'scale=2.00; 2048x2732; 452417278) AppleWebKit/420+', 'scale=2.00; 2048x2732; 674117118) AppleWebKit/420+',
'x-ads-opt-out': '1', 'x-ads-opt-out': '1',
'x-bloks-is-panorama-enabled': 'true', 'x-bloks-is-panorama-enabled': 'true',
'x-bloks-version-id': '01507c21540f73e2216b6f62a11a5b5e51aa85491b72475c080da35b1228ddd6', 'x-bloks-version-id': '16b7bd25c6c06886d57c4d455265669345a2d96625385b8ee30026ac2dc5ed97',
'x-fb-client-ip': 'True', 'x-fb-client-ip': 'True',
'x-fb-connection-type': 'wifi', 'x-fb-connection-type': 'wifi',
'x-fb-http-engine': 'Liger', 'x-fb-http-engine': 'Liger',
@@ -94,7 +107,6 @@ class InstaloaderContext:
self.quiet = quiet self.quiet = quiet
self.max_connection_attempts = max_connection_attempts self.max_connection_attempts = max_connection_attempts
self._graphql_page_length = 50 self._graphql_page_length = 50
self._root_rhx_gis = None
self.two_factor_auth_pending = None self.two_factor_auth_pending = None
self.iphone_support = iphone_support self.iphone_support = iphone_support
self.iphone_headers = default_iphone_headers() self.iphone_headers = default_iphone_headers()
@@ -104,7 +116,7 @@ class InstaloaderContext:
self._rate_controller = rate_controller(self) if rate_controller is not None else RateController(self) 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 self.raise_all_errors = False
# HTTP status codes that should cause an AbortDownloadException # HTTP status codes that should cause an AbortDownloadException
@@ -151,6 +163,16 @@ class InstaloaderContext:
if repeat_at_end: if repeat_at_end:
self.error_log.append(msg) 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.
.. versionadded: 4.12"""
return bool(self.error_log)
def close(self): def close(self):
"""Print error log and close session""" """Print error log and close session"""
if self.error_log and not self.quiet: if self.error_log and not self.quiet:
@@ -186,7 +208,8 @@ class InstaloaderContext:
'Referer': 'https://www.instagram.com/', 'Referer': 'https://www.instagram.com/',
'User-Agent': self.user_agent, 'User-Agent': self.user_agent,
'X-Instagram-AJAX': '1', 'X-Instagram-AJAX': '1',
'X-Requested-With': 'XMLHttpRequest'} 'X-Requested-With': 'XMLHttpRequest',
'x-ig-app-id': '936619743392459'}
if empty_session_only: if empty_session_only:
del header['Host'] del header['Host']
del header['Origin'] del header['Origin']
@@ -236,17 +259,26 @@ class InstaloaderContext:
def test_login(self) -> Optional[str]: def test_login(self) -> Optional[str]:
"""Not meant to be used directly, use :meth:`Instaloader.test_login`.""" """Not meant to be used directly, use :meth:`Instaloader.test_login`."""
data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {}) try:
return data["data"]["user"]["username"] if data["data"]["user"] is not None else None data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})
return data["data"]["user"]["username"] if data["data"]["user"] is not None else None
except (AbortDownloadException, ConnectionException) as err:
self.error(f"Error when checking if logged in: {err}")
return None
def login(self, user, passwd): def login(self, user, passwd):
"""Not meant to be used directly, use :meth:`Instaloader.login`. """Not meant to be used directly, use :meth:`Instaloader.login`.
:raises InvalidArgumentException: If the provided username does not exist.
:raises BadCredentialsException: If the provided password is wrong. :raises BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed.
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call
:meth:`Instaloader.two_factor_login`.""" :meth:`Instaloader.two_factor_login`.
:raises LoginException: An error happened during login (for example, and invalid response).
Or if the provided username does not exist.
.. versionchanged:: 4.12
Raises LoginException instead of ConnectionException when an error happens.
Raises LoginException instead of InvalidArgumentException when the username does not exist.
"""
# pylint:disable=import-outside-toplevel # pylint:disable=import-outside-toplevel
import http.client import http.client
# pylint:disable=protected-access # pylint:disable=protected-access
@@ -277,7 +309,7 @@ class InstaloaderContext:
resp_json = login.json() resp_json = login.json()
except json.decoder.JSONDecodeError as err: except json.decoder.JSONDecodeError as err:
raise ConnectionException( raise LoginException(
"Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason) "Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason)
) from err ) from err
if resp_json.get('two_factor_required'): if resp_json.get('two_factor_required'):
@@ -289,31 +321,32 @@ class InstaloaderContext:
resp_json['two_factor_info']['two_factor_identifier']) resp_json['two_factor_info']['two_factor_identifier'])
raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.") raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.")
if resp_json.get('checkpoint_url'): if resp_json.get('checkpoint_url'):
raise ConnectionException("Login: Checkpoint required. Point your browser to " raise LoginException(
"https://www.instagram.com{} - " f"Login: Checkpoint required. Point your browser to {resp_json.get('checkpoint_url')} - "
"follow the instructions, then retry.".format(resp_json.get('checkpoint_url'))) f"follow the instructions, then retry."
)
if resp_json['status'] != 'ok': if resp_json['status'] != 'ok':
if 'message' in resp_json: if 'message' in resp_json:
raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'], raise LoginException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message'])) resp_json['message']))
else: else:
raise ConnectionException("Login error: \"{}\" status.".format(resp_json['status'])) raise LoginException("Login error: \"{}\" status.".format(resp_json['status']))
if 'authenticated' not in resp_json: if 'authenticated' not in resp_json:
# Issue #472 # Issue #472
if 'message' in resp_json: if 'message' in resp_json:
raise ConnectionException("Login error: Unexpected response, \"{}\".".format(resp_json['message'])) raise LoginException("Login error: Unexpected response, \"{}\".".format(resp_json['message']))
else: else:
raise ConnectionException("Login error: Unexpected response, this might indicate a blocked IP.") raise LoginException("Login error: Unexpected response, this might indicate a blocked IP.")
if not resp_json['authenticated']: if not resp_json['authenticated']:
if resp_json['user']: if resp_json['user']:
# '{"authenticated": false, "user": true, "status": "ok"}' # '{"authenticated": false, "user": true, "status": "ok"}'
raise BadCredentialsException('Login error: Wrong password.') raise BadCredentialsException('Login error: Wrong password.')
else: else:
# '{"authenticated": false, "user": false, "status": "ok"}' # '{"authenticated": false, "user": false, "status": "ok"}'
# Raise InvalidArgumentException rather than BadCredentialException, because BadCredentialException # Raise LoginException rather than BadCredentialException, because BadCredentialException
# triggers re-asking of password in Instaloader.interactive_login(), which makes no sense if the # triggers re-asking of password in Instaloader.interactive_login(), which makes no sense if the
# username is invalid. # username is invalid.
raise InvalidArgumentException('Login error: User {} does not exist.'.format(user)) raise LoginException('Login error: User {} does not exist.'.format(user))
# '{"authenticated": true, "user": true, "userId": ..., "oneTapPrompt": false, "status": "ok"}' # '{"authenticated": true, "user": true, "userId": ..., "oneTapPrompt": false, "status": "ok"}'
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']}) session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session self._session = session
@@ -351,33 +384,64 @@ class InstaloaderContext:
if self.sleep: if self.sleep:
time.sleep(min(random.expovariate(0.6), 15.0)) time.sleep(min(random.expovariate(0.6), 15.0))
@staticmethod
def _response_error(resp: requests.Response) -> str:
extra_from_json: Optional[str] = None
with suppress(json.decoder.JSONDecodeError):
resp_json = resp.json()
if "status" in resp_json:
extra_from_json = (
f"\"{resp_json['status']}\" status, message \"{resp_json['message']}\""
if "message" in resp_json
else f"\"{resp_json['status']}\" status"
)
return (
f"{resp.status_code} {resp.reason}"
f"{f' - {extra_from_json}' if extra_from_json is not None else ''}"
f" when accessing {resp.url}"
)
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com', 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, _attempt=1,
response_headers: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: response_headers: Optional[Dict[str, Any]] = None,
use_post: bool = False) -> Dict[str, Any]:
"""JSON request to Instagram. """JSON request to Instagram.
:param path: URL, relative to the given domain which defaults to www.instagram.com/ :param path: URL, relative to the given domain which defaults to www.instagram.com/
:param params: GET parameters :param params: request parameters
:param host: Domain part of the URL from where to download the requested JSON; defaults to www.instagram.com :param host: Domain part of the URL from where to download the requested JSON; defaults to www.instagram.com
:param session: Session to use, or None to use self.session :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 :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 QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When query repeatedly failed. :raises ConnectionException: When query repeatedly failed.
.. versionchanged:: 4.13
Added `use_post` parameter.
""" """
is_graphql_query = 'query_hash' in params and 'graphql/query' in path 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_iphone_query = host == 'i.instagram.com'
is_other_query = not is_graphql_query and host == "www.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 sess = session if session else self._session
try: try:
self.do_sleep() self.do_sleep()
if is_graphql_query: if is_graphql_query:
self._rate_controller.wait_before_query(params['query_hash']) 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: if is_iphone_query:
self._rate_controller.wait_before_query('iphone') self._rate_controller.wait_before_query('iphone')
if is_other_query: if is_other_query:
self._rate_controller.wait_before_query('other') self._rate_controller.wait_before_query('other')
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False) 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: if resp.status_code in self.fatal_status_codes:
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else "" redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
body = "" body = ""
@@ -392,7 +456,7 @@ class InstaloaderContext:
if (redirect_url.startswith('https://www.instagram.com/accounts/login') or if (redirect_url.startswith('https://www.instagram.com/accounts/login') or
redirect_url.startswith('https://i.instagram.com/accounts/login')): redirect_url.startswith('https://i.instagram.com/accounts/login')):
if not self.is_logged_in: if not self.is_logged_in:
raise LoginRequiredException("Redirected to login page. Use --login.") raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.")
raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " + raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " +
"some time, recreate the session and try again") "some time, recreate the session and try again")
if redirect_url.startswith('https://{}/'.format(host)): if redirect_url.startswith('https://{}/'.format(host)):
@@ -404,21 +468,26 @@ class InstaloaderContext:
response_headers.clear() response_headers.clear()
response_headers.update(resp.headers) response_headers.update(resp.headers)
if resp.status_code == 400: if resp.status_code == 400:
raise QueryReturnedBadRequestException("400 Bad Request") 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: if resp.status_code == 404:
raise QueryReturnedNotFoundException("404 Not Found") raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code == 429: if resp.status_code == 429:
raise TooManyRequestsException("429 Too Many Requests") raise TooManyRequestsException(self._response_error(resp))
if resp.status_code != 200: if resp.status_code != 200:
raise ConnectionException("HTTP error code {}.".format(resp.status_code)) raise ConnectionException(self._response_error(resp))
else: else:
resp_json = resp.json() resp_json = resp.json()
if 'status' in resp_json and resp_json['status'] != "ok": if 'status' in resp_json and resp_json['status'] != "ok":
if 'message' in resp_json: raise ConnectionException(self._response_error(resp))
raise ConnectionException("Returned \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message']))
else:
raise ConnectionException("Returned \"{}\" status.".format(resp_json['status']))
return resp_json return resp_json
except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err: except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err:
error_string = "JSON Query to {}: {}".format(path, err) error_string = "JSON Query to {}: {}".format(path, err)
@@ -432,6 +501,8 @@ class InstaloaderContext:
if isinstance(err, TooManyRequestsException): if isinstance(err, TooManyRequestsException):
if is_graphql_query: if is_graphql_query:
self._rate_controller.handle_429(params['query_hash']) 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: if is_iphone_query:
self._rate_controller.handle_429('iphone') self._rate_controller.handle_429('iphone')
if is_other_query: if is_other_query:
@@ -443,15 +514,17 @@ class InstaloaderContext:
raise ConnectionException(error_string) from err raise ConnectionException(error_string) from err
def graphql_query(self, query_hash: str, variables: Dict[str, Any], def graphql_query(self, query_hash: str, variables: Dict[str, Any],
referer: Optional[str] = None, rhx_gis: Optional[str] = None) -> Dict[str, Any]: referer: Optional[str] = None) -> Dict[str, Any]:
""" """
Do a GraphQL Query. Do a GraphQL Query.
:param query_hash: Query identifying hash. :param query_hash: Query identifying hash.
:param variables: Variables for the Query. :param variables: Variables for the Query.
:param referer: HTTP Referer, or None. :param referer: HTTP Referer, or None.
:param rhx_gis: 'rhx_gis' variable as somewhere returned by Instagram, needed to 'sign' request
:return: The server's response dictionary. :return: The server's response dictionary.
.. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter.
""" """
with copy_session(self._session, self.request_timeout) as tmpsession: with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True)) tmpsession.headers.update(self._default_http_header(empty_session_only=True))
@@ -465,12 +538,6 @@ class InstaloaderContext:
variables_json = json.dumps(variables, separators=(',', ':')) variables_json = json.dumps(variables, separators=(',', ':'))
if rhx_gis:
#self.log("rhx_gis {} query_hash {}".format(rhx_gis, query_hash))
values = "{}:{}".format(rhx_gis, variables_json)
x_instagram_gis = hashlib.md5(values.encode()).hexdigest()
tmpsession.headers['x-instagram-gis'] = x_instagram_gis
resp_json = self.get_json('graphql/query', resp_json = self.get_json('graphql/query',
params={'query_hash': query_hash, params={'query_hash': query_hash,
'variables': variables_json}, 'variables': variables_json},
@@ -479,10 +546,52 @@ class InstaloaderContext:
self.error("GraphQL response did not contain a \"status\" field.") self.error("GraphQL response did not contain a \"status\" field.")
return resp_json return resp_json
def doc_id_graphql_query(self, doc_id: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]:
"""
Do a doc_id-based GraphQL Query using method POST.
.. versionadded:: 4.13
:param doc_id: doc_id for the query.
:param variables: Variables for the Query.
:param referer: HTTP Referer, or None.
:return: The server's response dictionary.
"""
csrf = next((c.value for c in self._session.cookies
if c.name == 'csrftoken' and c.value), None)
if not csrf:
self._session.get('https://www.instagram.com/', timeout=self.request_timeout)
csrf = next((c.value for c in self._session.cookies
if c.name == 'csrftoken' and c.value), '')
with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True))
del tmpsession.headers['Connection']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
tmpsession.headers['x-csrftoken'] = csrf
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
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:
self.error("GraphQL response did not contain a \"status\" field.")
return resp_json
def graphql_node_list(self, query_hash: str, query_variables: Dict[str, Any], def graphql_node_list(self, query_hash: str, query_variables: Dict[str, Any],
query_referer: Optional[str], query_referer: Optional[str],
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]], edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
rhx_gis: Optional[str] = None, _rhx_gis: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]: first_data: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]:
""" """
Retrieve a list of GraphQL nodes. Retrieve a list of GraphQL nodes.
@@ -494,7 +603,7 @@ class InstaloaderContext:
def _query(): def _query():
query_variables['first'] = self._graphql_page_length query_variables['first'] = self._graphql_page_length
try: try:
return edge_extractor(self.graphql_query(query_hash, query_variables, query_referer, rhx_gis)) return edge_extractor(self.graphql_query(query_hash, query_variables, query_referer))
except QueryReturnedBadRequestException: except QueryReturnedBadRequestException:
new_page_length = int(self._graphql_page_length / 2) new_page_length = int(self._graphql_page_length / 2)
if new_page_length >= 12: if new_page_length >= 12:
@@ -579,6 +688,38 @@ class InstaloaderContext:
return response return response
def get_page_data(self, path: str) -> List[Dict[str, Any]]:
"""Load a page of ``www.instagram.com`` as a logged-out visitor and return the data it embeds.
Instagram renders public profile and post pages with the results of their GraphQL queries
embedded in the HTML, even when it refuses anonymous API requests for the same data.
:param path: URL, relative to ``www.instagram.com/``
:return: The ``data`` object of each query result that the page embeds, in page order.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When the request failed.
"""
self.do_sleep()
self._rate_controller.wait_before_query('other')
url = 'https://www.instagram.com/{0}'.format(path)
# Instagram only embeds the data when the request looks like a browser's page navigation.
headers = {'Accept': 'text/html', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none'}
try:
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, headers=headers, allow_redirects=False)
except requests.exceptions.RequestException as err:
raise ConnectionException("Page request to {}: {}".format(url, err)) from err
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
data: List[Dict[str, Any]] = []
for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', resp.text, re.DOTALL):
with suppress(json.decoder.JSONDecodeError):
data.extend(_embedded_query_data(json.loads(script.group(1))))
return data
def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None: def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None:
"""Write raw response data into a file. """Write raw response data into a file.
@@ -607,11 +748,11 @@ class InstaloaderContext:
else: else:
if resp.status_code == 403: if resp.status_code == 403:
# suspected invalid URL signature # suspected invalid URL signature
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url)) raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404: if resp.status_code == 404:
# 404 not worth retrying. # 404 not worth retrying.
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url)) raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException("HTTP error code {}.".format(resp.status_code)) raise ConnectionException(self._response_error(resp))
def get_and_write_raw(self, url: str, filename: str) -> None: def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file. """Downloads and writes anonymously-requested raw data into a file.
@@ -637,22 +778,11 @@ class InstaloaderContext:
else: else:
if resp.status_code == 403: if resp.status_code == 403:
# suspected invalid URL signature # suspected invalid URL signature
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url)) raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404: if resp.status_code == 404:
# 404 not worth retrying. # 404 not worth retrying.
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url)) raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException("HTTP error code {}.".format(resp.status_code)) raise ConnectionException(self._response_error(resp))
@property
def root_rhx_gis(self) -> Optional[str]:
"""rhx_gis string returned in the / query."""
if self.is_logged_in:
# At the moment, rhx_gis seems to be required for anonymous requests only. By returning None when logged
# in, we can save the root_rhx_gis lookup query.
return None
if self._root_rhx_gis is None:
self._root_rhx_gis = self.get_json('', {}).get('rhx_gis', '')
return self._root_rhx_gis or None
class RateController: class RateController:
+9
View File
@@ -18,6 +18,7 @@ class LatestStamps:
POST_TIMESTAMP = 'post-timestamp' POST_TIMESTAMP = 'post-timestamp'
TAGGED_TIMESTAMP = 'tagged-timestamp' TAGGED_TIMESTAMP = 'tagged-timestamp'
IGTV_TIMESTAMP = 'igtv-timestamp' IGTV_TIMESTAMP = 'igtv-timestamp'
REELS_TIMESTAMP = 'reels-timestamp'
STORY_TIMESTAMP = 'story-timestamp' STORY_TIMESTAMP = 'story-timestamp'
ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z' ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z'
@@ -87,6 +88,14 @@ class LatestStamps:
"""Sets timestamp of last download of a profile's tagged posts.""" """Sets timestamp of last download of a profile's tagged posts."""
self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp) self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp)
def get_last_reels_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's reels posts."""
return self._get_timestamp(profile_name, self.REELS_TIMESTAMP)
def set_last_reels_timestamp(self, profile_name: str, timestamp: datetime):
"""Sets timestamp of last download of a profile's reels posts."""
self._set_timestamp(profile_name, self.REELS_TIMESTAMP, timestamp)
def get_last_igtv_timestamp(self, profile_name: str) -> datetime: def get_last_igtv_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's igtv posts.""" """Returns timestamp of last download of a profile's igtv posts."""
return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP) return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP)
+48 -23
View File
@@ -7,11 +7,11 @@ from datetime import datetime, timedelta
from lzma import LZMAError from lzma import LZMAError
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar
from .exceptions import AbortDownloadException, InvalidArgumentException, QueryReturnedBadRequestException from .exceptions import InvalidArgumentException
from .instaloadercontext import InstaloaderContext from .instaloadercontext import InstaloaderContext
class FrozenNodeIterator(NamedTuple): class FrozenNodeIterator(NamedTuple):
query_hash: str query_hash: Optional[str]
query_variables: Dict query_variables: Dict
query_referer: Optional[str] query_referer: Optional[str]
context_username: Optional[str] context_username: Optional[str]
@@ -19,6 +19,7 @@ class FrozenNodeIterator(NamedTuple):
best_before: Optional[float] best_before: Optional[float]
remaining_data: Optional[Dict] remaining_data: Optional[Dict]
first_node: Optional[Dict] first_node: Optional[Dict]
doc_id: Optional[str]
FrozenNodeIterator.query_hash.__doc__ = """The GraphQL ``query_hash`` parameter.""" FrozenNodeIterator.query_hash.__doc__ = """The GraphQL ``query_hash`` parameter."""
FrozenNodeIterator.query_variables.__doc__ = """The GraphQL ``query_variables`` parameter.""" FrozenNodeIterator.query_variables.__doc__ = """The GraphQL ``query_variables`` parameter."""
FrozenNodeIterator.query_referer.__doc__ = """The HTTP referer used for the GraphQL query.""" FrozenNodeIterator.query_referer.__doc__ = """The HTTP referer used for the GraphQL query."""
@@ -28,6 +29,7 @@ FrozenNodeIterator.best_before.__doc__ = """Date when parts of the stored nodes
FrozenNodeIterator.remaining_data.__doc__ = \ FrozenNodeIterator.remaining_data.__doc__ = \
"""The already-retrieved, yet-unprocessed ``edges`` and the ``page_info`` at time of freezing.""" """The already-retrieved, yet-unprocessed ``edges`` and the ``page_info`` at time of freezing."""
FrozenNodeIterator.first_node.__doc__ = """Node data of the first item, if an item has been produced.""" FrozenNodeIterator.first_node.__doc__ = """Node data of the first item, if an item has been produced."""
FrozenNodeIterator.doc_id.__doc__ = """The GraphQL ``doc_id`` parameter."""
T = TypeVar('T') T = TypeVar('T')
@@ -64,22 +66,27 @@ class NodeIterator(Iterator[T]):
NodeIterators are matching if and only if they have the same magic. NodeIterators are matching if and only if they have the same magic.
See also :func:`resumable_iteration` for a high-level context manager that handles a resumable iteration. See also :func:`resumable_iteration` for a high-level context manager that handles a resumable iteration.
.. versionchanged: 4.13
Included support for `doc_id`-based queries (using POST method).
""" """
_graphql_page_length = 50 _graphql_page_length = 12
_shelf_life = timedelta(days=29) _shelf_life = timedelta(days=29)
def __init__(self, def __init__(self,
context: InstaloaderContext, context: InstaloaderContext,
query_hash: str, query_hash: Optional[str],
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]], edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
node_wrapper: Callable[[Dict], T], node_wrapper: Callable[[Dict], T],
query_variables: Optional[Dict[str, Any]] = None, query_variables: Optional[Dict[str, Any]] = None,
query_referer: Optional[str] = None, query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None, first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T, Optional[T]], bool]] = None): is_first: Optional[Callable[[T, Optional[T]], bool]] = None,
doc_id: Optional[str] = None):
self._context = context self._context = context
self._query_hash = query_hash self._query_hash = query_hash
self._doc_id = doc_id
self._edge_extractor = edge_extractor self._edge_extractor = edge_extractor
self._node_wrapper = node_wrapper self._node_wrapper = node_wrapper
self._query_variables = query_variables if query_variables is not None else {} self._query_variables = query_variables if query_variables is not None else {}
@@ -95,26 +102,38 @@ class NodeIterator(Iterator[T]):
self._is_first = is_first self._is_first = is_first
def _query(self, after: Optional[str] = None) -> Dict: def _query(self, after: Optional[str] = None) -> Dict:
if self._doc_id is not None:
return self._query_doc_id(self._doc_id, after)
else:
assert self._query_hash is not None
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}
if after is not None:
pagination_variables['after'] = after
pagination_variables['before'] = None
pagination_variables['first'] = 12
pagination_variables['last'] = None
data = self._edge_extractor(
self._context.doc_id_graphql_query(
doc_id, {**self._query_variables, **pagination_variables}, self._query_referer
)
)
self._best_before = datetime.now() + NodeIterator._shelf_life
return data
def _query_query_hash(self, query_hash: str, after: Optional[str] = None) -> Dict:
pagination_variables: Dict[str, Any] = {'first': NodeIterator._graphql_page_length} pagination_variables: Dict[str, Any] = {'first': NodeIterator._graphql_page_length}
if after is not None: if after is not None:
pagination_variables['after'] = after pagination_variables['after'] = after
try: data = self._edge_extractor(
data = self._edge_extractor( self._context.graphql_query(
self._context.graphql_query( query_hash, {**self._query_variables, **pagination_variables}, self._query_referer
self._query_hash, {**self._query_variables, **pagination_variables}, self._query_referer
)
) )
self._best_before = datetime.now() + NodeIterator._shelf_life )
return data self._best_before = datetime.now() + NodeIterator._shelf_life
except QueryReturnedBadRequestException: return data
new_page_length = int(NodeIterator._graphql_page_length / 2)
if new_page_length >= 12:
NodeIterator._graphql_page_length = new_page_length
self._context.error("HTTP Error 400 (Bad Request) on GraphQL Query. Retrying with shorter page length.",
repeat_at_end=False)
return self._query(after)
else:
raise
def __iter__(self): def __iter__(self):
return self return self
@@ -184,6 +203,10 @@ class NodeIterator(Iterator[T]):
""" """
return self._node_wrapper(self._first_node) if self._first_node is not None else None return self._node_wrapper(self._first_node) if self._first_node is not None else None
@staticmethod
def page_length() -> int:
return NodeIterator._graphql_page_length
def freeze(self) -> FrozenNodeIterator: def freeze(self) -> FrozenNodeIterator:
"""Freeze the iterator for later resuming.""" """Freeze the iterator for later resuming."""
remaining_data = None remaining_data = None
@@ -199,6 +222,7 @@ class NodeIterator(Iterator[T]):
best_before=self._best_before.timestamp() if self._best_before else None, best_before=self._best_before.timestamp() if self._best_before else None,
remaining_data=remaining_data, remaining_data=remaining_data,
first_node=self._first_node, first_node=self._first_node,
doc_id=self._doc_id,
) )
def thaw(self, frozen: FrozenNodeIterator) -> None: def thaw(self, frozen: FrozenNodeIterator) -> None:
@@ -216,7 +240,8 @@ class NodeIterator(Iterator[T]):
if (self._query_hash != frozen.query_hash or if (self._query_hash != frozen.query_hash or
self._query_variables != frozen.query_variables or self._query_variables != frozen.query_variables or
self._query_referer != frozen.query_referer or self._query_referer != frozen.query_referer or
self._context.username != frozen.context_username): self._context.username != frozen.context_username or
self._doc_id != frozen.doc_id):
raise InvalidArgumentException("Mismatching resume information.") raise InvalidArgumentException("Mismatching resume information.")
if not frozen.best_before: if not frozen.best_before:
raise InvalidArgumentException("\"best before\" date missing.") raise InvalidArgumentException("\"best before\" date missing.")
@@ -293,7 +318,7 @@ def resumable_iteration(context: InstaloaderContext,
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc)) context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
try: try:
yield is_resuming, start_index yield is_resuming, start_index
except (KeyboardInterrupt, AbortDownloadException): except (Exception, KeyboardInterrupt):
if os.path.dirname(resume_file_path): if os.path.dirname(resume_file_path):
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True) os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
save(iterator.freeze(), resume_file_path) save(iterator.freeze(), resume_file_path)
+443 -69
View File
@@ -20,7 +20,7 @@ class PostSidecarNode(NamedTuple):
"""Item of a Sidecar Post.""" """Item of a Sidecar Post."""
is_video: bool is_video: bool
display_url: str display_url: str
video_url: str video_url: Optional[str]
PostSidecarNode.is_video.__doc__ = "Whether this node is a video." PostSidecarNode.is_video.__doc__ = "Whether this node is a video."
@@ -51,6 +51,29 @@ class PostComment:
self._answers = answers self._answers = answers
self._post = post self._post = post
@classmethod
def from_iphone_struct(
cls,
context: "InstaloaderContext",
media: Dict[str, Any],
answers: Iterator["PostCommentAnswer"],
post: "Post",
):
return cls(
context=context,
node={
"id": int(media["pk"]),
"created_at": media["created_at"],
"text": media["text"],
"edge_liked_by": {
"count": media["comment_like_count"],
},
"iphone_struct": media,
},
answers=answers,
post=post,
)
@property @property
def id(self) -> int: def id(self) -> int:
""" ID number of comment. """ """ ID number of comment. """
@@ -67,9 +90,13 @@ class PostComment:
return self._node['text'] return self._node['text']
@property @property
def owner(self): def owner(self) -> "Profile":
""" Owner :class:`Profile` of the comment. """ """ Owner :class:`Profile` of the comment. """
return Profile(self._context, self._node['owner']) if "iphone_struct" in self._node:
return Profile.from_iphone_struct(
self._context, self._node["iphone_struct"]["user"]
)
return Profile(self._context, self._node["owner"])
@property @property
def likes_count(self): def likes_count(self):
@@ -125,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 # support Unicode and a word/beginning of string delimiter at the beginning to ensure
# that no email addresses join the list of mentions. # that no email addresses join the list of mentions.
# http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/ # 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]: def _optional_normalize(string: Optional[str]) -> Optional[str]:
@@ -153,7 +180,7 @@ class Post:
This class unifies access to the properties associated with a post. It implements == and is This class unifies access to the properties associated with a post. It implements == and is
hashable. 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 node: Node structure, as returned by Instagram.
:param owner_profile: The Profile of the owner, if already known at creation. :param owner_profile: The Profile of the owner, if already known at creation.
""" """
@@ -205,21 +232,126 @@ class Post:
"title": media.get("title"), "title": media.get("title"),
"viewer_has_liked": media["has_liked"], "viewer_has_liked": media["has_liked"],
"edge_media_preview_like": {"count": media["like_count"]}, "edge_media_preview_like": {"count": media["like_count"]},
"accessibility_caption": media.get("accessibility_caption"),
"comments": media.get("comment_count"),
"iphone_struct": media, "iphone_struct": media,
} }
with suppress(KeyError): with suppress(KeyError):
fake_node["display_url"] = media['image_versions2']['candidates'][0]['url'] fake_node["display_url"] = media['image_versions2']['candidates'][0]['url']
with suppress(KeyError): with suppress(KeyError, TypeError):
fake_node["video_url"] = media['video_versions'][-1]['url'] fake_node["video_url"] = media['video_versions'][-1]['url']
fake_node["video_duration"] = media["video_duration"] fake_node["video_duration"] = media["video_duration"]
fake_node["video_view_count"] = media["view_count"] fake_node["video_view_count"] = media["view_count"]
with suppress(KeyError): with suppress(KeyError, TypeError):
fake_node["edge_sidecar_to_children"] = {"edges": [{"node": { fake_node["edge_sidecar_to_children"] = {"edges": [{"node":
"display_url": node['image_versions2']['candidates'][0]['url'], Post._convert_iphone_carousel(node, media_types)}
"is_video": media_types[node["media_type"]] == "GraphVideo", for node in media["carousel_media"]]}
}} for node in media["carousel_media"]]}
return cls(context, fake_node, Profile.from_iphone_struct(context, media["user"]) if "user" in media else None) return cls(context, fake_node, Profile.from_iphone_struct(context, media["user"]) if "user" in media else None)
@staticmethod
def _convert_iphone_carousel(iphone_node: Dict[str, Any], media_types: Dict[int, str]) -> Dict[str, Any]:
fake_node = {
"display_url": iphone_node["image_versions2"]["candidates"][0]["url"],
"is_video": media_types[iphone_node["media_type"]] == "GraphVideo",
}
if "video_versions" in iphone_node and iphone_node["video_versions"] is not None:
fake_node["video_url"] = iphone_node["video_versions"][0]["url"]
return fake_node
@staticmethod
def _fetch_play_count_from_clips(context: 'InstaloaderContext', user_id: str,
shortcode: str) -> Optional[int]:
"""Fetch play_count for a reel via the clips connection endpoint as a fallback."""
try:
resp = context.doc_id_graphql_query(
"27234427476213202",
{"data": {"include_feed_video": True, "page_size": 12,
"target_user_id": str(user_id)}},
)
edges = ((resp.get("data") or {})
.get("xdt_api__v1__clips__user__connection_v2") or {})
for edge in edges.get("edges") or []:
media = (edge.get("node") or {}).get("media") or {}
if media.get("code") == shortcode:
return media.get("play_count")
except (ConnectionException, BadResponseException):
pass
return None
@staticmethod
def _normalize_post_data(media: Dict[str, Any], context: 'InstaloaderContext') -> Dict[str, Any]:
"""Normalize a Polaris media item to a legacy-compatible node, preserving the source structure."""
media_types = {1: "GraphImage", 2: "GraphVideo", 8: "GraphSidecar"}
media_type = media.get("media_type")
typename = None
if isinstance(media_type, int):
typename = media_types.get(media_type)
if not typename:
raise BadResponseException(f"Unknown media_type in metadata: {media_type}.")
pic_json: Dict[str, Any] = media.copy()
pic_json["shortcode"] = media["code"]
pic_json["id"] = media["pk"]
pic_json["__typename"] = typename
pic_json["is_video"] = media_type == 2
pic_json["taken_at_timestamp"] = media["taken_at"]
pic_json["owner"] = {
"id": media["user"]["pk"],
"username": media["user"].get("username", ""),
"full_name": media["user"].get("full_name", ""),
}
candidates = (media.get("image_versions2") or {}).get("candidates") or []
pic_json["display_url"] = candidates[0]["url"] if candidates else None
video_versions = media.get("video_versions") or []
pic_json["video_url"] = video_versions[0]["url"] if video_versions else None
pic_json["video_duration"] = media.get("video_duration")
pic_json["video_view_count"] = media.get("view_count")
pic_json["video_play_count"] = media.get("play_count")
if media_type == 2 and pic_json["video_view_count"] is None:
pic_json["video_play_count"] = Post._fetch_play_count_from_clips(
context, media["user"]["pk"], media["code"]
)
caption = media.get("caption")
caption_text = caption.get("text") if isinstance(caption, dict) else None
pic_json["edge_media_to_caption"] = (
{"edges": [{"node": {"text": caption_text}}]} if caption_text is not None
else {"edges": []}
)
pic_json["edge_media_preview_like"] = {"count": media.get("like_count") or 0}
pic_json["edge_media_to_parent_comment"] = {
"count": media.get("comment_count") or 0,
"edges": [],
}
if media.get("has_liked") is not None:
pic_json["viewer_has_liked"] = media["has_liked"]
carousel = media.get("carousel_media") or []
if carousel:
carousel_nodes = []
for item in carousel:
item_type = item.get("media_type", 1)
node: Dict[str, Any] = {
"shortcode": item.get("code", ""),
"__typename": media_types.get(item_type, "GraphImage"),
"is_video": item_type == 2,
}
item_candidates = (item.get("image_versions2") or {}).get("candidates") or []
node["display_url"] = item_candidates[0]["url"] if item_candidates else ""
item_videos = item.get("video_versions") or []
node["video_url"] = item_videos[0]["url"] if item_videos else None
if item.get("accessibility_caption") is not None:
node["accessibility_caption"] = item["accessibility_caption"]
carousel_nodes.append({"node": node})
pic_json["edge_sidecar_to_children"] = {"edges": carousel_nodes}
tagged = (media.get("usertags") or {}).get("in") or []
if tagged:
pic_json["edge_media_to_tagged_user"] = {
"edges": [
{"node": {"user": {"username": t["user"]["username"].lower()}}}
for t in tagged
if (t.get("user") or {}).get("username")
]
}
return pic_json
@staticmethod @staticmethod
def shortcode_to_mediaid(code: str) -> int: def shortcode_to_mediaid(code: str) -> int:
if len(code) > 11: if len(code) > 11:
@@ -281,13 +413,18 @@ class Post:
def _obtain_metadata(self): def _obtain_metadata(self):
if not self._full_metadata_dict: if not self._full_metadata_dict:
pic_json = self._context.graphql_query( resp = self._context.doc_id_graphql_query(
'2b0673e0dc4580674a88d426fe00ea90', "27128499623469141",
{'shortcode': self.shortcode} {
"shortcode": self.shortcode,
"__relay_internal__pv__PolarisAIGMMediaWebLabelEnabledrelayprovider": False,
},
) )
self._full_metadata_dict = pic_json['data']['shortcode_media'] web_info = (resp.get("data") or {}).get("xdt_api__v1__media__shortcode__web_info") or {}
if self._full_metadata_dict is None: items = web_info.get("items")
if not items:
raise BadResponseException("Fetching Post metadata failed.") raise BadResponseException("Fetching Post metadata failed.")
self._full_metadata_dict = Post._normalize_post_data(items[0], self._context)
if self.shortcode != self._full_metadata_dict['shortcode']: if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict) self._node.update(self._full_metadata_dict)
raise PostChangedException raise PostChangedException
@@ -303,7 +440,7 @@ class Post:
if not self._context.iphone_support: if not self._context.iphone_support:
raise IPhoneSupportDisabledException("iPhone support is disabled.") raise IPhoneSupportDisabledException("iPhone support is disabled.")
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to access iPhone media info endpoint.") raise LoginRequiredException("Login required to access iPhone media info endpoint.")
if not self._iphone_struct_: if not self._iphone_struct_:
data = self._context.get_iphone_json(path='api/v1/media/{}/info/'.format(self.mediaid), params={}) data = self._context.get_iphone_json(path='api/v1/media/{}/info/'.format(self.mediaid), params={})
self._iphone_struct_ = data['items'][0] self._iphone_struct_ = data['items'][0]
@@ -464,14 +601,14 @@ class Post:
@property @property
def caption_hashtags(self) -> List[str]: 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: if not self.caption:
return [] return []
return _hashtag_regex.findall(self.caption.lower()) return _hashtag_regex.findall(self.caption.lower())
@property @property
def caption_mentions(self) -> List[str]: 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: if not self.caption:
return [] return []
return _mention_regex.findall(self.caption.lower()) return _mention_regex.findall(self.caption.lower())
@@ -554,6 +691,15 @@ class Post:
return self._field('video_view_count') return self._field('video_view_count')
return None return None
@property
def video_play_count(self) -> Optional[int]:
"""Play count of the video, or None.
.. versionadded:: 4.14.3"""
if self.is_video:
return self._field('video_play_count')
return None
@property @property
def video_duration(self) -> Optional[float]: def video_duration(self) -> Optional[float]:
"""Duration of the video in seconds, or None. """Duration of the video in seconds, or None.
@@ -590,16 +736,77 @@ class Post:
except KeyError: except KeyError:
return self._field('edge_media_to_comment', 'count') return self._field('edge_media_to_comment', 'count')
def _get_comments_via_iphone_endpoint(self) -> Iterable[PostComment]:
"""
Iterate over all comments of the post via an iPhone endpoint.
.. versionadded:: 4.10.3
fallback for :issue:`2125`.
"""
def _query(min_id=None):
pagination_params = {"min_id": min_id} if min_id is not None else {}
return self._context.get_iphone_json(
f"api/v1/media/{self.mediaid}/comments/",
{
"can_support_threading": "true",
"permalink_enabled": "false",
**pagination_params,
},
)
def _answers(comment_node):
def _answer(child_comment):
return PostCommentAnswer(
id=int(child_comment["pk"]),
created_at_utc=datetime.utcfromtimestamp(child_comment["created_at"]),
text=child_comment["text"],
owner=Profile.from_iphone_struct(self._context, child_comment["user"]),
likes_count=child_comment["comment_like_count"],
)
child_comment_count = comment_node["child_comment_count"]
if child_comment_count == 0:
return
preview_child_comments = comment_node["preview_child_comments"]
if child_comment_count == len(preview_child_comments):
yield from (
_answer(child_comment) for child_comment in preview_child_comments
)
return
pk = comment_node["pk"]
answers_json = self._context.get_iphone_json(
f"api/v1/media/{self.mediaid}/comments/{pk}/child_comments/",
{"max_id": ""},
)
yield from (
_answer(child_comment) for child_comment in answers_json["child_comments"]
)
def _paginated_comments(comments_json):
for comment_node in comments_json.get("comments", []):
yield PostComment.from_iphone_struct(
self._context, comment_node, _answers(comment_node), self
)
next_min_id = comments_json.get("next_min_id")
if next_min_id:
yield from _paginated_comments(_query(next_min_id))
return _paginated_comments(_query())
def get_comments(self) -> Iterable[PostComment]: def get_comments(self) -> Iterable[PostComment]:
r"""Iterate over all comments of the post. """Iterate over all comments of the post.
Each comment is represented by a PostComment NamedTuple with fields text (string), created_at (datetime), Each comment is represented by a PostComment NamedTuple with fields text (string), created_at (datetime),
id (int), owner (:class:`Profile`) and answers (:class:`~typing.Iterator`\ [:class:`PostCommentAnswer`]) id (int), owner (:class:`Profile`) and answers (:class:`~typing.Iterator` [:class:`PostCommentAnswer`])
if available. if available.
.. versionchanged:: 4.7 .. versionchanged:: 4.7
Change return type to ``Iterable``. Change return type to ``Iterable``.
""" """
if not self._context.is_logged_in:
raise LoginRequiredException("Login required to access comments of a post.")
def _postcommentanswer(node): def _postcommentanswer(node):
return PostCommentAnswer(id=int(node['id']), return PostCommentAnswer(id=int(node['id']),
created_at_utc=datetime.utcfromtimestamp(node['created_at']), created_at_utc=datetime.utcfromtimestamp(node['created_at']),
@@ -635,12 +842,22 @@ class Post:
# Avoid doing additional requests if there are no comments # Avoid doing additional requests if there are no comments
return [] return []
comment_edges = self._field('edge_media_to_comment', 'edges') try:
comment_edges = self._field("edge_media_to_parent_comment", "edges")
except KeyError:
comment_edges = self._field("edge_media_to_comment", "edges")
answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges) answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges)
if self.comments == len(comment_edges) + answers_count: if self.comments == len(comment_edges) + answers_count:
# If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them # If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them
return [_postcomment(comment['node']) for comment in comment_edges] return [_postcomment(comment['node']) for comment in comment_edges]
if self.comments > NodeIterator.page_length():
# comments pagination via our graphql query does not work reliably anymore (issue #2125), fallback to an
# iphone endpoint if needed.
return self._get_comments_via_iphone_endpoint()
return NodeIterator( return NodeIterator(
self._context, self._context,
'97b41c52301f77ce508f55e66d17620e', '97b41c52301f77ce508f55e66d17620e',
@@ -658,7 +875,7 @@ class Post:
Require being logged in (as required by Instagram). Require being logged in (as required by Instagram).
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to access likes of a post.") raise LoginRequiredException("Login required to access likes of a post.")
if self.likes == 0: if self.likes == 0:
# Avoid doing additional requests if there are no comments # Avoid doing additional requests if there are no comments
return return
@@ -723,7 +940,11 @@ class Post:
@property @property
def is_pinned(self) -> bool: def is_pinned(self) -> bool:
"""True if this Post has been pinned by at least one user. """
.. deprecated: 4.10.3
This information is not returned by IG anymore
Used to return True if this Post has been pinned by at least one user, now likely returns always false.
.. versionadded: 4.9.2""" .. versionadded: 4.9.2"""
return 'pinned_for_users' in self._node and bool(self._node['pinned_for_users']) return 'pinned_for_users' in self._node and bool(self._node['pinned_for_users'])
@@ -757,7 +978,8 @@ class Profile:
Also, this class implements == and is hashable. Also, this class implements == and is hashable.
""" """
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]): 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._context = context
self._has_public_story: Optional[bool] = None self._has_public_story: Optional[bool] = None
self._node = node self._node = node
@@ -777,10 +999,30 @@ class Profile:
:param username: Username :param username: Username
:raises: :class:`ProfileNotExistsException` :raises: :class:`ProfileNotExistsException`
""" """
# pylint:disable=protected-access # Resolve the profile through its public web page, which Instagram renders with the
profile = cls(context, {'username': username.lower()}) # profile embedded. The web_profile_info endpoint previously used here is refused
profile._obtain_metadata() # to raise ProfileNotExistsException now in case username is invalid # with HTTP 400 for some professional profiles, and with HTTP 401 or 429 for some
return profile # 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 @classmethod
def from_id(cls, context: InstaloaderContext, profile_id: int): def from_id(cls, context: InstaloaderContext, profile_id: int):
@@ -793,16 +1035,16 @@ class Profile:
""" """
if profile_id in context.profile_id_cache: if profile_id in context.profile_id_cache:
return context.profile_id_cache[profile_id] return context.profile_id_cache[profile_id]
data = context.graphql_query('7c16654f22c819fb63d1183034a5162f', # Resolve the current username from the user id, then load the full profile.
{'user_id': str(profile_id), # The GraphQL user query previously used here started responding with HTTP 400.
'include_chaining': False, try:
'include_reel': True, user = context.get_json(
'include_suggested_users': False, "api/v1/users/{0}/info/".format(profile_id), params={}
'include_logged_out_extras': False, ).get('user')
'include_highlight_reels': False}, except QueryReturnedNotFoundException:
rhx_gis=context.root_rhx_gis)['data']['user'] user = None
if data: if user and user.get('username'):
profile = cls(context, data['reel']['owner']) profile = cls.from_username(context, user['username'])
else: else:
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " + raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
str(profile_id) + ").") str(profile_id) + ").")
@@ -831,7 +1073,7 @@ class Profile:
.. versionadded:: 4.5.2""" .. versionadded:: 4.5.2"""
if not context.is_logged_in: if not context.is_logged_in:
raise LoginRequiredException("--login required to access own profile.") raise LoginRequiredException("Login required to access own profile.")
return cls(context, context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]["user"]) return cls(context, context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]["user"])
def _asdict(self): def _asdict(self):
@@ -848,11 +1090,41 @@ class Profile:
def _obtain_metadata(self): def _obtain_metadata(self):
try: try:
if not self._has_full_metadata: if not self._has_full_metadata:
metadata = self._context.get_iphone_json(f'api/v1/users/web_profile_info/?username={self.username}', if not self._context.is_logged_in:
params={}) # Anonymous access: the web_profile_info endpoint returns the full
if metadata['data']['user'] is None: # 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)) 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 self._has_full_metadata = True
except (QueryReturnedNotFoundException, KeyError) as err: except (QueryReturnedNotFoundException, KeyError) as err:
top_search_results = TopSearchResults(self._context, self.username) top_search_results = TopSearchResults(self._context, self.username)
@@ -867,6 +1139,45 @@ class Profile:
', '.join(similar_profiles[0:5]))) from err ', '.join(similar_profiles[0:5]))) from err
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) 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']}
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: def _metadata(self, *keys) -> Any:
try: try:
d = self._node d = self._node
@@ -885,7 +1196,7 @@ class Profile:
if not self._context.iphone_support: if not self._context.iphone_support:
raise IPhoneSupportDisabledException("iPhone support is disabled.") raise IPhoneSupportDisabledException("iPhone support is disabled.")
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to access iPhone profile info endpoint.") raise LoginRequiredException("Login required to access iPhone profile info endpoint.")
if not self._iphone_struct_: if not self._iphone_struct_:
data = self._context.get_iphone_json(path='api/v1/users/{}/info/'.format(self.userid), params={}) data = self._context.get_iphone_json(path='api/v1/users/{}/info/'.format(self.userid), params={})
self._iphone_struct_ = data['user'] self._iphone_struct_ = data['user']
@@ -957,7 +1268,7 @@ class Profile:
@property @property
def biography_hashtags(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -968,7 +1279,7 @@ class Profile:
@property @property
def biography_mentions(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -1075,15 +1386,52 @@ class Profile:
:rtype: NodeIterator[Post]""" :rtype: NodeIterator[Post]"""
self._obtain_metadata() self._obtain_metadata()
if not self._context.is_logged_in:
return NodeIterator(
context=self._context,
edge_extractor=lambda d: d["data"]["user"]["edge_owner_to_timeline_media"],
node_wrapper=lambda n: Post(self._context, n, self),
query_variables={
"data": {
"count": 12,
"include_relationship_info": True,
"latest_besties_reel_media": True,
"latest_reel_media": True,
},
"id": self.userid,
},
query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(),
doc_id="7950326061742207",
query_hash=None,
first_data=self._metadata("edge_owner_to_timeline_media"),
)
return NodeIterator( return NodeIterator(
self._context, context=self._context,
'003056d32c2554def87228bc3fd9668a', edge_extractor=lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"],
lambda d: d['data']['user']['edge_owner_to_timeline_media'], node_wrapper=lambda n: Post.from_iphone_struct(self._context, n),
lambda n: Post(self._context, n, self), # The query and variables that the web app sends for a profile's posts tab
{'id': self.userid}, # (PolarisProfilePostsTabContentQuery_connection). Instagram answers the
'https://www.instagram.com/{0}/'.format(self.username), # previous query, 7898261790222653, with "execution error".
self._metadata('edge_owner_to_timeline_media'), query_variables={
Profile._make_is_newest_checker() "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": 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="28975909992013618",
query_hash=None,
) )
def get_saved_posts(self) -> NodeIterator[Post]: def get_saved_posts(self) -> NodeIterator[Post]:
@@ -1092,7 +1440,7 @@ class Profile:
:rtype: NodeIterator[Post]""" :rtype: NodeIterator[Post]"""
if self.username != self._context.username: if self.username != self._context.username:
raise LoginRequiredException("--login={} required to get that profile's saved posts.".format(self.username)) raise LoginRequiredException(f"Login as {self.username} required to get that profile's saved posts.")
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1120,6 +1468,30 @@ class Profile:
is_first=Profile._make_is_newest_checker() is_first=Profile._make_is_newest_checker()
) )
def get_reels(self) -> NodeIterator[Post]:
"""Retrieve all reels from a profile.
:rtype: NodeIterator[Post]
.. versionadded:: 4.14.0
"""
self._obtain_metadata()
return NodeIterator(
context = self._context,
edge_extractor = lambda d: d['data']['xdt_api__v1__clips__user__connection_v2'],
# Reels post info is incomplete relative to regular posts so we create a Post from the shortcode
# and fetch the additional metadata with an additional API request per Reel
node_wrapper = lambda n: Post.from_shortcode(context=self._context, shortcode=n["media"]["code"]),
query_variables = {'data': {
'page_size': 12, 'include_feed_video': True, "target_user_id": str(self.userid)}},
query_referer = 'https://www.instagram.com/{0}/'.format(self.username),
is_first = Profile._make_is_newest_checker(),
# fb_api_req_friendly_name=PolarisProfileReelsTabContentQuery_connection
doc_id = '7845543455542541',
query_hash = None,
)
def get_igtv_posts(self) -> NodeIterator[Post]: def get_igtv_posts(self) -> NodeIterator[Post]:
"""Retrieve all IGTV posts. """Retrieve all IGTV posts.
@@ -1152,7 +1524,7 @@ class Profile:
.. versionadded:: 4.10 .. versionadded:: 4.10
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to get a profile's followers.") raise LoginRequiredException("Login required to get a profile's followers.")
self._obtain_metadata() self._obtain_metadata()
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1171,7 +1543,7 @@ class Profile:
:rtype: NodeIterator[Profile] :rtype: NodeIterator[Profile]
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to get a profile's followers.") raise LoginRequiredException("Login required to get a profile's followers.")
self._obtain_metadata() self._obtain_metadata()
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1190,7 +1562,7 @@ class Profile:
:rtype: NodeIterator[Profile] :rtype: NodeIterator[Profile]
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to get a profile's followees.") raise LoginRequiredException("Login required to get a profile's followees.")
self._obtain_metadata() self._obtain_metadata()
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1209,7 +1581,7 @@ class Profile:
.. versionadded:: 4.4 .. versionadded:: 4.4
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to get a profile's similar accounts.") raise LoginRequiredException("Login required to get a profile's similar accounts.")
self._obtain_metadata() self._obtain_metadata()
yield from (Profile(self._context, edge["node"]) for edge in yield from (Profile(self._context, edge["node"]) for edge in
self._context.graphql_query("ad99dd9d3646cc3c0dda65debcd266a7", self._context.graphql_query("ad99dd9d3646cc3c0dda65debcd266a7",
@@ -1288,7 +1660,7 @@ class StoryItem:
if not self._context.iphone_support: if not self._context.iphone_support:
raise IPhoneSupportDisabledException("iPhone support is disabled.") raise IPhoneSupportDisabledException("iPhone support is disabled.")
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("--login required to access iPhone media info endpoint.") raise LoginRequiredException("Login required to access iPhone media info endpoint.")
if not self._iphone_struct_: if not self._iphone_struct_:
data = self._context.get_iphone_json( data = self._context.get_iphone_json(
path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={} path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={}
@@ -1385,7 +1757,7 @@ class StoryItem:
@property @property
def caption_hashtags(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -1396,7 +1768,7 @@ class StoryItem:
@property @property
def caption_mentions(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -1695,11 +2067,11 @@ class Hashtag:
@classmethod @classmethod
def from_name(cls, context: InstaloaderContext, name: str): 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. hashtag with the given name.
:param context: :attr:`Instaloader.context` :param context: :attr:`Instaloader.context`
:param name: Hashtag, without preceeding '#' :param name: Hashtag, without preceding '#'
:raises: :class:`QueryReturnedNotFoundException` :raises: :class:`QueryReturnedNotFoundException`
""" """
# pylint:disable=protected-access # pylint:disable=protected-access
@@ -1709,11 +2081,13 @@ class Hashtag:
@property @property
def name(self): def name(self):
"""Hashtag name lowercased, without preceeding '#'""" """Hashtag name lowercased, without preceding '#'"""
return self._node["name"].lower() return self._node["name"].lower()
def _query(self, params): 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"] return json_response["graphql"]["hashtag"] if "graphql" in json_response else json_response["data"]
def _obtain_metadata(self): def _obtain_metadata(self):
@@ -2036,7 +2410,7 @@ def load_structure(context: InstaloaderContext, json_structure: dict) -> JsonExp
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from """Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
a json structure. 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 :param json_structure: Instaloader JSON structure
.. versionadded:: 4.8 .. versionadded:: 4.8
@@ -2066,7 +2440,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 """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`. 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' :param filename: Filename, ends in '.json' or '.json.xz'
""" """
compressed = filename.endswith('.xz') compressed = filename.endswith('.xz')
+7 -6
View File
@@ -18,10 +18,10 @@ def get_version():
raise SystemExit("Could not find version string.") raise SystemExit("Could not find version string.")
if sys.version_info < (3, 8): if sys.version_info < (3, 10):
sys.exit('Instaloader requires Python >= 3.8.') sys.exit('Instaloader requires Python >= 3.10.')
requirements = ['requests>=2.4'] requirements = ['requests>=2.25']
optional_requirements = { optional_requirements = {
'browser_cookie3': ['browser_cookie3>=0.19.1'], 'browser_cookie3': ['browser_cookie3>=0.19.1'],
} }
@@ -46,7 +46,7 @@ setup(
'from Instagram.', 'from Instagram.',
long_description=open(os.path.join(SRC, 'README.rst')).read(), long_description=open(os.path.join(SRC, 'README.rst')).read(),
install_requires=requirements, install_requires=requirements,
python_requires='>=3.8', python_requires='>=3.10',
extras_require=optional_requirements, extras_require=optional_requirements,
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']}, entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
zip_safe=False, zip_safe=False,
@@ -58,11 +58,12 @@ setup(
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12', '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', 'Programming Language :: Python :: 3 :: Only',
'Topic :: Internet', 'Topic :: Internet',
'Topic :: Multimedia :: Graphics' '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()