Compare commits

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

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

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

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

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

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

---------

Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2026-10-10 19:29:14 +02:00
Alexander Graf af898e2c31 Reference new integration tests and remove deprecated instaloader_unittests 2026-10-10 19:09:30 +02:00
Alexander Graf 8f680ae52d Add __repr__ to Instaloader and InstaloaderContext 2026-10-10 18:39:15 +02:00
agustux 7efc78de12 docs/codesnippets: Fix Firefox 147+ Linux cookie path (#2727) 2026-09-06 20:20:51 +02:00
Alexander Graf 0c143b6121 Merge branch 'master' into upcoming/v4.16 2026-07-26 11:19:13 +02:00
Alexander Graf 543469296e Release of version 4.15.3 2026-07-26 11:01:52 +02:00
Elias 6dd77e7b56 Fix Post metadata fetching (#2706)
Use doc_id 27128499623469141 (PolarisPostRootQuery /
xdt_api__v1__media__shortcode__web_info) which returns v1/iPhone API format. Converts the new format to the legacy GraphQL field names that the rest of the codebase expects.
2026-07-26 10:47:46 +02:00
Markus Magnuson a4adeb0835 Fix profile resolution (#2701)
Fix Profile.from_id(), Profile.from_username() and Profile._obtain_metadata() by using `users/web_profile_info/` and `users/{profile_id}/info` queries.
2026-07-26 10:18:00 +02:00
Joseph May ad03b392db Fix caption mentions being missed after a newline
_mention_regex required the character before '@' to be a non-word
character other than '\n', so a mention at the start of a new line was
never matched. The '\n' exclusion was introduced in 8784ac7 when the
prefix went from '\W' to '[^\w\n]'; restoring '\W' matches mentions
after a newline while still keeping email addresses out of the results,
since the character before '@' in an address is a word character.

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

Fixes #2709
2026-07-13 20:48:06 +02:00
André Koch-Kramer 297a88f252 Release of version 4.15.2 2026-07-05 21:16:53 +02:00
Mike Holzinger db2ae98928 Guard against null data in Post._obtain_metadata (#2705)
When Instagram returns {"data": null} for a shortcode metadata query (e.g. under
rate limiting, or for an unavailable post), response["data"]["xdt_shortcode_media"]
raised a raw TypeError before the existing `if pic_json is None` check could run.
Guard the intermediate "data" so the clean BadResponseException is raised instead,
which the download loop already handles.
2026-07-05 21:04:29 +02:00
npiriou b79a0a1e8b Fix profile metadata GraphQL query flags (#2696) 2026-07-05 20:49:03 +02:00
Alexander Graf d3cd639789 Remove unneeded symlink 2026-04-15 21:21:12 +02:00
Alexander Graf 618c23144e Welcome SocialAPIs as sponsor 2026-04-15 21:17:02 +02:00
Alexander Graf 66165a9d5d Improve typing on import_session() 2026-04-11 12:26:09 +02:00
Alexander Graf b806153ab6 Merge branch 'master' into upcoming/v4.16 2026-03-21 22:07:52 +01:00
Alexander Graf 27e42f661e Release of version 4.15.1 2026-03-21 22:01:12 +01:00
Xyfer 78bbd697cd Fix obtaining Profiles (when logged in) by migrating to new GraphQL endpoints (#2652)
Use doc_id 26347858941511777 (search query) in from_username() to get user_id
Use doc_id 25980296051578533 (PolarisProfilePageContentQuery) in _obtain_metadata()
Add _normalize_profile_data() to map new API fields to legacy format
2026-03-21 22:00:18 +01:00
Luis Fernando Illapa 247bddcc9f Add progress counter to "Retrieving highlights" log line (#2634)
Introduce progress output for highlight retrieval, displaying a counter of
the current highlight index relative to the total number of highlights. This
mirrors the existing per-item download counter and provides consistent,
useful feedback when downloading multiple highlights. It uses the same
dynamic-width format as other Instaloader progress indicators.

To support this feature, the Iterator returned by get_highlights(user) is
materialized into a list so the total number of highlights can be determined
upfront. This does not alter functional behavior, since all highlights were
already iterated over, and preserves ordering while enabling fixed-width
formatted progress output.
2026-01-18 17:08:58 +01:00
Grant Nestor e4524e119a Save resume information on exception (#2600) 2026-01-18 16:46:03 +01:00
Alexander Graf 520b69d6ef Release of version 4.15 2025-11-08 22:27:37 +01:00
Alexander Graf 25c4daa861 Merge branch 'upcoming/v4.15' 2025-11-08 22:19:37 +01:00
Alexander Graf 7d256c36c1 Avoid using deprecated Hashtag.get_posts in codesnippets 2025-11-08 22:14:36 +01:00
Samuele MazziandAlexander Graf 64908788d3 Fix anonymous fetch of profile posts (#2533)
Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2025-11-08 22:07:58 +01:00
DenysandAlexander Graf b7ef306374 Abort download on {feedback,checkpoint,challenge}_required (#2612)
Handle Instagram feedback_required/checkpoint_required/challenge_required responses with a AbortDownloadException, causing the resumable iteration to be interrupted.

Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2025-11-08 18:10:47 +01:00
Alexander Graf 813621026c Update default user agent 2025-11-08 17:36:41 +01:00
Alexander Graf 44bd4d173b Update dependency versions used in CI 2025-11-08 17:33:16 +01:00
Naitri Doshi 2393a1a7a4 Add Post.video_play_count property (#2595) 2025-10-23 11:02:21 +00:00
Alexander Graf 5304e9794b First Alpha Release for Version 4.15 2025-07-18 08:06:07 +02:00
Alexander Graf ee3106814e Update default user agent 2025-07-18 08:05:11 +02:00
Alexander Graf 4c6d6e50af Update dependency versions used in CI 2025-07-18 08:05:11 +02:00
Alexander Graf af287c3fdd Merge branch 'master' into upcoming/v4.15 2025-07-18 07:51:08 +02:00
Alexander Graf 0bc9ab55dd Release of version 4.14.2 2025-07-18 07:47:19 +02:00
Ivan Semkin e64394fd25 Add possibly_pinned to download reels loop (#2539)
Add `possibly_pinned` to reels loop.
2025-07-04 07:45:07 +02:00
Alexander Graf 43cc15d1b3 Update paragraph explaining --load-cookies and --cookiefile 2025-01-29 08:09:38 +01:00
Alexander Graf a263ec85a6 Update default user agent 2025-01-29 07:52:35 +01:00
Alexander Graf 487d2c366d Update dependency versions used in CI
Changes to browser_cookie3: https://github.com/borisbabic/browser_cookie3/compare/0.19.1...0.20.1
2025-01-29 07:50:01 +01:00
master131 e493fa9c2d Update headers for iOS (#2472) 2025-01-24 08:23:49 +01:00
Alexander Graf 2e482b063a Release of version 4.14.1 2025-01-24 08:19:13 +01:00
Alexander Graf 0bc91b722e Fix download hashtags
Closes #2144.
2025-01-24 08:17:53 +01:00
tinyboxvk 4264d40f19 Fix typos (#2474)
Signed-off-by: tinyboxvk <13696594+tinyboxvk@users.noreply.github.com>
2025-01-15 08:29:32 +01:00
André Koch-Kramer b15a595083 Update .SRCINFO template for arch package 2024-11-21 08:41:30 +01:00
Alexander Graf 71ae32c85e Update PyPI and Arch packaging process 2024-11-21 08:26:05 +01:00
David Hotham f5134ff95e Publish wheels (#2430) 2024-11-21 07:58:27 +01:00
Alexander Graf 347f01b7fc Release of version 4.14 2024-11-07 08:21:03 +01:00
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
Alexander Graf 6eea8e6fcd Second release candidate of Version 4.11 2023-12-18 08:08:43 +01:00
Alexander Graf 5166fe691a Merge branch 'master' into upcoming/v4.11 2023-12-18 08:07:43 +01:00
Alexander Graf 9fa01509df Update pipfile.lock dependencies 2023-12-18 08:05:44 +01:00
Alexander Graf f0420e8a20 Update default user agent 2023-12-18 08:01:06 +01:00
Alexander Graf 7cc13ee08e Add versionadded directives and spacings 2023-12-18 07:58:53 +01:00
Juned Khan bf396f0e6c Implemented fix to avoid simuntaneous use of --login and --load-cookies (#2119) 2023-12-18 07:50:45 +01:00
Alexander Graf 39566735f7 Release of version 4.10.2 2023-12-09 17:59:08 +01:00
Alexander Graf fff25d6f4a Remove unnecessary do_sleep() 2023-12-09 17:58:40 +01:00
LostXOR b52547ea1c Fix 403 during login due to incorrect login endpoint and CSRF token location (#2133) 2023-12-09 17:56:21 +01:00
Alexander Graf 08bf0fef29 Attempt to fix publish package workflow 2023-11-15 08:02:57 +01:00
Alexander Graf 5efa5b3637 First release candidate of Version 4.11 2023-11-15 07:54:59 +01:00
Alexander Graf 82b46605a2 Add notice of 2FA currently not working 2023-11-15 07:53:45 +01:00
Alexander Graf f80eebec7a Update default user agent 2023-11-15 07:47:34 +01:00
Juned Khan 5a92f264c8 Load cookies from browser (#2040) 2023-11-15 07:32:18 +01:00
Alexander Graf acb65b5cca Merge branch 'master' into upcoming/v4.11 2023-10-16 08:54:59 +02:00
Alexander Graf 51a16a433e Release of version 4.10.1 2023-10-16 08:27:46 +02:00
Alexander Graf 6145c5b383 Temporarily disable lint check on python 3.11
It fails due to a problem with installing dependencies, which is already
fixed in a3dc75c04e.
2023-10-16 08:26:10 +02:00
LostXOR 8bc35cf3a1 Fixed error when trying to log in due to changes by Instagram (#2088) 2023-10-16 08:23:25 +02:00
Alexander Graf a3dc75c04e Update all dependencies 2023-10-14 17:31:41 +02:00
sczerniawski e48251e75e Fix crash related to --latest-stamps (#2056) 2023-09-18 08:15:35 +02:00
ybenel 3ebfe40729 Get comment likes (#1998) 2023-07-17 08:09:20 +02:00
Alexander Graf ebdc5f1557 Release of Version 4.10 2023-07-10 07:18:05 +02:00
Alexander Graf 4c6a184390 Merge branch 'master' into upcoming/v4.10 2023-07-10 07:15:36 +02:00
ybenel ed9ca62a48 Get Followed Hashtags (#1967) 2023-05-24 07:32:47 +02:00
Alexander Graf 99c3960ad4 NodeIterator: Support single page data 2023-05-15 07:37:34 +02:00
Eduardo Kalinowski 14124b617e Log the response when an abort status is received (#1901) 2023-05-10 07:19:53 +02:00
Alexander Graf b40a5aa243 resumable_iterator: catch EOFError when loading resume file
Fixes #1905.
2023-05-02 09:48:02 +02:00
Alexander Graf cc8c9ff883 README: Add new sponsor 2023-04-25 07:33:37 +02:00
Alexander Graf 75d3ab69d1 First Beta Release for Version 4.10 2023-04-19 07:58:14 +02:00
Alexander Graf 79820bb442 Merge branch 'master' into upcoming/v4.10 2023-04-08 07:29:13 +02:00
Alexander Graf f70a7eb4db README: remove travis badge 2023-04-02 12:48:40 +02:00
Eduardo Kalinowski 2dca99b89e Abort the download when redirected to login (#1900)
Abort the download when redirected to login (fixes #1882)
2023-03-30 18:51:27 +02:00
master131 1074a74a59 Update iOS version headers 2023-03-30 18:42:45 +02:00
Alexander Graf aa59683bd1 update CI dependencies, require Python>=3.8 2023-03-30 18:35:42 +02:00
Alexander Graf 6a3d763cf3 Release of Version 4.9.6 2023-02-11 13:21:07 +01:00
SayanthD e2d42807a2 Fixed downloading with location id 2023-02-11 13:13:27 +01:00
Eduardo M KALINOWSKI d07bd6a096 Detect redirects to https://i.instagram.com/accounts/login 2023-02-11 13:00:52 +01:00
Alexander Graf 0462d5c34a update CI dependencies, require Python>=3.8 2023-02-11 12:45:38 +01:00
master131 1db182ec27 Update UA and enhance headers for iOS struct (#1744) 2022-10-05 20:11:54 +02:00
Alexander Graf d09493e669 Merge branch 'master' into upcoming/v4.10 2022-10-05 20:02:51 +02:00
Alexander Graf 3cc29a4ceb Release of Version 4.9.5 2022-09-26 21:38:51 +02:00
ILikeToCode ffb0aea5eb Fixed downloading hashtags (#1749) 2022-09-26 21:37:02 +02:00
Alexander Graf 84bb60c155 Release of Version 4.9.4 2022-09-16 20:58:52 +02:00
fireattack 7857909886 Handle empty video resource in some old stories in highlights (#1732) 2022-09-16 20:56:01 +02:00
Alexander Graf 2206a7e5d7 Add new sponsor to README.rst
We are proud to announce @socialmethod as our new sponsor.
2022-09-16 19:03:24 +02:00
Alexander Graf f1b5e9cdf1 Increase stale period for pull requests and issues 2022-09-07 21:09:46 +02:00
Eduardo Kalinowski 8debcbb7ab Fixes incorrect latest stamp when download is resumed (#1717) 2022-09-07 21:01:48 +02:00
Alexander Graf e97fd8a27c Release of Version 4.9.3 2022-08-11 20:00:42 +02:00
fireattack 7f9d1618ad Use api/v1/feed/reels_media endpoint to get story info (#1675) 2022-08-11 19:59:02 +02:00
fireattack 6a42906697 don't try to call iPhone api when comparing profile_pic URL (#1681) 2022-08-11 19:56:52 +02:00
MiguelX413 0570dd71d6 Add Profile.biography_hashtags, .biography_mentions, StoryItem.caption, .caption_hashtags, .caption_mentions, and .pcaption 2022-07-23 16:49:36 +02:00
MiguelX413 fb14c2699e Make profile biographies be unicode normalized 2022-07-23 16:49:36 +02:00
MiguelX413 8784ac7d9b Fix and globalize regex 2022-07-23 16:49:36 +02:00
fireattack 4bb32e3c5a write caption in text mode (#1632) 2022-07-14 21:00:24 +02:00
Alexander Graf 5e232fd4f5 Release of Version 4.9.2 2022-07-04 20:18:22 +02:00
Eduardo Kalinowski da0dcb106c Handle pinned posts with --fast-update and --latest-stamps (#1586)
Instead of hard-coding a check for pinned items when storing the first
item, allows the creater of NodeIterator to specify how the comparison
should be made.

This also allows storing the newest post (whatever it's status is),
instead of simply storing the newst non-pinned post, which prevents
redownloads in case a user adds only a pinned post between runs.
2022-07-04 20:02:41 +02:00
MiguelX413 0c21da8c18 Improve typing syntax and type NamedTuples (#1610)
* Change to PEP 526 typing syntax

* Change from collections.namedtuple to typing.NamedTuple
2022-07-04 20:00:07 +02:00
Marco Volpato 9b928b4679 Add save and load session from dict (#1591) 2022-06-23 20:53:52 +02:00
Alexander Graf 8649a10113 docs: fix link to requests intersphinx inventory 2022-06-23 20:50:05 +02:00
Alexander Graf d406de4f35 docs: fix link to requests intersphinx inventory 2022-06-23 20:43:53 +02:00
Ziemowit Zabawa 2d37e1dca2 Make docs explicit about importing multiple argument files (#1548) 2022-06-16 10:25:43 +02:00
fireattack 37cbc5d72d Handle StoryImage in StoryItem.url (#1558) 2022-06-16 10:18:59 +02:00
Alexander Graf 862aa4e82c Merge branch 'master' into upcoming/v4.10 2022-06-08 18:18:53 +02:00
Alexander Graf 853e860363 Release of Version 4.9.1 2022-06-08 18:13:38 +02:00
twitter-79 90cfeaf471 Fixed fetching a users profile information (#1561)
* fixed fetching a users profile information

switched to new api/v1/users/web_profile_info/?username=xy endpoint

* fixed non existing users

thanks to @Dibort for fixing this!
2022-06-08 18:12:07 +02:00
Nicolai Weitkemper 5a24064012 Fix incorrect error messages when certain requests fail (#1536)
- handle case where `Profile.from_id` works, but `Profile.from_username` doesn't

- Handle case where `QueryReturnedNotFoundException` lies and `similar_profiles` contains the exact username

- Still raises `ProfileNotExistsException` for compatibility.
2022-05-30 21:29:34 +02:00
canh 644fab1f5e Make latest-stamps create intermediate directories (#1500)
Fixes #1467
2022-04-21 18:23:41 +02:00
MiguelX413 0704602e50 Checks if caption is not None before normalization (#1475) 2022-04-18 09:49:34 +02:00
Alexander Graf 218d88fc6d Release of Version 4.9 2022-03-26 17:18:56 +01:00
Alexander Graf 385c6c8a35 Merge branch 'upcoming/v4.9' 2022-03-26 17:15:09 +01:00
Alexander Graf c0e5d0475b StoryItem.from_mediaid()
Addresses #1238.
2022-03-26 17:03:57 +01:00
Alexander Graf fbd7df2a90 Use resumable iteration to download hashtags
Closes #1457.
2022-03-26 17:02:06 +01:00
JeremyKj87 569808f7a4 Fix for infinity loading if next page is empty (#1465) 2022-03-26 15:57:45 +01:00
Alexander Graf b808843efe Actions: Optimize Publish Package workflow 2022-03-19 17:27:22 +01:00
Alexander Graf f66cdd3e07 Release of Version 4.8.5 2022-03-19 17:15:52 +01:00
Alexander Graf 83dc5f749d Attempt to fix Documentation deployment 2022-03-19 17:02:24 +01:00
Alexander Graf 41de9e5a7c Attempt to fix AUR deployment 2022-03-19 16:53:55 +01:00
Alexander Graf d938f3e2f5 Third Beta Release for Version 4.9 2022-03-19 16:35:53 +01:00
Alexander Graf 374d9d0d81 Merge branch 'fully-migrate-to-gh-actions' into upcoming/v4.9 2022-03-19 16:34:59 +01:00
Alexander Graf 3cbee1b318 Fully Migrate to GitHub Actions 2022-03-19 16:29:48 +01:00
Alexander Graf dbc91cbeb1 Release of Version 4.8.5 2022-03-19 15:08:36 +01:00
fireattack 5a42a7537f Use header to determine file extension (#1447) 2022-03-19 15:04:42 +01:00
MiguelX413 d8d268f861 Mention regex will now be restricted to ASCII (#1456) 2022-03-18 14:57:28 +01:00
MiguelX413 463ada519e Add unicode normalization of captions (#1453) 2022-03-17 16:06:26 +01:00
canh 604b107586 Add --sanitize-paths option (#1452) 2022-03-17 15:27:36 +01:00
Alexander Graf 37a93ee59a Second Beta Release for Version 4.9 2022-01-15 17:34:18 +01:00
2d4f3b8537 Make {Post,StoryItem}.date_local timezone aware
This commit is made up of 21365ec668 555c86633c d864ce08ff

Discussed in #1305, #1316, #1372.

Co-Authored-By: Misael <misael.om@gmail.com>
Co-Authored-By: Eduardo Kalinowski <eduardo@kalinowski.com.br>
2022-01-15 17:34:18 +01:00
Alexander Graf 8c752532e1 Merge branch 'master' into upcoming/v4.9 2022-01-15 17:17:19 +01:00
Alexander Graf fdcf2e1367 Release of Version 4.8.4 2022-01-15 17:11:45 +01:00
Alexander Graf ca78fee307 Revert date_utc and date_local being timezone aware
Fixes #1379.

Revert "Make {Post,StoryItem}.date_utc timezone aware"
d864ce08ff.

Revert "Remove unnecessary conversion to timezone aware timestamp (#1372)"
555c86633c.

Revert "Timezone option for datetime style formatting (#1316)"
21365ec668.
2022-01-15 17:08:04 +01:00
Alexander Graf d6e5e31005 Release of Version 4.8.3 2022-01-07 14:56:40 +01:00
Alexander Graf d864ce08ff Make {Post,StoryItem}.date_utc timezone aware
Also simplify {Post,StoryItem}.date_local.

Discussed in #1316.
2022-01-07 14:53:23 +01:00
Eduardo Kalinowski 555c86633c Remove unnecessary conversion to timezone aware timestamp (#1372)
Post.date_local now already returns a timestamp with the appropriate
timezone.
2022-01-07 14:31:41 +01:00
Misael 21365ec668 Timezone option for datetime style formatting (#1316)
Add timezone awareness for datetime objects returned by `date_local` and `date_utc`.
Fixes #1305.
2021-12-31 16:50:16 +01:00
Alexander Graf 6343e9a69d Release of Version 4.8.2 2021-11-27 16:25:12 +01:00
Alexander Graf 06574eb428 Fix KeyError on attempt to get incomplete location
Fixes #1349.
2021-11-27 16:25:12 +01:00
JeremyKj87 815ba83d77 avoid comments endless loop - check if query_response is always the same (#1347) 2021-11-27 15:47:32 +01:00
Alexander Graf 5bff9b5474 First Beta Release for Version 4.9 2021-11-12 21:44:08 +01:00
Alexander Graf a23b832a69 Add Post.accessibility_caption property 2021-11-12 21:41:40 +01:00
Alexander Graf 5d18857695 Fix download of hashtags and locations
Fixes #1080, fixes #1129, closes #1240.
2021-11-12 21:33:48 +01:00
Alexander Graf d6fd4c560c Merge branch 'master' into upcoming/v4.9 2021-11-12 20:28:19 +01:00
fireattack 470fc0d905 Fix KeyError for video_url, and better workflow (#1321)
Fixes #1320.
2021-11-12 15:21:33 +01:00
Alexander Graf 0300d8a4f1 Fix CI for Python 3.10 2021-11-12 14:32:15 +01:00
Alexander Graf f09bd51ed4 Update feature suggestion issue template 2021-09-25 10:32:36 +02:00
Arman Yeghiazaryan 7a32272b55 Fix the issue with folder names starting with dots (#1252) 2021-08-14 16:27:14 +02:00
Alexander Graf 9eb2339028 Update default user agent string 2021-08-10 09:25:00 +02:00
Alexander Graf c45b536be9 Update dependencies 2021-08-10 09:25:00 +02:00
Alexander Graf 2c116683c2 Release of Version 4.8.1 2021-08-10 09:15:11 +02:00
Alexander Graf 054fa47245 Fix rare KeyError: 'video_versions' error
Closes #1251.
2021-08-10 09:13:42 +02:00
Alexander Graf 1f8ebe09b4 Release of Version 4.8 2021-08-04 18:27:45 +02:00
Alexander Graf 87dfdff335 Merge branch 'master' into upcoming/v4.8 2021-08-04 18:24:46 +02:00
Alexander Graf 631503d463 Log "Using latest stamps from ..." message
Instaloader always logs which files are loaded and considered in the
current run (such as session files or resume files). This commit adds a
log message "Using latest stamps from ..." when using --latest-stamps,
to be consistent.
2021-08-04 18:12:12 +02:00
Eduardo Kalinowski 43c9d32b22 Avoid uninitialized variable when called with :stories (#1242) 2021-08-04 18:04:16 +02:00
Alexander Graf 1ad9111753 Release of Version 4.7.6 2021-08-04 17:46:06 +02:00
fireattackandAlexander Graf 327fcfd8e8 Download best-quality video (#1232)
Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2021-08-04 17:42:14 +02:00
Arman Yeghiazaryan ae39ab9893 Fix Post.location / --geotags (#1244)
Closes #1109.
2021-08-04 17:28:56 +02:00
Alexander Graf 0372f7c8e8 Release of Version 4.7.5 2021-07-24 20:34:14 +02:00
Alexander Graf aaeaaa0e22 Fourth Release Candidate for Version 4.8 2021-07-24 19:29:56 +02:00
Alexander Graf 35bd8357d1 Merge branch 'master' into upcoming/v4.8 2021-07-24 19:28:53 +02:00
Eduardo Kalinowski 9cdf679fc1 Fix interruped downloads with --latest-stamps (#1219)
The most recent post is cached in NodeIterator (and
saved to the disk), and its timestamp is used, instead of the
timestamp instaloader was run.

This way, even in later resuming runs the timestamp stored is the same
that would have been stored if the first run.

Fixes #1206.
2021-07-24 19:27:46 +02:00
Alexander Graf 94d599db61 List all login issues in docs/troubleshooting 2021-07-24 19:18:39 +02:00
fireattack a0e3e8db0d Do not delete referer header (#1237)
since it causes IG's CDN to limit download speed
2021-07-24 16:37:07 +02:00
Alexander Graf 26c9f71132 Third Release Candidate for Version 4.8 2021-06-22 21:52:26 +02:00
Eduardo Kalinowski 210719af56 Avoid uninitialized variable (#1204)
fixes #1203
2021-06-22 21:50:06 +02:00
Alexander Graf 8c1129e556 Add GitHub Sponsors link 2021-06-20 13:50:59 +02:00
Alexander Graf c200031be6 Increase stale period for pull requests and issues 2021-06-20 13:50:59 +02:00
Alexander Graf a0d2ec95b9 Release of Version 4.7.4 2021-06-18 15:19:51 +02:00
Alexander Graf 750dd3f47f Second Release Candidate for Version 4.8 2021-06-18 15:13:01 +02:00
Alexander Graf 839b77eef0 First Release Candidate for Version 4.8 2021-06-16 23:17:04 +02:00
Alexander Graf e346842f7b Merge branch 'master' into upcoming/v4.8 2021-06-16 23:08:18 +02:00
Eduardo Kalinowski 455a757159 Make downloads using --latest-stamps resumable (#1193)
Using itertools.takewhile() on a NodeIterator returns a plain Iterator,
and so it's not resumable.

The strategy has been altered to pass an extra argument to
posts_download_loop, a lambda that is evaluated for each post, and
causes the loop to stop when it returns false.
2021-06-16 22:19:16 +02:00
Eduardo Kalinowski ac5d6e312a Report Profile renames even if --quiet is enabled (#1180)
While this isn't really an error, it requires user action, so the message
is printed to stderr even with --quiet, and repeated at the end when that
option is not enabled.

Resolves #1159.
2021-06-10 18:49:05 +02:00
Eduardo Kalinowski d142fb70b0 --latest-stamps, like --fast-update, but without the need to keep downloaded files (#1131)
Adds the --latest-stamps command line option, pointing to a file where
the latest time each profile was scraped. On the next run, only posts
newer than that time are downloaded.

Fixes #1122.
2021-05-27 18:22:33 +02:00
Eduardo Kalinowski 5345470ebf Add option --title-pattern for profile pics and covers (#1127) 2021-05-15 16:25:36 +02:00
Eduardo Kalinowski a2715cbe03 Show progress of profile scraping (#1114) 2021-04-29 21:14:23 +02:00
anokhovd fa19b95107 Add support for loading structures directly from python json objects (#1092) 2021-04-22 20:12:42 +02:00
A Bored Pervert 3837b642b0 Make iPhone endpoint query optional (#1076) 2021-04-22 20:06:35 +02:00
55 changed files with 4583 additions and 2217 deletions
+9 -2
View File
@@ -6,13 +6,15 @@ labels: "feature suggestion"
---
**Provide us a use case of the feature**
How could the user invoke the new function? Which problem would it solve? If new information is obtained, how would it be further processed?
How could the user invoke the new function? Which problem would it solve?
If new information is obtained, how would it be further processed?
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
How do you achieve the here-described goal without the feature you are requesting?
**If the feature request is accepted, would you be willing to submit a pull request?**
Yes / No
@@ -21,4 +23,9 @@ Yes / No
**Additional context**
Add any other context about the feature request here.
<!-- please also see https://instaloader.github.io/contributing.html for how to suggest a feature -->
<!--
Also see https://instaloader.github.io/contributing.html for how to suggest a feature.
Please understand that a feature suggestion issue that lacks the required information cannot be
processed.
-->
+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
+2 -1
View File
@@ -1 +1,2 @@
custom: "https://instaloader.github.io/contributing.html#donations"
github: [aandergr]
custom: "https://instaloader.github.io/sponsors.html"
+10 -4
View File
@@ -2,17 +2,23 @@
<!--
Thanks for your willingness to contribute to Instaloader! Please briefly
describe
describe:
- A motivation for this change, e.g.
- Fixes # .
- More general: What problem does the pull request solve?
- What problem does the pull request solve?
- The changes proposed in this pull request
- Keep the PR focused: avoid bundling independent fixes or refactorings.
- The completeness of this change
- Is it just a proof of concept?
- Is the documentation updated (if appropriate)?
- Do you consider it ready to be merged or is it a draft?
- How has it been tested?
- Do you consider it ready to be merged or is it a draft / proof of concept?
- Can we help you at some point?
- Further considerations
- Is it based on master (bugfix), upcoming/v4.X (new feature), or another pull request?
- Does it preserve backward compatibility and existing behavior?
-->
+33
View File
@@ -0,0 +1,33 @@
name: "Documentation"
on: [push, pull_request]
jobs:
docs:
name: "Documentation"
runs-on: ubuntu-latest
steps:
- name: "Checkout Instaloader Repository"
uses: actions/checkout@v7
with:
fetch-depth: 0 # needed for building docs
persist-credentials: false
- name: "Setup Python"
uses: actions/setup-python@v7
with:
python-version: 3.15
cache: 'pipenv'
- name: "Install Dependencies"
run: |
python -m pip install pipenv==2026.8.0
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: "Build Documentation"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
- name: "Deploy Documentation"
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: master
repository-name: instaloader/instaloader.github.io
folder: docs/_build/html
token: ${{ secrets.PAGES_DEPLOYMENT_TOKEN }}
+10 -11
View File
@@ -7,25 +7,24 @@ jobs:
name: PyLint and MyPy
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: [3.8, 3.9]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
steps:
- name: Checkout Instaloader Repository
uses: actions/checkout@v2
uses: actions/checkout@v7
with:
fetch-depth: 0 # needed for building docs
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v2
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: 'pipenv'
- name: Install Dependencies
run: |
python -m pip install pipenv==2020.11.15
pipenv sync --dev
python -m pip install pipenv==2026.8.0
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint
run: pipenv run pylint instaloader
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
- name: MyPy
run: pipenv run mypy -m instaloader
- name: Build Documentation
if: matrix.python-version == '3.8'
run: pipenv run make -C docs html SPHINXOPTS="-W -n"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader
+40
View File
@@ -0,0 +1,40 @@
name: "Publish Packages"
on:
push:
tags:
- v*
jobs:
publish:
name: "Publish Package"
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v7
with:
persist-credentials: false
- name: "Setup Python"
uses: actions/setup-python@v7
with:
python-version: 3.15
- name: "Get the tagged version"
id: get_version
env:
GITHUB_REF: ${{ github.ref }}
run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
shell: bash
- name: "Install Dependencies"
run: python -m pip install -U setuptools wheel build
- name: "Generate Python Package"
run: python -m build
- name: "Publish Distribution to PyPI"
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_PASSWORD }}
- name: "Publish Distribution to AUR"
run: deploy/arch/deploy.sh $VERSION_TAG
env:
VERSION_TAG: v${{ steps.get_version.outputs.VERSION }}
AUR_KEY: ${{ secrets.AUR_KEY }}
AUR_IV: ${{ secrets.AUR_IV }}
+7 -7
View File
@@ -2,7 +2,7 @@ name: Mark stale issues and pull requests
on:
schedule:
- cron: "10 14 * * *"
- cron: "55 7 * * *"
jobs:
stale:
@@ -11,24 +11,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v3
- uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: 'question'
stale-issue-message: 'There has been no activity on this question for an extended period of time. This issue will be closed after further 14 days of inactivity.'
stale-issue-label: 'stale'
exempt-issue-labels: 'leave open'
days-before-stale: 15
days-before-stale: 21
days-before-close: -1
remove-stale-when-updated: false
- uses: actions/stale@v1
- uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'There has been no activity on this issue for an extended period of time. This issue will be closed after further 14 days of inactivity.'
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-pr-label: 'stale'
exempt-issue-label: 'leave open'
exempt-pr-label: 'leave open'
days-before-stale: 90
exempt-issue-labels: 'leave open'
exempt-pr-labels: 'leave open'
days-before-stale: 189
days-before-close: 14
+12 -19
View File
@@ -8,41 +8,34 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout Instaloader repository
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v1
uses: actions/checkout@v7
with:
python-version: 3.9
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: 3.15
cache: 'pipenv'
architecture: x64
- name: Get the tagged version
id: get_version
env:
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
- name: Run custom python script for EXE creation
env:
VERSION_TAG: v${{ steps.get_version.outputs.VERSION }}
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')
id: create_release
uses: actions/create-release@v1
uses: softprops/action-gh-release@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
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
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
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
files: ./instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
+468 -405
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

-35
View File
@@ -1,35 +0,0 @@
dist: xenial
language: python
python:
- "3.6"
- "3.7"
install:
- pipenv sync --dev
script:
- pylint instaloader
- mypy -m instaloader
- make -C docs html SPHINXOPTS="-W -n"
addons:
ssh_known_hosts: aur.archlinux.org
deploy:
- provider: pypi
user: aandergr
on:
tags: true
python: 3.6
password:
secure: KBsATtXNnEzqr0X2ALdyx2zaYj2EUfK8YjP4jEOilBELpJ4evHYXVgRkn9CKXGsD7LKbWmR3gz2rr8NZ6U42pHw0fpQHizz91MpJSKbNY7RZZh4UVWMDh81izeglchWAF+m3Wdpxpo/GBDbrZIbRQFhPgB+kjSzJo3uiCS+yVXtGG+6csJHzd0WkJjsVHz+XSdxyOiLy/qFF3nmY6ZrVrgw6WbX2YLbx9je6DxK3YRrnBqgMgH7Ui2xWWbgJCk3KKpQTMiMKnTa9mdeMJAd26YMCz1gG/X7ck6VNly/6bs5+Zq7/bU0dSolhPBhNthyccYdS0IeGPdPF3YqatblJ4fLGTfHn8oJs3/T11yIW9pvOT3UftEynPOUX+en2Qnb/3WfgwUfVts8i8DAYgEFLGEzbF5XMGy4gwAD/bZ0JW0pmgAG2PZsdOokAt+ydlNj/uxq1GFW2doZbul6k4t03p1mik6itYscvuhKoEm8Yw6wr1P9aDvMR6FFpSu4gDt2XrKmRmWkSXxpNeCfZujTjyAnISxLVQGSY7BG5K0Z2HbDtqqNz9nj4WgNGzZmngiRofCevWR+vPIDUDW7NCX03csfcFS9RRllBO/BrL7NVUV+Qp9dfTQYp+8SqYLWgHtDoXdgyMREjJaAmbx2u/TYTY8bADZ5F3vGUpv2j5hP3NTU=
- provider: pages
skip-cleanup: true
github-token: $GITHUB_TOKEN
local-dir: docs/_build/html
repo: instaloader/instaloader.github.io
target-branch: master
on:
branch: master
python: 3.6
- provider: script
script: deploy/arch/deploy.sh $TRAVIS_TAG
on:
tags: true
python: 3.6
+1
View File
@@ -6,4 +6,5 @@ Instaloader is written by
- Alexander Graf (@aandergr)
- André Koch-Kramer (@Thammus)
- Lars Lindqvist (@e5150)
- Eduardo M Kalinowksi (@ekalin)
- ... and many more, see https://github.com/instaloader/instaloader/graphs/contributors
+9 -4
View File
@@ -6,12 +6,17 @@ verify_ssl = true
[dev-packages]
pylint = "*"
mypy = "*"
sphinx = "~=2.3"
sphinx = "*"
pyinstaller = "*"
pefile = "*"
pywin32-ctypes = "*"
psutil = "*"
typing-extensions = "*"
types-requests = "*"
sphinx-autodoc-typehints = "*"
sphinxcontrib-jquery = "*"
tomli = "*" # Needed for Pylint for "python_version < '3.11'"
dill = "*" # Needed for Pylint for "python_version < '3.11'"
pywin32-ctypes = "*" # Needed for pyinstaller on Windows
pefile = "*" # Needed for pyinstaller on Windows
[packages]
requests = "*"
browser-cookie3 = "*"
Generated
+1231 -283
View File
File diff suppressed because it is too large Load Diff
+62 -24
View File
@@ -2,11 +2,7 @@
.. badges-start
|travis| |pypi| |pyversion| |license| |aur| |contributors| |downloads|
.. |travis| image:: https://img.shields.io/travis/instaloader/instaloader/master.svg
:alt: Travis-CI Build Status
:target: https://travis-ci.org/instaloader/instaloader
|pypi| |pyversion| |license| |aur| |contributors| |downloads|
.. |pypi| image:: https://img.shields.io/pypi/v/instaloader.svg
:alt: Instaloader PyPI Project Page
@@ -57,7 +53,7 @@
::
instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--igtv]
[--stories] [--highlights] [--tagged] [--reels] [--igtv]
[--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | :stories | :feed | :saved
@@ -86,6 +82,16 @@ To later **update your local copy** of that profiles, you may run
If ``--fast-update`` is given, Instaloader stops when arriving at the
first already-downloaded picture.
Alternatively, you can use ``--latest-stamps`` to have Instaloader store
the time each profile was last downloaded and only download newer media:
::
instaloader --latest-stamps -- profile [profile ...]
With this option it's possible to move or delete downloaded media and still keep
the archive updated.
When updating profiles, Instaloader
automatically **detects profile name changes** and renames the target directory
accordingly.
@@ -104,6 +110,56 @@ already have a valid session cookie file.
`Instaloader Documentation <https://instaloader.github.io/basic-usage.html>`__
Contributing
------------
As an open source project, Instaloader heavily depends on the contributions from
its community. See
`contributing <https://instaloader.github.io/contributing.html>`__
for how you may help Instaloader to become an even greater tool.
Supporters
----------
.. current-sponsors-start
| Instaloader is proudly sponsored by
.. list-table::
:align: center
* - |socialapis|
* - `SocialAPIs <https://socialapis.io/?utm_source=github&utm_medium=sponsor&utm_campaign=instaloader>`__
|
.. list-table::
:align: center
* - `@rocketapi-io <https://github.com/rocketapi-io>`__
|
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
how you can sponsor the development of Instaloader!
.. |socialapis| image:: ./.sponsor-logos/socialapis.png
:alt: SocialAPIs
:target: https://socialapis.io/?utm_source=github&utm_medium=sponsor&utm_campaign=instaloader
:height: 96px
.. current-sponsors-end
It is a pleasure for us to share our Instaloader to the world, and we are proud
to have attracted such an active and motivating community, with so many users
who share their suggestions and ideas with us. Buying a community-sponsored beer
or coffee from time to time is very likely to further raise our passion for the
development of Instaloader.
| For Donations, we provide GitHub Sponsors page, a PayPal.Me link and a Bitcoin address.
| GitHub Sponsors: `Sponsor @aandergr on GitHub Sponsors <https://github.com/sponsors/aandergr>`__
| PayPal: `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
Disclaimer
----------
@@ -116,21 +172,3 @@ subsidiaries. This is an independent and unofficial project. Use at your own ris
Instaloader is licensed under an MIT license. Refer to ``LICENSE`` file for more information.
.. disclaimer-end
Contributing
------------
As an open source project, Instaloader heavily depends on the contributions from
its community. See
`contributing <https://instaloader.github.io/contributing.html>`__
for how you may help Instaloader to become an even greater tool.
It is a pleasure for us to share our Instaloader to the world, and we are proud
to have attracted such an active and motivating community, with so many users
who share their suggestions and ideas with us. Buying a community-sponsored beer
or coffee from time to time is very likely to further raise our passion for the
development of Instaloader.
| For Donations, we provide a PayPal.Me link and a Bitcoin address.
| PayPal: `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
+6 -2
View File
@@ -5,9 +5,13 @@ pkgbase = instaloader
url = https://instaloader.github.io/
arch = any
license = MIT
makedepends = python-build
makedepends = python-installer
makedepends = python-setuptools
depends = python>=3.6
depends = python-requests>=2.4
makedepends = python-wheel
depends = python
depends = python-requests
optdepends = python-browser-cookie3: Import session cookies from browser
options = !emptydirs
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
sha512sums = {{hash}}
+15 -5
View File
@@ -8,20 +8,30 @@ arch=('any')
url="https://instaloader.github.io/"
license=('MIT')
groups=()
depends=('python>=3.6' 'python-requests>=2.4')
makedepends=('python-setuptools')
depends=(
python
python-requests
)
makedepends=(
python-build
python-installer
python-setuptools
python-wheel
)
optdepends=(
'python-browser-cookie3: Import session cookies from browser'
)
options=('!emptydirs')
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
sha512sums=('{{hash}}')
build() {
cd "$srcdir/$pkgname-$pkgver"
python setup.py clean --all
python setup.py build
python -m build --wheel --no-isolation
}
package() {
cd "$srcdir/$pkgname-$pkgver"
install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE
python setup.py install --root "$pkgdir/" --skip-build
python -m installer --destdir="$pkgdir" dist/*.whl
}
+3 -1
View File
@@ -8,10 +8,12 @@ echo $VERSION | grep -qe "[abc]" && exit 0
cd $(dirname $0)
# decrypt and add ssh key
openssl aes-256-cbc -K $encrypted_354637631c28_key -iv $encrypted_354637631c28_iv -in id_rsa_AUR.enc -out /tmp/AUR_openssh -d
openssl aes-256-cbc -K $AUR_KEY -iv $AUR_IV -in id_rsa_AUR.enc -out /tmp/AUR_openssh -d
eval "$(ssh-agent -s)"
chmod 600 /tmp/AUR_openssh
ssh-add /tmp/AUR_openssh
mkdir -p ~/.ssh
ssh-keyscan -t rsa aur.archlinux.org >> ~/.ssh/known_hosts
# clone and modify AUR repo
git clone --depth 1 ssh://aur@aur.archlinux.org/instaloader.git
Binary file not shown.
+2 -2
View File
@@ -44,8 +44,8 @@ with open('__main__.py', 'w+') as f:
f.writelines(lines)
# install dependencies and invoke PyInstaller
commands = ["pip install pipenv==2020.11.15",
"pipenv sync --dev",
commands = ["pip install pipenv==2026.8.0",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
for command in commands:
+1
View File
@@ -0,0 +1 @@
../.sponsor-logos
+4 -3
View File
@@ -7,9 +7,10 @@ https://instaloader.github.io/
## Build
The documentation is created with [Sphinx](http://www.sphinx-doc.org/). To build it, use
The documentation is created with [Sphinx](http://www.sphinx-doc.org/). To build it, use (in the top-level directory
of the Instaloader source tree):
```
pip3 install -r requirements.txt
make html
pipenv sync --dev
make -C docs html
```
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -21,7 +21,7 @@
{% block header %}
<nav class="navbar navbar-light navbar-expand bg-light border-bottom">
<a class="navbar-brand" href="{{ pathto(master_doc) }}">
<img src="{{ pathto('_static/' + logo, 1) }}" width="30" height="30" class="d-inline-block align-top" alt="logo">
<img src="{{ pathto('_static/logo.png', 1) }}" width="30" height="30" class="d-inline-block align-top" alt="logo">
<span class="ml-3">Instaloader</span>
</a>
<ul class="navbar-nav mr-auto">
+81 -19
View File
@@ -39,6 +39,16 @@ To later **update your local copy** of that profiles, you may run
If :option:`--fast-update` is given, Instaloader stops when arriving at the
first already-downloaded picture.
Alternatively, you can use :option:`--latest-stamps` to have Instaloader store
the time each profile was last downloaded and only download newer media:
::
instaloader --latest-stamps -- profile [profile ...]
With this option it's possible to move or delete downloaded media and still keep
the archive updated.
When updating profiles, Instaloader
automatically **detects profile name changes** and renames the target directory
accordingly.
@@ -65,7 +75,7 @@ What to Download
Instaloader supports the following targets:
- ``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
finds it by its unique ID and renames the folder accordingly.
@@ -82,37 +92,41 @@ Instaloader supports the following targets:
- :option:`--tagged`
to **download posts where the user is tagged**, and
- :option:`--reels`
to **download Reels videos**.
- :option:`--igtv`
to **download IGTV videos**.
- ``"#hashtag"``
Posts with a certain **hashtag** (the quotes are usually necessary).
Posts with a certain **hashtag** (the quotes are usually necessary). Requires :ref:`login<login>`.
- ``%location id``
Posts tagged with a given location; the location ID is the numerical ID
Instagram labels a location with (e.g.
\https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/).
Requires :option:`--login`.
Requires :ref:`login<login>`.
.. versionadded:: 4.2
- ``:stories``
The currently-visible **stories** of your followees (requires
:option:`--login`).
:ref:`login<login>`).
- ``:feed``
Your **feed** (requires :option:`--login`).
Your **feed** (requires :ref:`login<login>`).
- ``:saved``
Posts which are marked as **saved** (requires :option:`--login`).
Posts which are marked as **saved** (requires :ref:`login<login>`).
- ``@profile``
All profiles that are followed by ``profile``, i.e. the *followees* of
``profile`` (requires :option:`--login`).
``profile`` (requires :ref:`login<login>`).
- ``-post``
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
@@ -130,7 +144,7 @@ downloads the pictures and videos and their captions. You can specify
- :option:`--geotags`
**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
:ref:`command-line-options`.
@@ -149,27 +163,37 @@ target. The default is ``--dirname-pattern={target}``. In the dirname
pattern, the token ``{target}`` is replaced by the target name, and
``{profile}`` is replaced by the owner of the post which is downloaded.
:option:`--filename-pattern` configures the path of the post's files relative
:option:`--filename-pattern` configures the path of the post and story's files relative
to the target directory that is specified with :option:`--dirname-pattern`.
The default is ``--filename-pattern={date_utc}_UTC``.
The tokens ``{target}`` and ``{profile}`` are replaced like in the
dirname pattern. The following tokens are defined for usage with
:option:`--filename-pattern`:
dirname pattern.
:option:`--title-pattern` is similar to :option:`--filename-pattern`, but for profile
pics, hashtag profile pics, and highlight covers. The default is
``{date_utc}_UTC_{typename}`` if :option:`--dirname-pattern` contains ``{target}`` or
``{profile}``, or ``{target}_{date_utc}_UTC_{typename}`` if it does not. Some tokens
are not supported for this option, see below for details.
The following tokens are defined for usage with
:option:`--filename-pattern` and :option:`--title-pattern`:
- ``{target}``
Target name (as given in Instaloader command line)
- ``{profile}`` (same as ``{owner_username}``)
Owner of the Post / StoryItem.
Owner of the Post / StoryItem / ProfilePic. For hashtag profile pics and
highlight covers, equivalent to ``{target}``.
- ``{owner_id}``
Unique integer ID of owner profile.
Unique integer ID of owner profile. For hashtag profile pics, equivalent to
``{target}``.
- ``{shortcode}``
Shortcode (identifier string).
Shortcode (identifier string). Not available for :option:`--title-pattern`.
- ``{mediaid}``
Integer representation of shortcode.
Integer representation of shortcode. Not available for :option:`--title-pattern`.
- ``{filename}``
Instagram's internal filename.
@@ -182,6 +206,10 @@ dirname pattern. The following tokens are defined for usage with
{date_utc:%Y-%m-%d_%H-%M-%S}
- ``{typename}``
Type of media being saved, such as GraphImage, GraphStoryVideo, profile_pic,
etc.
For example, encode the poster's profile name in the filenames with::
instaloader --filename-pattern={date_utc}_UTC_{profile} "#hashtag"
@@ -207,7 +235,7 @@ The filter string must be a
where the attributes from :class:`Post` or
: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`:
- :attr:`~Post.owner_username` (str), :attr:`~Post.owner_id` (int)
@@ -231,7 +259,7 @@ Id est, the following attributes can be used with both
As :option:`--post-filter`, the following attributes can be used additionally:
- :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::
instaloader --login=your_username --post-filter=viewer_has_liked :feed
@@ -283,13 +311,47 @@ the post's caption::
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
files. Say, you now also want to export the number of comments the Posts had
when they were downloaded::
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
+83 -10
View File
@@ -26,7 +26,7 @@ Targets
^^^^^^^
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
: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
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
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
default.
default. Requires :ref:`login<login>`.
.. option:: --no-captions
@@ -100,7 +100,6 @@ What to Download of each Post
Do not xz compress JSON files, rather create pretty formatted JSONs.
What to Download of each Profile
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -117,12 +116,12 @@ What to Download of each Profile
.. option:: --stories, -s
Also download stories of each profile that is downloaded. Requires
:option:`--login`.
:ref:`login<login>`.
.. option:: --highlights
Also download highlights of each profile that is downloaded. Requires
:option:`--login`.
:ref:`login<login>`.
.. versionadded:: 4.1
@@ -132,6 +131,12 @@ What to Download of each Profile
.. versionadded:: 4.1
.. option:: --reels
Also download Reels videos.
.. versionadded:: 4.14
.. option:: --igtv
Also download IGTV videos.
@@ -147,6 +152,23 @@ Which Posts to Download
This flag is recommended when you use Instaloader to update your personal
Instagram archive.
.. option:: --latest-stamps [STAMPSFILE]
Works similarly to :option:`--fast-update`, but instead of relying on already
downloaded media, the time each profile was downloaded is stored, and only
media newer than the last download is fetched. This allows updating your
personal Instagram archive while emptying the target directories.
Only works for media associated with a specific profile, and that is returned
in chronological order: profile posts, profile stories, profile IGTV posts
and profile tagged posts.
By default, the information is stored in
``~/.config/instaloader/latest-stamps.ini``, but you can specify an
alternative location.
.. versionadded:: 4.8
.. option:: --post-filter filter, --only-if filter
Expression that, if given, must evaluate to True for each post to be
@@ -167,6 +189,7 @@ Which Posts to Download
Do not attempt to download more than COUNT posts. Applies to
``#hashtag``, ``%location_id``, ``:feed``, and ``:saved``.
.. _login:
Login (Download Private Profiles)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -176,10 +199,34 @@ 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
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
Login name (profile name) for your Instagram account.
.. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME
Load Instagram session cookies from a browser instead of using Instaloader's own login mechanism.
This method requires the `browser_cookie3 <https://github.com/borisbabic/browser_cookie3>`__ library.
By default, cookies are loaded from the browser's main profile, but you can specify a custom cookie file using :option:`--cookiefile`.
Since :option:`--load-cookies` and :option:`--login` are alternative ways to acquire a session, they cannot be used together when initially acquiring a session.
Supported browsers: Arc, Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, Opera, Opera_GX, Safari, and Vivaldi.
Once logged in via browser cookies, Instaloader saves the session, allowing you to use :option:`--login` in subsequent runs.
.. versionadded:: 4.11
.. option:: --cookiefile COOKIE-FILE, -B COOKIE-FILE
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
.. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
Path for loading and storing session key file. Defaults to
@@ -206,12 +253,30 @@ How to Download
.. option:: --filename-pattern FILENAME_PATTERN
Prefix of filenames, relative to the directory given with
Prefix of filenames for posts and stories, relative to the directory given with
:option:`--dirname-pattern`. ``{profile}`` is replaced by the profile name,
``{target}`` is replaced by the target you specified, i.e. either ``:feed``,
``#hashtag`` or the profile name. Defaults to ``{date_utc}_UTC``.
See :ref:`filename-specification` for a list of supported tokens.
.. option:: --title-pattern TITLE_PATTERN
Prefix of filenames for profile pics, hashtag profile pics, and highlight
covers, relative to the directory given with :option:`--dirname-pattern`.
Defaults to ``{date_utc}_UTC_{typename}`` if :option:`--dirname-pattern`
contains ``{target}`` or ``{profile}``, otherwise defaults to
``{target}_{date_utc}_UTC_{typename}``.
See :ref:`filename-specification` for a list of supported tokens.
.. versionadded:: 4.8
.. option:: --sanitize-paths
Force sanitization of paths so that the resulting file and directory names
are valid on both Windows and Unix.
.. versionadded:: 4.9
.. option:: --resume-prefix prefix
For many targets, Instaloader is capable of resuming a previously-aborted
@@ -224,7 +289,8 @@ How to Download
- Profile posts,
- Profile IGTV posts (:option:`--igtv`),
- Profile tagged posts (:option:`--tagged`),
- Saved posts (``:saved``).
- Saved posts (``:saved``),
- Hashtags.
This feature is enabled by default for targets where it is supported;
:option:`--resume-prefix` only changes the name of the iterator files.
@@ -246,7 +312,7 @@ How to Download
.. option:: --user-agent USER_AGENT
User Agent to use for HTTP requests. Per default, Instaloader pretends being
Chrome/89 on Linux.
Chrome/142 on Linux.
.. option:: --max-connection-attempts N
@@ -274,6 +340,12 @@ How to Download
.. versionadded:: 4.7
.. option:: --no-iphone
Do not attempt to download iPhone version of images and videos.
.. versionadded:: 4.8
Miscellaneous Options
^^^^^^^^^^^^^^^^^^^^^
@@ -288,12 +360,13 @@ Miscellaneous Options
Read arguments from file `args.txt`, a shortcut to provide arguments from
file rather than command-line. This provides a convenient way to hide login
info from CLI, and can also be used to simplify management of long arguments.
You can provide more than one file at once, e.g.: ``+args1.txt +args2.txt``.
.. note::
Text file should separate arguments with line breaks.
args.txt example::
`args.txt` example::
--login=MYUSERNAME
--password=MYPASSWORD
+1 -1
View File
@@ -2,7 +2,7 @@ import instaloader
L = instaloader.Instaloader()
posts = instaloader.Hashtag.from_name(L.context, 'urbanphotography').get_posts()
posts = instaloader.Hashtag.from_name(L.context, 'urbanphotography').get_posts_resumable()
users = set()
@@ -14,8 +14,13 @@ def get_cookiefile():
default_cookiefile = {
"Windows": "~/AppData/Roaming/Mozilla/Firefox/Profiles/*/cookies.sqlite",
"Darwin": "~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite",
}.get(system(), "~/.mozilla/firefox/*/cookies.sqlite")
cookiefiles = glob(expanduser(default_cookiefile))
}.get(system(), None)
if default_cookiefile:
cookiefiles = glob(expanduser(default_cookiefile))
else:
# Check Firefox 147+ XDG path, then legacy
cookiefiles = glob(expanduser("~/.config/mozilla/firefox/*/cookies.sqlite")) or \
glob(expanduser("~/.mozilla/firefox/*/cookies.sqlite"))
if not cookiefiles:
raise SystemExit("No Firefox cookies.sqlite file found. Use -c COOKIEFILE.")
return cookiefiles[0]
@@ -3,7 +3,7 @@ import instaloader
L = instaloader.Instaloader()
posts = instaloader.Hashtag.from_name(L.context, "urbanphotography").get_posts()
posts = instaloader.Hashtag.from_name(L.context, "urbanphotography").get_posts_resumable()
SINCE = datetime(2020, 5, 10) # further from today, inclusive
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
+63 -354
View File
@@ -1,56 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Configuration file for the Sphinx documentation builder.
#
# Instaloader documentation build configuration file, created by
# sphinx-quickstart on Fri Aug 25 15:38:26 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import platform
import subprocess
import sys
import typing
sys.path.insert(0, os.path.abspath('..'))
import docs.sphinx_autodoc_typehints as sphinx_autodoc_typehints
# -- General configuration ------------------------------------------------
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
project = 'Instaloader'
copyright = '2023, Alexander Graf and Andre Koch-Kramer'
author = 'Alexander Graf and Andre Koch-Kramer'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.githubpages',
'sphinx.ext.intersphinx',
'sphinx.ext.extlinks'
"sphinx.ext.autodoc",
"sphinx.ext.extlinks",
"sphinx.ext.githubpages",
"sphinx.ext.intersphinx",
"sphinxcontrib.jquery",
]
autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members']
# autodoc options
autodoc_member_order = 'bysource'
autodoc_default_options = {
'show-inheritance': True,
'members': True,
'undoc-members': True,
}
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
'requests': ('https://requests.kennethreitz.org/en/master/', None)}
# extlinks options
nitpick_ignore = [('py:class', 'typing.Tuple')]
extlinks = {
'issue': (
'https://github.com/instaloader/instaloader/issues/%s',
'Issue #%s'
),
'example': (
'https://raw.githubusercontent.com/instaloader/instaloader/master/docs/codesnippets/%s',
'Example %s'
),
}
# intersphinx options
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
'requests': ('https://requests.readthedocs.io/en/latest/', None),
}
# other options
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# ignore TypeVar as it is currently unsupported by Sphinx
nitpick_ignore = [('py:class', 'instaloader.nodeiterator.T')]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
pygments_style = 'fruity'
html_theme = 'basic'
html_static_path = ['_static']
html_logo = "logo.png"
html_css_files = ['instaloaderdoc.css']
html_js_files = ['instaloaderdoc.js']
# get release number
current_release = subprocess.check_output(["git", "describe", "--abbrev=0"]).decode("ascii")[1:-1]
date_format = "%e %b %Y" if platform.system() != "Windows" else "%d %b %Y"
@@ -58,322 +86,3 @@ current_release_date = subprocess.check_output(
["git", "log", "-1", "--tags", "--format=%ad", "--date=format:"+date_format]).decode("ascii")[:-1]
html_context = {'current_release': current_release, 'current_release_date': current_release_date}
extlinks = {
'issue': (
'https://github.com/instaloader/instaloader/issues/%s',
'Issue #'
),
'example': (
'https://raw.githubusercontent.com/instaloader/instaloader/master/docs/codesnippets/%s',
'Example '
),
}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The encoding of source files.
#
# source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'Instaloader'
copyright = '2017-2020, Alexander Graf and Andre Koch-Kramer'
author = 'Alexander Graf and Andre Koch-Kramer'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
#version = '3.0'
# The full version, including alpha/beta/rc tags.
#release = '3.0rc0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = "en"
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#
# today = ''
#
# Else, today_fmt is used as the format for a strftime call.
#
# today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#
# default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#
# add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#
add_module_names = False
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#
# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'fruity'
# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
# keep_warnings = False
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'basic'
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
# The name for this set of Sphinx documents.
# "<project> v<release> documentation" by default.
#
# html_title = 'Instaloader v3.0rc0'
# A shorter title for the navigation bar. Default is the same as html_title.
#
# html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#
html_logo = "logo.png"
# The name of an image file (relative to this directory) to use as a favicon of
# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#
html_favicon = "favicon.ico"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#
# html_extra_path = []
# If not None, a 'Last updated on:' timestamp is inserted at every page
# bottom, using the given strftime format.
# The empty string is equivalent to '%b %d, %Y'.
#
# html_last_updated_fmt = None
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#
# html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#
#html_sidebars = {'**': ["relations.html", "links.html"] }
html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#
# html_additional_pages = {}
# If false, no module index is generated.
#
html_domain_indices = False
# If false, no index is generated.
#
html_use_index = True
# If true, the index is split into individual pages for each letter.
#
# html_split_index = False
# If true, links to the reST sources are added to the pages.
#
html_show_sourcelink = False
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#
html_show_sphinx = False
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#
html_show_copyright = False
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#
# html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
# html_file_suffix = None
# Language to be used for generating the HTML full-text search index.
# Sphinx supports the following languages:
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja'
# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh'
#
# html_search_language = 'en'
# A dictionary with options for the search language support, empty by default.
# 'ja' uses this config value.
# 'zh' user can custom change `jieba` dictionary path.
#
# html_search_options = {'type': 'default'}
# The name of a javascript file (relative to the configuration directory) that
# implements a search results scorer. If empty, the default will be used.
#
# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
htmlhelp_basename = 'Instaloaderdoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'Instaloader.tex', 'Instaloader Documentation',
'Alexander Graf, André Koch-Kramer', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#
# latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#
# latex_use_parts = False
# If true, show page references after internal links.
#
# latex_show_pagerefs = False
# If true, show URL addresses after external links.
#
# latex_show_urls = False
# Documents to append as an appendix to all manuals.
#
# latex_appendices = []
# It false, will not define \strong, \code, itleref, \crossref ... but only
# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added
# packages.
#
# latex_keep_old_macro_names = True
# If false, no module index is generated.
#
# latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'instaloader', 'Instaloader Documentation',
[author], 1)
]
# If true, show URL addresses after external links.
#
# man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'Instaloader', 'Instaloader Documentation',
author, 'Instaloader', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#
# texinfo_appendices = []
# If false, no module index is generated.
#
# texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#
# texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#
# texinfo_no_detailmenu = False
def setup(app):
typing.TYPE_CHECKING = True
app.add_stylesheet("instaloaderdoc.css")
app.connect('autodoc-process-signature', sphinx_autodoc_typehints.process_signature)
app.connect('autodoc-process-docstring', sphinx_autodoc_typehints.process_docstring)
+13 -17
View File
@@ -74,6 +74,14 @@ Improvements of the Instaloader source or its documentation can be proposed as a
- a bug fix that does require thorough testing before being released to a
final version.
- Keep Pull Requests focused: address a single problem or feature in a
self-contained change. Avoid bundling multiple independent fixes, refactorings,
or architectural changes into one pull request, as this makes it hard to
review, validate, or selectively merge them.
- Be mindful of backward compatibility: changes should preserve existing intended
behavior whenever possible.
- All Pull Requests are analyzed by `Pylint <https://www.pylint.org/>`__ for
error and syntax checking of the source and
`Mypy <https://github.com/python/mypy>`__ for type checking. You can run them
@@ -132,21 +140,9 @@ details. If you feel the need to do so, please create a "feature suggestion".
be implemented and the underlying problem could be solved. Also **describe
alternatives** that you have considered.
Donations
---------
Sponsoring
----------
.. donations-start
It is a pleasure for us to share our Instaloader to the world, and we are proud
to have attracted such an active and motivating community, with so many users
who share their suggestions and ideas with us. Buying a community-sponsored beer
or coffee from time to time is very likely to further raise our passion for the
development of Instaloader.
| For donations, we provide a PayPal.Me link and a Bitcoin address.
| `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
.. donations-end
.. (Discussion in :issue:`130`)
.. include:: sponsors.rst
:start-after: donations-start
:end-before: donations-end
+12 -9
View File
@@ -48,7 +48,7 @@ See :ref:`install` for more options on how to install Instaloader.
::
instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--igtv]
[--stories] [--highlights] [--tagged] [--reels] [--igtv]
[--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | %location_id |
:stories | :feed | :saved
@@ -78,13 +78,6 @@ Useful Links
- `Issue Tracker / Bug Tracker <https://github.com/instaloader/instaloader/issues>`__
- `Version History <https://github.com/instaloader/instaloader/releases>`__
Disclaimer
----------
.. include:: ../README.rst
:start-after: disclaimer-start
:end-before: disclaimer-end
Contributing
------------
@@ -92,10 +85,20 @@ As an open source project, Instaloader heavily depends on the contributions from
its community. See :ref:`contributing` for how you may help Instaloader to
become an even greater tool.
.. include:: contributing.rst
Supporters
----------
.. include:: sponsors.rst
:start-after: donations-start
:end-before: donations-end
Disclaimer
----------
.. include:: ../README.rst
:start-after: disclaimer-start
:end-before: disclaimer-end
..
* :ref:`genindex`
* :ref:`modindex`
+1 -1
View File
@@ -8,7 +8,7 @@ Install Instaloader
To **install Instaloader**,
#. Ensure that you have `Python <https://www.python.org/>`__, at least
version 3.6, and `pip <https://pypi.python.org/pypi/pip>`__
version 3.8, and `pip <https://pypi.python.org/pypi/pip>`__
installed.
#. Then, install Instaloader using::
+11 -1
View File
@@ -2,7 +2,7 @@ Exceptions
^^^^^^^^^^
.. module:: instaloader
:noindex:
:no-index:
.. highlight:: python
@@ -27,16 +27,26 @@ Exceptions
.. autoexception:: LoginRequiredException
.. autoexception:: LoginException
.. versionadded:: 4.12
.. autoexception:: TwoFactorAuthRequiredException
.. versionadded:: 4.2
.. versionchanged:: 4.12
Inherits LoginException
.. autoexception:: InvalidArgumentException
.. autoexception:: BadResponseException
.. autoexception:: BadCredentialsException
.. versionchanged:: 4.12
Inherits LoginException
.. autoexception:: PostChangedException
.. autoexception:: QueryReturnedNotFoundException
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader
:noindex:
:no-index:
.. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader
:noindex:
:no-index:
.. highlight:: python
+2 -2
View File
@@ -2,7 +2,7 @@ Resumable Iterations
^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader
:noindex:
:no-index:
.. highlight:: python
@@ -30,7 +30,7 @@ Iterator :class:`NodeIterator` and a context manager
It can be serialized and deserialized with :func:`save_structure_to_file`
and :func:`load_structure_from_file`, as well as with :mod:`json` and
:mod:`pickle` thanks to being a :func:`~collections.namedtuple`.
:mod:`pickle` thanks to being a :class:`~typing.NamedTuple`.
``resumable_iteration``
"""""""""""""""""""""""
+19 -1
View File
@@ -2,7 +2,7 @@ Instagram Structures
^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader
:noindex:
:no-index:
.. highlight:: python
@@ -71,12 +71,30 @@ TopSearchResults
.. versionadded:: 4.3
TitlePic
""""""""
.. autoclass:: TitlePic
:no-show-inheritance:
.. versionadded:: 4.8
Loading and Saving
""""""""""""""""""
:class:`Post`, :class:`StoryItem`, :class:`Profile`, :class:`Hashtag` and
:class:`FrozenNodeIterator` can be saved and loaded to/from JSON files.
.. autofunction:: load_structure
.. autofunction:: load_structure_from_file
.. autofunction:: get_json_structure
.. autofunction:: save_structure_to_file
LatestStamps
""""""""""""
.. autoclass:: LatestStamps
:no-show-inheritance:
-2
View File
@@ -1,2 +0,0 @@
requests
sphinx~=2.3
-251
View File
@@ -1,251 +0,0 @@
# sphinx_autodoc_typehints from Nov 20 2018 (version 1.5.2).
# https://github.com/agronholm/sphinx-autodoc-typehints
# (slightly modified to fix class links, maybe related to agronholm/sphinx-autodoc-typehints#38)
#
# This is the MIT license: http://www.opensource.org/licenses/mit-license.php
#
# Copyright (c) Alex Grönholm
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of this
# software and associated documentation files (the "Software"), to deal in the Software
# without restriction, including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
# to whom the Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or
# substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
# FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
import inspect
from typing import get_type_hints, TypeVar, Any, AnyStr, Generic, Union
from sphinx.util import logging
from sphinx.util.inspect import Signature
try:
from inspect import unwrap
except ImportError:
def unwrap(func, *, stop=None):
"""This is the inspect.unwrap() method copied from Python 3.5's standard library."""
if stop is None:
def _is_wrapper(f):
return hasattr(f, '__wrapped__')
else:
def _is_wrapper(f):
return hasattr(f, '__wrapped__') and not stop(f)
f = func # remember the original func for error reporting
memo = {id(f)} # Memoise by id to tolerate non-hashable objects
while _is_wrapper(func):
func = func.__wrapped__
id_func = id(func)
if id_func in memo:
raise ValueError('wrapper loop when unwrapping {!r}'.format(f))
memo.add(id_func)
return func
logger = logging.getLogger(__name__)
def format_annotation(annotation):
if inspect.isclass(annotation) and annotation.__module__ == 'builtins':
if annotation.__qualname__ == 'NoneType':
return '``None``'
else:
return ':py:class:`{}`'.format(annotation.__qualname__)
annotation_cls = annotation if inspect.isclass(annotation) else type(annotation)
class_name = None
if annotation_cls.__module__ == 'typing':
params = None
prefix = ':py:class:'
module = 'typing'
extra = ''
if inspect.isclass(getattr(annotation, '__origin__', None)):
annotation_cls = annotation.__origin__
try:
if Generic in annotation_cls.mro():
module = annotation_cls.__module__
except TypeError:
pass # annotation_cls was either the "type" object or typing.Type
if annotation is Any:
return ':py:data:`~typing.Any`'
elif annotation is AnyStr:
return ':py:data:`~typing.AnyStr`'
elif isinstance(annotation, TypeVar):
return '\\%r' % annotation
elif (annotation is Union or getattr(annotation, '__origin__', None) is Union or
hasattr(annotation, '__union_params__')):
prefix = ':py:data:'
class_name = 'Union'
if hasattr(annotation, '__union_params__'):
params = annotation.__union_params__
elif hasattr(annotation, '__args__'):
params = annotation.__args__
if params and len(params) == 2 and (hasattr(params[1], '__qualname__') and
params[1].__qualname__ == 'NoneType'):
class_name = 'Optional'
params = (params[0],)
elif annotation_cls.__qualname__ == 'Tuple' and hasattr(annotation, '__tuple_params__'):
params = annotation.__tuple_params__
if annotation.__tuple_use_ellipsis__:
params += (Ellipsis,)
elif annotation_cls.__qualname__ == 'Callable':
prefix = ':py:data:'
arg_annotations = result_annotation = None
if hasattr(annotation, '__result__'):
arg_annotations = annotation.__args__
result_annotation = annotation.__result__
elif getattr(annotation, '__args__', None):
arg_annotations = annotation.__args__[:-1]
result_annotation = annotation.__args__[-1]
if arg_annotations in (Ellipsis, (Ellipsis,)):
params = [Ellipsis, result_annotation]
elif arg_annotations is not None:
params = [
'\\[{}]'.format(
', '.join(format_annotation(param) for param in arg_annotations)),
result_annotation
]
elif hasattr(annotation, 'type_var'):
# Type alias
class_name = annotation.name
params = (annotation.type_var,)
elif getattr(annotation, '__args__', None) is not None:
params = annotation.__args__
elif hasattr(annotation, '__parameters__'):
params = annotation.__parameters__
if params:
extra = '\\[{}]'.format(', '.join(format_annotation(param) for param in params))
if not class_name:
class_name = annotation_cls.__qualname__.title()
return '{}`~{}.{}`{}'.format(prefix, module, class_name, extra)
elif annotation is Ellipsis:
return '...'
elif inspect.isclass(annotation) or inspect.isclass(getattr(annotation, '__origin__', None)):
if not inspect.isclass(annotation):
annotation_cls = annotation.__origin__
extra = ''
if Generic in annotation_cls.mro():
params = (getattr(annotation, '__parameters__', None) or
getattr(annotation, '__args__', None))
extra = '\\[{}]'.format(', '.join(format_annotation(param) for param in params))
module = annotation.__module__.split('.')[0] # hack to 'fix' class linking for Instaloader project
return ':py:class:`~{}.{}`{}'.format(module, annotation_cls.__qualname__,
extra)
return str(annotation)
def process_signature(app, what: str, name: str, obj, options, signature, return_annotation):
if not callable(obj):
return
if what in ('class', 'exception'):
obj = getattr(obj, '__init__', getattr(obj, '__new__', None))
if not getattr(obj, '__annotations__', None):
return
obj = unwrap(obj)
signature = Signature(obj)
parameters = [
param.replace(annotation=inspect.Parameter.empty)
for param in signature.signature.parameters.values()
]
if parameters:
if what in ('class', 'exception'):
del parameters[0]
elif what == 'method':
outer = inspect.getmodule(obj)
for clsname in obj.__qualname__.split('.')[:-1]:
outer = getattr(outer, clsname)
method_name = obj.__name__
if method_name.startswith("__") and not method_name.endswith("__"):
# If the method starts with double underscore (dunder)
# Python applies mangling so we need to prepend the class name.
# This doesn't happen if it always ends with double underscore.
class_name = obj.__qualname__.split('.')[-2]
method_name = "_{c}{m}".format(c=class_name, m=method_name)
method_object = outer.__dict__[method_name]
if not isinstance(method_object, (classmethod, staticmethod)):
del parameters[0]
signature.signature = signature.signature.replace(
parameters=parameters,
return_annotation=inspect.Signature.empty)
return signature.format_args().replace('\\', '\\\\'), None
def process_docstring(app, what, name, obj, options, lines):
if isinstance(obj, property):
obj = obj.fget
if callable(obj):
if what in ('class', 'exception'):
obj = getattr(obj, '__init__')
obj = unwrap(obj)
try:
type_hints = get_type_hints(obj)
except (AttributeError, TypeError):
# Introspecting a slot wrapper will raise TypeError
return
except NameError as exc:
logger.warning('Cannot resolve forward reference in type annotations of "%s": %s',
name, exc)
type_hints = obj.__annotations__
for argname, annotation in type_hints.items():
if argname.endswith('_'):
argname = '{}\\_'.format(argname[:-1])
formatted_annotation = format_annotation(annotation)
if argname == 'return':
if what in ('class', 'exception'):
# Don't add return type None from __init__()
continue
insert_index = len(lines)
for i, line in enumerate(lines):
if line.startswith(':rtype:'):
insert_index = None
break
elif line.startswith(':return:') or line.startswith(':returns:'):
insert_index = i
if insert_index is not None:
if insert_index == len(lines):
# Ensure that :rtype: doesn't get joined with a paragraph of text, which
# prevents it being interpreted.
lines.append('')
insert_index += 1
lines.insert(insert_index, ':rtype: {}'.format(formatted_annotation))
else:
searchfor = ':param {}:'.format(argname)
for i, line in enumerate(lines):
if line.startswith(searchfor):
lines.insert(i, ':type {}: {}'.format(argname, formatted_annotation))
break
+23
View File
@@ -0,0 +1,23 @@
.. _sponsors:
Sponsoring the development of Instaloader
=========================================
.. donations-start
.. include:: ../README.rst
:start-after: current-sponsors-start
:end-before: current-sponsors-end
It is a pleasure for us to share our Instaloader to the world, and we are proud
to have attracted such an active and motivating community, with so many users
who share their suggestions and ideas with us. Buying a community-sponsored beer
or coffee from time to time is very likely to further raise our passion for the
development of Instaloader.
| For Donations, we provide GitHub Sponsors page, a PayPal.Me link and a Bitcoin address.
| GitHub Sponsors: `Sponsor @aandergr on GitHub Sponsors <https://github.com/sponsors/aandergr>`__
| PayPal: `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
.. donations-end
+3 -2
View File
@@ -33,7 +33,7 @@ happen under normal conditions, consider adjusting the
There have been observations that services, that in their nature offer
promiscuous IP addresses, such as cloud, VPN and public proxy services, might be
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
:class:`instaloader.RateController`.
@@ -62,7 +62,8 @@ Two-Factor-Authentication. It also supports handling the *checkpoint challenge*,
issued when Instagram suspects authentication activity on your account, by
pointing the user to an URL to be opened in a browser.
Nevertheless, in :issue:`92` and :issue:`615` users reported problems with
Nevertheless, in :issue:`92`, :issue:`615`, :issue:`1150` and :issue:`1217`,
users reported problems with
logging in. We recommend to always keep the session file which Instaloader
creates when using :option:`--login`. If a session file is present,
:option:`--login` does not make use of the failure-prone login procedure.
+24 -6
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.7.3'
__version__ = '4.15.4'
try:
@@ -13,8 +13,26 @@ else:
win_unicode_console.enable()
from .exceptions import *
from .instaloader import Instaloader
from .instaloadercontext import InstaloaderContext, RateController
from .nodeiterator import NodeIterator, FrozenNodeIterator, resumable_iteration
from .structures import (Hashtag, Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation,
Profile, Story, StoryItem, TopSearchResults, load_structure_from_file, save_structure_to_file)
from .instaloader import Instaloader as Instaloader
from .instaloadercontext import (InstaloaderContext as InstaloaderContext,
RateController as RateController)
from .lateststamps import LatestStamps as LatestStamps
from .nodeiterator import (NodeIterator as NodeIterator,
FrozenNodeIterator as FrozenNodeIterator,
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)
+209 -60
View File
@@ -6,22 +6,38 @@ import os
import re
import sys
from argparse import ArgumentParser, ArgumentTypeError, SUPPRESS
from enum import IntEnum
from typing import List, Optional
from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException,
InvalidArgumentException, Post, Profile, ProfileNotExistsException, StoryItem,
InvalidArgumentException, LoginException, Post, Profile, ProfileNotExistsException, StoryItem,
TwoFactorAuthRequiredException, __version__, load_structure_from_file)
from .instaloader import get_default_session_filename
from .instaloader import (get_default_session_filename, get_default_stamps_filename)
from .instaloadercontext import default_user_agent
from .lateststamps import LatestStamps
try:
import browser_cookie3
bc3_library = True
except ImportError:
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():
# NOTE: duplicated in README.rst and docs/index.rst
argv0 = os.path.basename(sys.argv[0])
argv0 = "instaloader" if argv0 == "__main__.py" else argv0
return """
{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}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved
{0} --help""".format(argv0, len(argv0), '')
@@ -44,7 +60,6 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
class TransformFilterAst(ast.NodeTransformer):
def visit_Name(self, node: ast.Name):
# pylint:disable=no-self-use
if not isinstance(node.ctx, ast.Load):
raise InvalidArgumentException("Invalid filter: Modifying variables ({}) not allowed.".format(node.id))
if node.id == "datetime":
@@ -67,6 +82,53 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
return filterfunc
def get_cookies_from_instagram(domain: str, browser: str, cookie_file: Optional[str] = None):
supported_browsers = {
"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_gx": browser_cookie3.opera_gx,
"safari": browser_cookie3.safari,
"vivaldi": browser_cookie3.vivaldi,
}
if browser not in supported_browsers:
raise InvalidArgumentException("Loading cookies from the specified browser failed\n"
"Supported browsers are Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, "
"Opera, Opera_GX, Safari and Vivaldi")
cookies = {}
browser_cookies = list(supported_browsers[browser](cookie_file=cookie_file))
for cookie in browser_cookies:
if domain in cookie.domain:
cookies[cookie.name] = cookie.value
if cookies:
print(f"Cookies loaded successfully from {browser}")
else:
raise LoginException(f"No cookies found for Instagram in {browser}, "
f"Are you logged in successfully in {browser}?")
return cookies
def import_session(browser: str, instaloader: Instaloader, cookiefile: Optional[str]):
cookie = get_cookies_from_instagram('instagram', browser, cookiefile)
if cookie is not None:
instaloader.context.update_cookies(cookie)
username = instaloader.test_login()
if not username:
raise LoginException(f"Not logged in. Are you logged in successfully in {browser}?")
instaloader.context.username = username
print(f"{username} has been successfully logged in.")
print(f"Next time use --login={username} to reuse the same session.")
def _main(instaloader: Instaloader, targetlist: List[str],
username: Optional[str] = None, password: Optional[str] = None,
sessionfile: Optional[str] = None,
@@ -74,10 +136,14 @@ def _main(instaloader: Instaloader, targetlist: List[str],
download_stories: bool = False,
download_highlights: bool = False,
download_tagged: bool = False,
download_reels: bool = False,
download_igtv: bool = False,
fast_update: bool = False,
latest_stamps_file: Optional[str] = None,
max_count: Optional[int] = None, post_filter_str: Optional[str] = None,
storyitem_filter_str: Optional[str] = None) -> None:
storyitem_filter_str: Optional[str] = None,
browser: Optional[str] = None,
cookiefile: Optional[str] = None) -> ExitCode:
"""Download set of profiles, hashtags etc. and handle logging in and session files if desired."""
# Parse and generate filter function
post_filter = None
@@ -88,6 +154,15 @@ def _main(instaloader: Instaloader, targetlist: List[str],
if storyitem_filter_str is not None:
storyitem_filter = filterstr_to_filterfunc(storyitem_filter_str, StoryItem)
instaloader.context.log('Only download storyitems with property "{}".'.format(storyitem_filter_str))
latest_stamps = None
if latest_stamps_file is not None:
latest_stamps = LatestStamps(latest_stamps_file)
instaloader.context.log(f"Using latest stamps from {latest_stamps_file}.")
# load cookies if browser is not None
if browser and bc3_library:
import_session(browser.lower(), instaloader, cookiefile)
elif browser and not bc3_library:
raise InvalidArgumentException("browser_cookie3 library is needed to load cookies from browsers")
# Login, if desired
if username is not None:
if not re.match(r"^[A-Za-z0-9._]+$", username):
@@ -103,6 +178,10 @@ def _main(instaloader: Instaloader, targetlist: List[str],
try:
instaloader.login(username, password)
except TwoFactorAuthRequiredException:
# https://github.com/instaloader/instaloader/issues/1217
instaloader.context.error("Warning: There have been reports of 2FA currently not working. "
"Consider importing session cookies from your browser with "
"--load-cookies.")
while True:
try:
code = input("Enter 2FA verification code: ")
@@ -112,14 +191,19 @@ def _main(instaloader: Instaloader, targetlist: List[str],
print(err, file=sys.stderr)
pass
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)
# since 4.2.9 login is required for geotags
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)
profiles = set()
anonymous_retry_profiles = set()
exit_code = ExitCode.SUCCESS
try:
# Generate set of profiles, already downloading non-profile targets
for target in targetlist:
@@ -172,11 +256,13 @@ def _main(instaloader: Instaloader, targetlist: List[str],
instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count,
post_filter=post_filter)
elif re.match(r"^[A-Za-z0-9._]+$", target):
download_profile_content = download_posts or download_tagged or download_reels or download_igtv
try:
profile = instaloader.check_profile_id(target)
profile = instaloader.check_profile_id(target, latest_stamps)
if instaloader.context.is_logged_in and profile.has_blocked_viewer:
if download_profile_pic or ((download_posts or download_tagged or download_igtv)
and not profile.is_private):
if download_profile_pic or (
download_profile_content and not profile.is_private
):
raise ProfileNotExistsException("{} blocked you; But we download her anonymously."
.format(target))
else:
@@ -186,13 +272,13 @@ def _main(instaloader: Instaloader, targetlist: List[str],
except ProfileNotExistsException as err:
# 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.
if instaloader.context.is_logged_in and (download_profile_pic or download_posts or
download_tagged or download_igtv):
if instaloader.context.is_logged_in and (download_profile_pic or download_profile_content):
instaloader.context.log(err)
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
with instaloader.anonymous_copy() as anonymous_loader:
with instaloader.context.error_catcher():
anonymous_retry_profiles.add(anonymous_loader.check_profile_id(target))
anonymous_retry_profiles.add(anonymous_loader.check_profile_id(target,
latest_stamps))
instaloader.context.error("Warning: {} will be downloaded anonymously (\"{}\")."
.format(target, err))
else:
@@ -207,23 +293,44 @@ def _main(instaloader: Instaloader, targetlist: List[str],
if len(profiles) > 1:
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
' '.join([p.username for p in profiles])))
if profiles and (download_profile_pic or download_posts) and not instaloader.context.is_logged_in:
instaloader.context.log("Hint: Use --login to download higher-quality versions of pictures.")
instaloader.download_profiles(profiles,
download_profile_pic, download_posts, download_tagged, download_igtv,
download_highlights, download_stories,
fast_update, post_filter, storyitem_filter)
if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \
not instaloader.context.is_logged_in:
instaloader.context.log("Hint: Login to download higher-quality versions of pictures.")
instaloader.download_profiles(
profiles,
download_profile_pic,
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:
instaloader.context.log("Downloading anonymously: {}"
.format(' '.join([p.username for p in anonymous_retry_profiles])))
with instaloader.anonymous_copy() as anonymous_loader:
anonymous_loader.download_profiles(anonymous_retry_profiles,
download_profile_pic, download_posts, download_tagged, download_igtv,
fast_update=fast_update, post_filter=post_filter)
anonymous_loader.download_profiles(
anonymous_retry_profiles,
download_profile_pic,
download_posts,
download_tagged,
download_igtv,
fast_update=fast_update,
post_filter=post_filter,
latest_stamps=latest_stamps,
reels=download_reels
)
except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr)
exit_code = ExitCode.USER_ABORTED
except AbortDownloadException as exc:
print("\nDownload aborted: {}.".format(exc), file=sys.stderr)
exit_code = ExitCode.DOWNLOAD_ABORTED
# Save session if it is useful
if instaloader.context.is_logged_in:
instaloader.save_session_to_file(sessionfile)
@@ -235,6 +342,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
else:
# Instaloader did not do anything
instaloader.context.log("usage:" + usage_string())
exit_code = ExitCode.INIT_FAILURE
return exit_code
def main():
@@ -250,17 +359,17 @@ def main():
help="Download profile. If an already-downloaded profile has been renamed, Instaloader "
"automatically finds it by its unique ID and renames the folder likewise.")
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.")
g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.")
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",
help="Download pictures from your feed. Requires --login.")
help="Download pictures from your feed. Requires login.")
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",
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",
help="Download the post with the given shortcode")
g_targets.add_argument('_json', nargs='*', metavar="filename.json[.xz]",
@@ -291,11 +400,11 @@ def main():
help='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 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',
help='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 default.')
'server for each post, which is why it is disabled by default. Requires login.')
g_post.add_argument('--no-captions', action='store_true',
help='Do not create txt files.')
g_post.add_argument('--post-metadata-txt', action='append',
@@ -309,13 +418,15 @@ def main():
g_post.add_argument('--no-compress-json', action='store_true',
help='Do not xz compress JSON files, rather create pretty formatted JSONs.')
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',
help=SUPPRESS)
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',
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',
help='Also download IGTV videos.')
@@ -324,7 +435,10 @@ def main():
g_cond.add_argument('-F', '--fast-update', action='store_true',
help='For each target, stop when encountering the first already-downloaded picture. This '
'flag is recommended when you use Instaloader to update your personal Instagram archive.')
g_cond.add_argument('--latest-stamps', nargs='?', metavar='STAMPSFILE', const=get_default_stamps_filename(),
help='Store the timestamps of latest media scraped for each profile. This allows updating '
'your personal Instagram archive even if you delete the destination directories. '
'If STAMPSFILE is not provided, defaults to ' + get_default_stamps_filename())
g_cond.add_argument('--post-filter', '--only-if', metavar='filter',
help='Expression that, if given, must evaluate to True for each post to be downloaded. Must be '
'a syntactically valid python expression. Variables are evaluated to '
@@ -342,9 +456,14 @@ def main():
'Instaloader can login to Instagram. This allows downloading private profiles. '
'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 '
'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',
help='Login name (profile name) for your Instagram account.')
g_login.add_argument('-b', '--load-cookies', metavar='BROWSER-NAME',
help='Browser name to load cookies from Instagram')
g_login.add_argument('-B', '--cookiefile', metavar='COOKIE-FILE',
help='Cookie file of a profile to load cookies')
g_login.add_argument('-f', '--sessionfile',
help='Path for loading and storing session key file. '
'Defaults to ' + get_default_session_filename("<login_name>"))
@@ -359,13 +478,21 @@ def main():
'{target} is replaced by the target you specified, i.e. either :feed, #hashtag or the '
'profile name. Defaults to \'{target}\'.')
g_how.add_argument('--filename-pattern',
help='Prefix of filenames, relative to the directory given with '
help='Prefix of filenames for posts and stories, relative to the directory given with '
'--dirname-pattern. {profile} is replaced by the profile name,'
'{target} is replaced by the target you specified, i.e. either :feed'
'#hashtag or the profile name. Defaults to \'{date_utc}_UTC\'')
g_how.add_argument('--title-pattern',
help='Prefix of filenames for profile pics, hashtag profile pics, and highlight covers. '
'Defaults to \'{date_utc}_UTC_{typename}\' if --dirname-pattern contains \'{target}\' '
'or \'{dirname}\', or if --dirname-pattern is not specified. Otherwise defaults to '
'\'{target}_{date_utc}_UTC_{typename}\'.')
g_how.add_argument('--resume-prefix', metavar='PREFIX',
help='Prefix for filenames that are used to save the information to resume an interrupted '
'download.')
g_how.add_argument('--sanitize-paths', action='store_true',
help='Sanitize paths so that the resulting file and directory names are valid on both '
'Windows and Unix.')
g_how.add_argument('--no-resume', action='store_true',
help='Do not resume a previously-aborted download iteration, and do not save such information '
'when interrupted.')
@@ -383,6 +510,8 @@ def main():
g_how.add_argument('--abort-on', type=http_status_code_list, metavar="STATUS_CODES",
help='Comma-separated list of HTTP status codes that cause Instaloader to abort, bypassing all '
'retry logic.')
g_how.add_argument('--no-iphone', action='store_true',
help='Do not attempt to download iPhone version of images and videos.')
g_misc = parser.add_argument_group('Miscellaneous Options')
g_misc.add_argument('-q', '--quiet', action='store_true',
@@ -395,15 +524,15 @@ def main():
args = parser.parse_args()
try:
if args.login is None and (args.stories or args.stories_only):
print("--login=USERNAME required to download stories.", file=sys.stderr)
if (args.login is None and args.load_cookies is None) and (args.stories or args.stories_only):
print("Login is required to download stories.", file=sys.stderr)
args.stories = False
if args.stories_only:
raise SystemExit(1)
raise InvalidArgumentException()
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 "
"eventually --post-filter=viewer_has_liked.")
raise InvalidArgumentException(":feed-all and :feed-liked were removed. Use :feed as target and "
"eventually --post-filter=viewer_has_liked.")
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
@@ -413,15 +542,18 @@ def main():
post_metadata_txt_pattern = ''
storyitem_metadata_txt_pattern = ''
else:
raise SystemExit("--no-captions and --post-metadata-txt or --storyitem-metadata-txt given; "
"That contradicts.")
raise InvalidArgumentException("--no-captions and --post-metadata-txt or --storyitem-metadata-txt "
"given; That contradicts.")
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
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:
raise InvalidArgumentException('--load-cookies and --login cannot be used together.')
# Determine what to download
download_profile_pic = not args.no_profile_pic or args.profile_pic_only
@@ -442,25 +574,42 @@ def main():
resume_prefix=resume_prefix,
check_resume_bbd=not args.use_aged_resume_files,
slide=args.slide,
fatal_status_codes=args.abort_on)
_main(loader,
args.profile,
username=args.login.lower() if args.login is not None else None,
password=args.password,
sessionfile=args.sessionfile,
download_profile_pic=download_profile_pic,
download_posts=download_posts,
download_stories=download_stories,
download_highlights=args.highlights,
download_tagged=args.tagged,
download_igtv=args.igtv,
fast_update=args.fast_update,
max_count=int(args.count) if args.count is not None else None,
post_filter_str=args.post_filter,
storyitem_filter_str=args.storyitem_filter)
fatal_status_codes=args.abort_on,
iphone_support=not args.no_iphone,
title_pattern=args.title_pattern,
sanitize_paths=args.sanitize_paths)
exit_code = _main(loader,
args.profile,
username=args.login.lower() if args.login is not None else None,
password=args.password,
sessionfile=args.sessionfile,
download_profile_pic=download_profile_pic,
download_posts=download_posts,
download_stories=download_stories,
download_highlights=args.highlights,
download_tagged=args.tagged,
download_reels=args.reels,
download_igtv=args.igtv,
fast_update=args.fast_update,
latest_stamps_file=args.latest_stamps,
max_count=int(args.count) if args.count is not None else None,
post_filter_str=args.post_filter,
storyitem_filter_str=args.storyitem_filter,
browser=args.load_cookies,
cookiefile=args.cookiefile)
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:
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__":
+8 -2
View File
@@ -33,7 +33,11 @@ class LoginRequiredException(InstaloaderException):
pass
class TwoFactorAuthRequiredException(InstaloaderException):
class LoginException(InstaloaderException):
pass
class TwoFactorAuthRequiredException(LoginException):
pass
@@ -45,7 +49,7 @@ class BadResponseException(InstaloaderException):
pass
class BadCredentialsException(InstaloaderException):
class BadCredentialsException(LoginException):
pass
@@ -65,6 +69,8 @@ class QueryReturnedNotFoundException(ConnectionException):
class TooManyRequestsException(ConnectionException):
pass
class IPhoneSupportDisabledException(InstaloaderException):
pass
class AbortDownloadException(Exception):
"""
+416 -130
View File
@@ -10,7 +10,6 @@ import tempfile
from contextlib import contextmanager, suppress
from datetime import datetime, timezone
from functools import wraps
from hashlib import md5
from io import BytesIO
from pathlib import Path
from typing import Any, Callable, IO, Iterator, List, Optional, Set, Union, cast
@@ -21,23 +20,30 @@ import urllib3 # type: ignore
from .exceptions import *
from .instaloadercontext import InstaloaderContext, RateController
from .lateststamps import LatestStamps
from .nodeiterator import NodeIterator, resumable_iteration
from .sectioniterator import SectionIterator
from .structures import (Hashtag, Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem,
load_structure_from_file, save_structure_to_file, PostSidecarNode)
load_structure_from_file, save_structure_to_file, PostSidecarNode, TitlePic)
def _get_config_dir() -> str:
if platform.system() == "Windows":
# on Windows, use %LOCALAPPDATA%\Instaloader
localappdata = os.getenv("LOCALAPPDATA")
if localappdata is not None:
return os.path.join(localappdata, "Instaloader")
# legacy fallback - store in temp dir if %LOCALAPPDATA% is not set
return os.path.join(tempfile.gettempdir(), ".instaloader-" + getpass.getuser())
# on Unix, use ~/.config/instaloader
return os.path.join(os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "instaloader")
def get_default_session_filename(username: str) -> str:
"""Returns default session filename for given username."""
configdir = _get_config_dir()
sessionfilename = "session-{}".format(username)
if platform.system() == "Windows":
# on Windows, use %LOCALAPPDATA%\Instaloader\session-USERNAME
localappdata = os.getenv("LOCALAPPDATA")
if localappdata is not None:
return os.path.join(localappdata, "Instaloader", sessionfilename)
# legacy fallback - store in temp dir if %LOCALAPPDATA% is not set
return os.path.join(tempfile.gettempdir(), ".instaloader-" + getpass.getuser(), sessionfilename)
# on Unix, use ~/.config/instaloader/session-USERNAME
return os.path.join(os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "instaloader", sessionfilename)
return os.path.join(configdir, sessionfilename)
def get_legacy_session_filename(username: str) -> str:
@@ -47,6 +53,17 @@ def get_legacy_session_filename(username: str) -> str:
return filename.lower()
def get_default_stamps_filename() -> str:
"""
Returns default filename for latest stamps database.
.. versionadded:: 4.8
"""
configdir = _get_config_dir()
return os.path.join(configdir, "latest-stamps.ini")
def format_string_contains_key(format_string: str, key: str) -> bool:
# pylint:disable=unused-variable
for literal_text, field_name, format_spec, conversion in string.Formatter().parse(format_string):
@@ -60,13 +77,13 @@ def _requires_login(func: Callable) -> Callable:
@wraps(func)
def call(instaloader, *args, **kwargs):
if not instaloader.context.is_logged_in:
raise LoginRequiredException("--login=USERNAME required.")
raise LoginRequiredException("Login required.")
return func(instaloader, *args, **kwargs)
return call
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.
@@ -102,7 +119,7 @@ class _ArbitraryItemFormatter(string.Formatter):
def get_value(self, key, args, kwargs):
"""Override to substitute {ATTRIBUTE} by attributes of our _item."""
if key == 'filename' and isinstance(self._item, (Post, StoryItem, PostSidecarNode)):
if key == 'filename' and isinstance(self._item, (Post, StoryItem, PostSidecarNode, TitlePic)):
return "{filename}"
if hasattr(self._item, key):
return getattr(self._item, key)
@@ -120,20 +137,38 @@ class _ArbitraryItemFormatter(string.Formatter):
class _PostPathFormatter(_ArbitraryItemFormatter):
RESERVED: set = {'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'}
def __init__(self, item: Any, force_windows_path: bool = False):
super().__init__(item)
self.force_windows_path = force_windows_path
def get_value(self, key, args, kwargs):
ret = super().get_value(key, args, kwargs)
if not isinstance(ret, str):
return ret
return self.sanitize_path(ret)
return self.sanitize_path(ret, self.force_windows_path)
@staticmethod
def sanitize_path(ret: str) -> str:
def sanitize_path(ret: str, force_windows_path: bool = False) -> str:
"""Replaces '/' with similar looking Division Slash and some other illegal filename characters on Windows."""
ret = ret.replace('/', '\u2215')
if platform.system() == 'Windows':
if ret.startswith('.'):
ret = ret.replace('.', '\u2024', 1)
if force_windows_path or platform.system() == 'Windows':
ret = ret.replace(':', '\uff1a').replace('<', '\ufe64').replace('>', '\ufe65').replace('\"', '\uff02')
ret = ret.replace('\\', '\ufe68').replace('|', '\uff5c').replace('?', '\ufe16').replace('*', '\uff0a')
ret = ret.replace('\n', ' ').replace('\r', ' ')
root, ext = os.path.splitext(ret)
if root.upper() in _PostPathFormatter.RESERVED:
root += '_'
if ext == '.':
ext = '\u2024'
ret = root + ext
return ret
@@ -144,6 +179,9 @@ class Instaloader:
:param user_agent: :option:`--user-agent`
:param dirname_pattern: :option:`--dirname-pattern`, default is ``{target}``
:param filename_pattern: :option:`--filename-pattern`, default is ``{date_utc}_UTC``
:param title_pattern:
:option:`--title-pattern`, default is ``{date_utc}_UTC_{typename}`` if ``dirname_pattern`` contains
``{target}`` or ``{profile}``, ``{target}_{date_utc}_UTC_{typename}`` otherwise.
:param download_pictures: not :option:`--no-pictures`
:param download_videos: not :option:`--no-videos`
:param download_video_thumbnails: not :option:`--no-video-thumbnails`
@@ -162,6 +200,8 @@ class Instaloader:
:param check_resume_bbd: Whether to check the date of expiry of resume files and reject them if expired.
:param slide: :option:`--slide`
:param fatal_status_codes: :option:`--abort-on`
:param iphone_support: not :option:`--no-iphone`
:param sanitize_paths: :option:`--sanitize-paths`
.. attribute:: context
@@ -181,22 +221,35 @@ class Instaloader:
download_comments: bool = False,
save_metadata: bool = True,
compress_json: bool = True,
post_metadata_txt_pattern: str = None,
storyitem_metadata_txt_pattern: str = None,
post_metadata_txt_pattern: Optional[str] = None,
storyitem_metadata_txt_pattern: Optional[str] = None,
max_connection_attempts: int = 3,
request_timeout: float = 300.0,
rate_controller: Optional[Callable[[InstaloaderContext], RateController]] = None,
resume_prefix: Optional[str] = "iterator",
check_resume_bbd: bool = True,
slide: Optional[str] = None,
fatal_status_codes: Optional[List[int]] = None):
fatal_status_codes: Optional[List[int]] = None,
iphone_support: bool = True,
title_pattern: Optional[str] = None,
sanitize_paths: bool = False):
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts,
request_timeout, rate_controller, fatal_status_codes)
request_timeout, rate_controller, fatal_status_codes,
iphone_support)
# configuration parameters
self.dirname_pattern = dirname_pattern or "{target}"
self.filename_pattern = filename_pattern or "{date_utc}_UTC"
if title_pattern is not None:
self.title_pattern = title_pattern
else:
if (format_string_contains_key(self.dirname_pattern, 'profile') or
format_string_contains_key(self.dirname_pattern, 'target')):
self.title_pattern = '{date_utc}_UTC_{typename}'
else:
self.title_pattern = '{target}_{date_utc}_UTC_{typename}'
self.sanitize_paths = sanitize_paths
self.download_pictures = download_pictures
self.download_videos = download_videos
self.download_video_thumbnails = download_video_thumbnails
@@ -259,7 +312,9 @@ class Instaloader:
resume_prefix=self.resume_prefix,
check_resume_bbd=self.check_resume_bbd,
slide=self.slide,
fatal_status_codes=self.context.fatal_status_codes)
fatal_status_codes=self.context.fatal_status_codes,
iphone_support=self.context.iphone_support,
sanitize_paths=self.sanitize_paths)
yield new_loader
self.context.error_log.extend(new_loader.context.error_log)
new_loader.context.error_log = [] # avoid double-printing of errors
@@ -275,20 +330,33 @@ class Instaloader:
def __exit__(self, *args):
self.close()
def __repr__(self):
return f"<Instaloader context={self.context}>"
@_retry_on_connection_error
def download_pic(self, filename: str, url: str, mtime: datetime,
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool:
"""Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated."""
urlmatch = re.search('\\.[a-z0-9]*\\?', url)
file_extension = url[-3:] if urlmatch is None else urlmatch.group(0)[1:-1]
if filename_suffix is not None:
filename += '_' + filename_suffix
filename += '.' + file_extension
if os.path.isfile(filename):
urlmatch = re.search('\\.[a-z0-9]*\\?', url)
file_extension = url[-3:] if urlmatch is None else urlmatch.group(0)[1:-1]
nominal_filename = filename + '.' + file_extension
if os.path.isfile(nominal_filename):
self.context.log(nominal_filename + ' exists', end=' ', flush=True)
return False
resp = self.context.get_raw(url)
if 'Content-Type' in resp.headers and resp.headers['Content-Type']:
header_extension = '.' + resp.headers['Content-Type'].split(';')[0].split('/')[-1]
header_extension = header_extension.lower().replace('jpeg', 'jpg')
filename += header_extension
else:
filename = nominal_filename
if filename != nominal_filename and os.path.isfile(filename):
self.context.log(filename + ' exists', end=' ', flush=True)
return False
self.context.get_and_write_raw(url, filename)
self.context.write_raw(resp, filename)
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
return True
@@ -382,7 +450,7 @@ class Instaloader:
"""Updates picture caption / Post metadata info"""
def _elliptify(caption):
pcaption = caption.replace('\n', ' ').strip()
return '[' + ((pcaption[:29] + u"\u2026") if len(pcaption) > 31 else pcaption) + ']'
return '[' + ((pcaption[:29] + "\u2026") if len(pcaption) > 31 else pcaption) + ']'
filename += '.txt'
caption += '\n'
pcaption = _elliptify(caption)
@@ -413,17 +481,19 @@ class Instaloader:
self.context.log(pcaption, end=' ', flush=True)
except UnicodeEncodeError:
self.context.log('txt', end=' ', flush=True)
with open(filename, 'wb') as text_file:
with BytesIO(bcaption) as bio:
shutil.copyfileobj(cast(IO, bio), text_file)
with open(filename, 'w', encoding='UTF-8') as fio:
fio.write(caption)
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
"""Save post location name and Google Maps link."""
filename += '_location.txt'
location_string = (location.name + "\n" +
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat,
location.lng))
if location.lat is not None and location.lng is not None:
location_string = (location.name + "\n" +
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat,
location.lng))
else:
location_string = location.name
with open(filename, 'wb') as text_file:
with BytesIO(location_string.encode()) as bio:
shutil.copyfileobj(cast(IO, bio), text_file)
@@ -456,31 +526,52 @@ class Instaloader:
.. versionadded:: 4.3"""
def _epoch_to_string(epoch: datetime) -> str:
return epoch.strftime('%Y-%m-%d_%H-%M-%S_UTC')
http_response = self.context.get_raw(url)
date_object = None # type: Optional[datetime]
date_object: Optional[datetime] = None
if 'Last-Modified' in http_response.headers:
date_object = datetime.strptime(http_response.headers["Last-Modified"], '%a, %d %b %Y %H:%M:%S GMT')
date_object = date_object.replace(tzinfo=timezone.utc)
pic_bytes = None
pic_identifier = _epoch_to_string(date_object)
else:
pic_bytes = http_response.content
pic_identifier = md5(pic_bytes).hexdigest()[:16]
filename = self.format_filename_within_target_path(target, owner_profile, pic_identifier, name_suffix, 'jpg')
ig_filename = url.split('/')[-1].split('?')[0]
pic_data = TitlePic(owner_profile, target, name_suffix, ig_filename, date_object)
dirname = _PostPathFormatter(pic_data, self.sanitize_paths).format(self.dirname_pattern, target=target)
filename_template = os.path.join(
dirname,
_PostPathFormatter(pic_data, self.sanitize_paths).format(self.title_pattern, target=target))
filename = self.__prepare_filename(filename_template, lambda: url) + ".jpg"
content_length = http_response.headers.get('Content-Length', None)
if os.path.isfile(filename) and (not self.context.is_logged_in or
(content_length is not None and
os.path.getsize(filename) >= int(content_length))):
self.context.log(filename + ' already exists')
return None
return
os.makedirs(os.path.dirname(filename), exist_ok=True)
self.context.write_raw(pic_bytes if pic_bytes else http_response, filename)
if date_object:
os.utime(filename, (datetime.now().timestamp(), date_object.timestamp()))
self.context.log('') # log output of _get_and_write_raw() does not produce \n
def download_profilepic_if_new(self, profile: Profile, latest_stamps: Optional[LatestStamps]) -> None:
"""
Downloads and saves profile pic if it has not been downloaded before.
:param latest_stamps: Database with the last downloaded data. If not present,
the profile pic is downloaded unless it already exists
.. versionadded:: 4.8
"""
if latest_stamps is None:
self.download_profilepic(profile)
return
profile_pic_basename = profile.profile_pic_url_no_iphone.split('/')[-1].split('?')[0]
saved_basename = latest_stamps.get_profile_pic(profile.username)
if saved_basename == profile_pic_basename:
return
self.download_profilepic(profile)
latest_stamps.set_profile_pic(profile.username, profile_pic_basename)
def download_profilepic(self, profile: Profile) -> None:
"""Downloads and saves profile pic."""
self.download_title_pic(profile.profile_pic_url, profile.username.lower(), 'profile_pic', profile)
@@ -497,6 +588,23 @@ class Instaloader:
.. versionadded:: 4.4"""
self.download_title_pic(hashtag.profile_pic_url, '#' + hashtag.name, 'profile_pic', None)
@_requires_login
def save_session(self) -> dict:
"""Saves internally stored :class:`requests.Session` object to :class:`dict`.
:raises LoginRequiredException: If called without being logged in.
.. versionadded:: 4.10
"""
return self.context.save_session()
def load_session(self, username: str, session_data: dict) -> None:
"""Internally stores :class:`requests.Session` object from :class:`dict`.
.. versionadded:: 4.10
"""
self.context.load_session(username, session_data)
@_requires_login
def save_session_to_file(self, filename: Optional[str] = None) -> None:
"""Saves internally stored :class:`requests.Session` object.
@@ -538,11 +646,16 @@ class Instaloader:
def login(self, user: str, passwd: str) -> None:
"""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 ConnectionException: If connection to Instagram failed.
: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)
def two_factor_login(self, two_factor_code) -> None:
@@ -568,11 +681,12 @@ class Instaloader:
os.makedirs(os.path.dirname(filename), exist_ok=True)
return filename
def format_filename(self, item: Union[Post, StoryItem, PostSidecarNode], target: Optional[Union[str, Path]] = None):
def format_filename(self, item: Union[Post, StoryItem, PostSidecarNode, TitlePic],
target: Optional[Union[str, Path]] = None):
"""Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter.
.. versionadded:: 4.1"""
return _PostPathFormatter(item).format(self.filename_pattern, target=target)
return _PostPathFormatter(item, self.sanitize_paths).format(self.filename_pattern, target=target)
def download_post(self, post: Post, target: Union[str, Path]) -> bool:
"""
@@ -604,14 +718,14 @@ class Instaloader:
return False
return True
dirname = _PostPathFormatter(post).format(self.dirname_pattern, target=target)
dirname = _PostPathFormatter(post, self.sanitize_paths).format(self.dirname_pattern, target=target)
filename_template = os.path.join(dirname, self.format_filename(post, target=target))
filename = self.__prepare_filename(filename_template, lambda: post.url)
# Download the image(s) / video thumbnail and videos within sidecars if desired
downloaded = True
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(
filename_template, enumerate(
(post.get_is_videos()[i]
@@ -623,22 +737,23 @@ class Instaloader:
post.get_sidecar_nodes(self.slide_start, self.slide_end),
start=self.slide_start % post.mediacount + 1
):
suffix = str(edge_number) # type: Optional[str]
suffix: Optional[str] = str(edge_number)
if '{filename}' in self.filename_pattern:
suffix = None
if self.download_pictures and (not sidecar_node.is_video or self.download_video_thumbnails):
video_url = sidecar_node.video_url
if self.download_pictures and (video_url is None or self.download_video_thumbnails):
# pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.display_url)
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url,
mtime=post.date_local, filename_suffix=suffix)
if sidecar_node.is_video and self.download_videos:
if video_url is not None and self.download_videos:
# pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.video_url)
lambda: video_url)
# Download sidecar video if desired
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.video_url,
downloaded &= self.download_pic(filename=sidecar_filename, url=video_url,
mtime=post.date_local, filename_suffix=suffix)
else:
downloaded = False
@@ -714,7 +829,8 @@ class Instaloader:
userids: Optional[List[Union[int, Profile]]] = None,
fast_update: bool = False,
filename_target: Optional[str] = ':stories',
storyitem_filter: Optional[Callable[[StoryItem], bool]] = None) -> None:
storyitem_filter: Optional[Callable[[StoryItem], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None:
"""
Download available stories from user followees or all stories of users whose ID are given.
Does not mark stories as seen.
@@ -725,20 +841,38 @@ class Instaloader:
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
or None if profile name should be used instead
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
:param latest_stamps: Database with the last times each user was scraped
:raises LoginRequiredException: If called without being logged in.
.. versionchanged:: 4.8
Add `latest_stamps` parameter.
"""
if not userids:
self.context.log("Retrieving all visible stories...")
profile_count = None
else:
userids = [p if isinstance(p, int) else p.userid for p in userids]
profile_count = len(userids)
for user_story in self.get_stories(userids):
for i, user_story in enumerate(self.get_stories(userids), start=1):
name = user_story.owner_username
self.context.log("Retrieving stories from profile {}.".format(name))
if profile_count is not None:
msg = "[{0:{w}d}/{1:{w}d}] Retrieving stories from profile {2}.".format(i, profile_count, name,
w=len(str(profile_count)))
else:
msg = "[{:3d}] Retrieving stories from profile {}.".format(i, name)
self.context.log(msg)
totalcount = user_story.itemcount
count = 1
if latest_stamps is not None:
# pylint:disable=cell-var-from-loop
last_scraped = latest_stamps.get_last_story_timestamp(name)
scraped_timestamp = datetime.now().astimezone()
for item in user_story.get_items():
if latest_stamps is not None:
if item.date_local <= last_scraped:
break
if storyitem_filter is not None and not storyitem_filter(item):
self.context.log("<{} skipped>".format(item), flush=True)
continue
@@ -748,6 +882,8 @@ class Instaloader:
downloaded = self.download_storyitem(item, filename_target if filename_target else name)
if fast_update and not downloaded:
break
if latest_stamps is not None:
latest_stamps.set_last_story_timestamp(name, scraped_timestamp)
def download_storyitem(self, item: StoryItem, target: Union[str, Path]) -> bool:
"""Download one user story.
@@ -765,17 +901,22 @@ class Instaloader:
return True
date_local = item.date_local
dirname = _PostPathFormatter(item).format(self.dirname_pattern, target=target)
dirname = _PostPathFormatter(item, self.sanitize_paths).format(self.dirname_pattern, target=target)
filename_template = os.path.join(dirname, self.format_filename(item, target=target))
filename = self.__prepare_filename(filename_template, lambda: item.url)
downloaded = False
if not item.is_video or self.download_video_thumbnails is True:
video_url_fetch_failed = False
if item.is_video and self.download_videos is True:
video_url = item.video_url
if video_url:
filename = self.__prepare_filename(filename_template, lambda: str(video_url))
downloaded |= (not _already_downloaded(filename + ".mp4") and
self.download_pic(filename=filename, url=video_url, mtime=date_local))
else:
video_url_fetch_failed = True
if video_url_fetch_failed or not item.is_video or self.download_video_thumbnails is True:
downloaded = (not _already_downloaded(filename + ".jpg") and
self.download_pic(filename=filename, url=item.url, mtime=date_local))
if item.is_video and self.download_videos is True:
filename = self.__prepare_filename(filename_template, lambda: str(item.video_url))
downloaded |= (not _already_downloaded(filename + ".mp4") and
self.download_pic(filename=filename, url=item.video_url, mtime=date_local))
# Save caption if desired
metadata_string = _ArbitraryItemFormatter(item).format(self.storyitem_metadata_txt_pattern).strip()
if metadata_string:
@@ -822,6 +963,11 @@ class Instaloader:
.. versionchanged:: 4.3
Also downloads and saves the Highlight's cover pictures.
.. versionchanged:: 4.16
Add progress output for highlight retrieval, displaying a counter of the current
highlight number and the total number of highlights being processed. This provides
feedback similar to the per-item download counter already present in this function.
:param user: ID or Profile of the user whose highlights should get downloaded.
:param fast_update: If true, abort when first already-downloaded picture is encountered
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
@@ -829,13 +975,20 @@ class Instaloader:
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
:raises LoginRequiredException: If called without being logged in.
"""
for user_highlight in self.get_highlights(user):
user_highlights = list(self.get_highlights(user))
hl_size = len(user_highlights)
for hl_number, user_highlight in enumerate(user_highlights, start=1):
name = user_highlight.owner_username
highlight_target = (filename_target
highlight_target: Union[str, Path] = (filename_target
if filename_target
else (Path(_PostPathFormatter.sanitize_path(name)) /
_PostPathFormatter.sanitize_path(user_highlight.title))) # type: Union[str, Path]
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) /
_PostPathFormatter.sanitize_path(user_highlight.title,
self.sanitize_paths)))
self.context.log(
"[{0:{w}d}/{1:{w}d}] Retrieving highlights \"{2}\" from profile {3}".format(
hl_number, hl_size, user_highlight.title, name, w=len(str(hl_size))
)
)
self.download_highlight_cover(user_highlight, highlight_target)
totalcount = user_highlight.itemcount
count = 1
@@ -858,7 +1011,9 @@ class Instaloader:
post_filter: Optional[Callable[[Post], bool]] = None,
max_count: Optional[int] = None,
total_count: Optional[int] = None,
owner_profile: Optional[Profile] = None) -> None:
owner_profile: Optional[Profile] = None,
takewhile: Optional[Callable[[Post], bool]] = None,
possibly_pinned: int = 0) -> None:
"""
Download the Posts returned by given Post Iterator.
@@ -867,6 +1022,12 @@ class Instaloader:
.. versionchanged:: 4.5
Transparently resume an aborted operation if `posts` is a :class:`NodeIterator`.
.. versionchanged:: 4.8
Add `takewhile` parameter.
.. versionchanged:: 4.10.3
Add `possibly_pinned` parameter.
:param posts: Post Iterator to loop through.
:param target: Target name.
:param fast_update: :option:`--fast-update`.
@@ -874,12 +1035,17 @@ class Instaloader:
:param max_count: Maximum count of Posts to download (:option:`--count`).
:param total_count: Total number of posts returned by given iterator.
:param owner_profile: Associated profile, if any.
: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
else total_count)
sanitized_target = target
if isinstance(target, str):
sanitized_target = _PostPathFormatter.sanitize_path(target)
sanitized_target = _PostPathFormatter.sanitize_path(target, self.sanitize_paths)
if takewhile is None:
takewhile = lambda _: True
with resumable_iteration(
context=self.context,
iterator=posts,
@@ -892,7 +1058,10 @@ class Instaloader:
enabled=self.resume_prefix is not None
) as (is_resuming, start_index):
for number, post in enumerate(posts, start=start_index + 1):
if max_count is not None and number > max_count:
should_stop = not takewhile(post)
if should_stop and number <= possibly_pinned:
continue
if (max_count is not None and number > max_count) or should_stop:
break
if displayed_count is not None:
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count,
@@ -924,7 +1093,7 @@ class Instaloader:
except PostChangedException:
post_changed = True
continue
if fast_update and not downloaded and not post_changed:
if fast_update and not downloaded and not post_changed and number > possibly_pinned:
# disengage fast_update for first post when resuming
if not is_resuming or number > 0:
break
@@ -955,7 +1124,7 @@ class Instaloader:
'has_stories': False})["data"]
@_requires_login
def download_feed_posts(self, max_count: int = None, fast_update: bool = False,
def download_feed_posts(self, max_count: Optional[int] = None, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
"""
Download pictures from the user's feed.
@@ -976,7 +1145,7 @@ class Instaloader:
self.posts_download_loop(self.get_feed_posts(), ":feed", fast_update, post_filter, max_count=max_count)
@_requires_login
def download_saved_posts(self, max_count: int = None, fast_update: bool = False,
def download_saved_posts(self, max_count: Optional[int] = None, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
"""Download user's saved pictures.
@@ -1004,18 +1173,12 @@ class Instaloader:
.. versionchanged:: 4.2.9
Require being logged in (as required by Instagram)
"""
has_next_page = True
end_cursor = None
while has_next_page:
if end_cursor:
params = {'__a': 1, 'max_id': end_cursor}
else:
params = {'__a': 1}
location_data = self.context.get_json('explore/locations/{0}/'.format(location),
params)['graphql']['location']['edge_location_to_media']
yield from (Post(self.context, edge['node']) for edge in location_data['edges'])
has_next_page = location_data['page_info']['has_next_page']
end_cursor = location_data['page_info']['end_cursor']
yield from SectionIterator(
self.context,
lambda d: d["native_location_data"]["recent"],
lambda m: Post.from_iphone_struct(self.context, m),
f"explore/locations/{location}/",
)
@_requires_login
def download_location(self, location: str,
@@ -1064,9 +1227,10 @@ class Instaloader:
"""Get Posts associated with a #hashtag.
.. deprecated:: 4.4
Use :meth:`Hashtag.get_posts`."""
return Hashtag.from_name(self.context, hashtag).get_posts()
Use :meth:`Hashtag.get_posts_resumable`."""
return Hashtag.from_name(self.context, hashtag).get_posts_resumable()
@_requires_login
def download_hashtag(self, hashtag: Union[Hashtag, str],
max_count: Optional[int] = None,
post_filter: Optional[Callable[[Post], bool]] = None,
@@ -1101,7 +1265,7 @@ class Instaloader:
self.download_hashtag_profilepic(hashtag)
if posts:
self.context.log("Retrieving pictures with hashtag #{}...".format(hashtag.name))
self.posts_download_loop(hashtag.get_all_posts(), target, fast_update, post_filter,
self.posts_download_loop(hashtag.get_posts_resumable(), target, fast_update, post_filter,
max_count=max_count)
if self.save_metadata:
json_filename = '{0}/{1}'.format(self.dirname_pattern.format(profile=target,
@@ -1111,25 +1275,73 @@ class Instaloader:
def download_tagged(self, profile: Profile, fast_update: bool = False,
target: Optional[str] = None,
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
post_filter: Optional[Callable[[Post], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None:
"""Download all posts where a profile is tagged.
.. versionadded:: 4.1"""
.. versionadded:: 4.1
.. versionchanged:: 4.8
Add `latest_stamps` parameter."""
self.context.log("Retrieving tagged posts for profile {}.".format(profile.username))
self.posts_download_loop(profile.get_tagged_posts(),
posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None:
last_scraped = latest_stamps.get_last_tagged_timestamp(profile.username)
posts_takewhile = lambda p: p.date_local > last_scraped
tagged_posts = profile.get_tagged_posts()
self.posts_download_loop(tagged_posts,
target if target
else (Path(_PostPathFormatter.sanitize_path(profile.username)) /
_PostPathFormatter.sanitize_path(':tagged')),
fast_update, post_filter)
else (Path(_PostPathFormatter.sanitize_path(profile.username, self.sanitize_paths)) /
_PostPathFormatter.sanitize_path(':tagged', self.sanitize_paths)),
fast_update, post_filter, takewhile=posts_takewhile)
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)
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,
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
post_filter: Optional[Callable[[Post], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None:
"""Download IGTV videos of a profile.
.. versionadded:: 4.3"""
.. versionadded:: 4.3
.. versionchanged:: 4.8
Add `latest_stamps` parameter."""
self.context.log("Retrieving IGTV videos for profile {}.".format(profile.username))
self.posts_download_loop(profile.get_igtv_posts(), profile.username, fast_update, post_filter,
total_count=profile.igtvcount, owner_profile=profile)
posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None:
last_scraped = latest_stamps.get_last_igtv_timestamp(profile.username)
posts_takewhile = lambda p: p.date_local > last_scraped
igtv_posts = profile.get_igtv_posts()
self.posts_download_loop(igtv_posts, profile.username, fast_update, post_filter,
total_count=profile.igtvcount, owner_profile=profile, takewhile=posts_takewhile)
if latest_stamps is not None and igtv_posts.first_item is not None:
latest_stamps.set_last_igtv_timestamp(profile.username, igtv_posts.first_item.date_local)
def _get_id_filename(self, profile_name: str) -> str:
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
@@ -1141,9 +1353,22 @@ class Instaloader:
return os.path.join(self.dirname_pattern.format(),
'{0}_id'.format(profile_name.lower()))
def load_profile_id(self, profile_name: str) -> Optional[int]:
"""
Load ID of profile from profile directory.
.. versionadded:: 4.8
"""
id_filename = self._get_id_filename(profile_name)
try:
with open(id_filename, 'rb') as id_file:
return int(id_file.read())
except (FileNotFoundError, ValueError):
return None
def save_profile_id(self, profile: Profile):
"""
Store ID of profile locally.
Store ID of profile on profile directory.
.. versionadded:: 4.0.6
"""
@@ -1153,13 +1378,18 @@ class Instaloader:
text_file.write(str(profile.userid) + "\n")
self.context.log("Stored ID {0} for profile {1}.".format(profile.userid, profile.username))
def check_profile_id(self, profile_name: str) -> Profile:
def check_profile_id(self, profile_name: str, latest_stamps: Optional[LatestStamps] = None) -> Profile:
"""
Consult locally stored ID of profile with given name, check whether ID matches and whether name
has changed and return current name of the profile, and store ID of profile.
:param profile_name: Profile name
:param latest_stamps: Database of downloaded data. If present, IDs are retrieved from it,
otherwise from the target directory
:return: Instance of current profile
.. versionchanged:: 4.8
Add `latest_stamps` parameter.
"""
profile = None
profile_name_not_exists_err = None
@@ -1167,10 +1397,11 @@ class Instaloader:
profile = Profile.from_username(self.context, profile_name)
except ProfileNotExistsException as err:
profile_name_not_exists_err = err
id_filename = self._get_id_filename(profile_name)
try:
with open(id_filename, 'rb') as id_file:
profile_id = int(id_file.read())
if latest_stamps is None:
profile_id = self.load_profile_id(profile_name)
else:
profile_id = latest_stamps.get_profile_id(profile_name)
if profile_id is not None:
if (profile is None) or \
(profile_id != profile.userid):
if profile is not None:
@@ -1181,23 +1412,31 @@ class Instaloader:
profile_id))
profile_from_id = Profile.from_id(self.context, profile_id)
newname = profile_from_id.username
self.context.log("Profile {0} has changed its name to {1}.".format(profile_name, newname))
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
format_string_contains_key(self.dirname_pattern, 'target'))):
os.rename(self.dirname_pattern.format(profile=profile_name.lower(),
target=profile_name.lower()),
self.dirname_pattern.format(profile=newname.lower(),
target=newname.lower()))
if profile_name == newname:
self.context.error(
f"Warning: Profile {profile_name} could not be retrieved by its name, but by its ID.")
return profile_from_id
self.context.error("Profile {0} has changed its name to {1}.".format(profile_name, newname))
if latest_stamps is None:
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
format_string_contains_key(self.dirname_pattern, 'target'))):
os.rename(self.dirname_pattern.format(profile=profile_name.lower(),
target=profile_name.lower()),
self.dirname_pattern.format(profile=newname.lower(),
target=newname.lower()))
else:
os.rename('{0}/{1}_id'.format(self.dirname_pattern.format(), profile_name.lower()),
'{0}/{1}_id'.format(self.dirname_pattern.format(), newname.lower()))
else:
os.rename('{0}/{1}_id'.format(self.dirname_pattern.format(), profile_name.lower()),
'{0}/{1}_id'.format(self.dirname_pattern.format(), newname.lower()))
latest_stamps.rename_profile(profile_name, newname)
return profile_from_id
# profile exists and profile id matches saved id
return profile
except (FileNotFoundError, ValueError):
pass
if profile is not None:
self.save_profile_id(profile)
if latest_stamps is None:
self.save_profile_id(profile)
else:
latest_stamps.save_profile_id(profile.username, profile.userid)
return profile
if profile_name_not_exists_err:
raise profile_name_not_exists_err
@@ -1212,7 +1451,10 @@ class Instaloader:
fast_update: bool = False,
post_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,
max_count: Optional[int] = None,
reels: bool = False):
"""High-level method to download set of profiles.
:param profiles: Set of profiles to download.
@@ -1228,11 +1470,23 @@ class Instaloader:
:param raise_errors:
Whether :exc:`LoginRequiredException` and :exc:`PrivateProfileNotFollowedException` should be raised or
catched and printed with :meth:`InstaloaderContext.error_catcher`.
:param latest_stamps: :option:`--latest-stamps`.
:param max_count: Maximum count of posts to download.
:param reels: :option:`--reels`.
.. versionadded:: 4.1
.. versionchanged:: 4.3
Add `igtv` parameter.
.. versionchanged:: 4.8
Add `latest_stamps` parameter.
.. versionchanged:: 4.13
Add `max_count` parameter.
.. versionchanged:: 4.14
Add `reels` parameter.
"""
@contextmanager
@@ -1242,14 +1496,16 @@ class Instaloader:
# error_handler type is Callable[[Optional[str]], ContextManager[None]] (not supported with Python 3.5.0..3.5.3)
error_handler = _error_raiser if raise_errors else self.context.error_catcher
for profile in profiles:
for i, profile in enumerate(profiles, start=1):
self.context.log("[{0:{w}d}/{1:{w}d}] Downloading profile {2}".format(i, len(profiles), profile.username,
w=len(str(len(profiles)))))
with error_handler(profile.username): # type: ignore # (ignore type for Python 3.5 support)
profile_name = profile.username
# Download profile picture
if profile_pic:
with self.context.error_catcher('Download profile picture of {}'.format(profile_name)):
self.download_profilepic(profile)
self.download_profilepic_if_new(profile, latest_stamps)
# Save metadata as JSON if desired.
if self.save_metadata:
@@ -1262,7 +1518,7 @@ class Instaloader:
if tagged or igtv or highlights or posts:
if (not self.context.is_logged_in and
profile.is_private):
raise LoginRequiredException("--login=USERNAME required.")
raise LoginRequiredException("Login required.")
if (self.context.username != profile.username and
profile.is_private and
not profile.followed_by_viewer):
@@ -1271,12 +1527,20 @@ class Instaloader:
# Download tagged, if requested
if tagged:
with self.context.error_catcher('Download tagged of {}'.format(profile_name)):
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)
# 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
if igtv:
with self.context.error_catcher('Download IGTV of {}'.format(profile_name)):
self.download_igtv(profile, fast_update=fast_update, post_filter=post_filter)
self.download_igtv(profile, fast_update=fast_update, post_filter=post_filter,
latest_stamps=latest_stamps)
# Download highlights, if requested
if highlights:
@@ -1286,14 +1550,24 @@ class Instaloader:
# Iterate over pictures and download them
if posts:
self.context.log("Retrieving posts from profile {}.".format(profile_name))
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
total_count=profile.mediacount, owner_profile=profile)
posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None:
# pylint:disable=cell-var-from-loop
last_scraped = latest_stamps.get_last_post_timestamp(profile_name)
posts_takewhile = lambda p: p.date_local > last_scraped
posts_to_download = profile.get_posts()
self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter,
total_count=profile.mediacount, owner_profile=profile,
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:
latest_stamps.set_last_post_timestamp(profile_name,
posts_to_download.first_item.date_local)
if stories and profiles:
with self.context.error_catcher("Download stories"):
self.context.log("Downloading stories")
self.download_stories(userids=list(profiles), fast_update=fast_update, filename_target=None,
storyitem_filter=storyitem_filter)
storyitem_filter=storyitem_filter, latest_stamps=latest_stamps)
def download_profile(self, profile_name: Union[str, Profile],
profile_pic: bool = True, profile_pic_only: bool = False,
@@ -1372,11 +1646,16 @@ class Instaloader:
def interactive_login(self, username: str) -> None:
"""Logs in and internally stores session, asking user for password interactively.
:raises LoginRequiredException: when in quiet mode.
:raises InvalidArgumentException: If the provided username does not exist.
:raises ConnectionException: If connection to Instagram failed."""
:raises InvalidArgumentException: when in quiet mode.
:raises LoginException: If the provided username does not exist.
: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:
raise LoginRequiredException("Quiet mode requires given password or valid session file.")
raise InvalidArgumentException("Quiet mode requires given password or valid session file.")
try:
password = None
while password is None:
@@ -1395,3 +1674,10 @@ class Instaloader:
except BadCredentialsException as err:
print(err, file=sys.stderr)
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
+351 -108
View File
@@ -1,4 +1,3 @@
import hashlib
import json
import os
import pickle
@@ -9,7 +8,8 @@ import sys
import textwrap
import time
import urllib.parse
from contextlib import contextmanager
import uuid
from contextlib import contextmanager, suppress
from datetime import datetime, timedelta
from functools import partial
from typing import Any, Callable, Dict, Iterator, List, Optional, Union
@@ -24,16 +24,57 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
"""Duplicates a requests.Session."""
new = requests.Session()
new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies))
new.headers = session.headers.copy()
new.headers = session.headers.copy() # type: ignore
# Override default timeout behavior.
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
new.request = partial(new.request, timeout=request_timeout) # type: ignore
new.request = partial(new.request, timeout=request_timeout) # type: ignore
return new
def _embedded_query_data(obj: Any) -> Iterator[Dict[str, Any]]:
"""Yield the data of each GraphQL query result in JSON that a page of www.instagram.com embeds."""
if isinstance(obj, dict):
result = obj['__bbox'].get('result') if isinstance(obj.get('__bbox'), dict) else None
if isinstance(result, dict) and isinstance(result.get('data'), dict):
yield result['data']
for value in obj.values():
yield from _embedded_query_data(value)
elif isinstance(obj, list):
for value in obj:
yield from _embedded_query_data(value)
def default_user_agent() -> str:
return 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
'(KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36'
return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
def default_iphone_headers() -> Dict[str, Any]:
return {'User-Agent': 'Instagram 361.0.0.35.82 (iPad13,8; iOS 18_0; en_US; en-US; ' \
'scale=2.00; 2048x2732; 674117118) AppleWebKit/420+',
'x-ads-opt-out': '1',
'x-bloks-is-panorama-enabled': 'true',
'x-bloks-version-id': '16b7bd25c6c06886d57c4d455265669345a2d96625385b8ee30026ac2dc5ed97',
'x-fb-client-ip': 'True',
'x-fb-connection-type': 'wifi',
'x-fb-http-engine': 'Liger',
'x-fb-server-cluster': 'True',
'x-fb': '1',
'x-ig-abr-connection-speed-kbps': '2',
'x-ig-app-id': '124024574287414',
'x-ig-app-locale': 'en-US',
'x-ig-app-startup-country': 'US',
'x-ig-bandwidth-speed-kbps': '0.000',
'x-ig-capabilities': '36r/F/8=',
'x-ig-connection-speed': '{}kbps'.format(random.randint(1000, 20000)),
'x-ig-connection-type': 'WiFi',
'x-ig-device-locale': 'en-US',
'x-ig-mapped-locale': 'en-US',
'x-ig-timezone-offset': str((datetime.now().astimezone().utcoffset() or timedelta(seconds=0)).seconds),
'x-ig-www-claim': '0',
'x-pigeon-session-id': str(uuid.uuid4()),
'x-tigon-is-retry': 'False',
'x-whatsapp': '0'}
class InstaloaderContext:
@@ -54,45 +95,54 @@ class InstaloaderContext:
def __init__(self, sleep: bool = True, quiet: bool = False, user_agent: Optional[str] = None,
max_connection_attempts: int = 3, request_timeout: float = 300.0,
rate_controller: Optional[Callable[["InstaloaderContext"], "RateController"]] = None,
fatal_status_codes: Optional[List[int]] = None):
fatal_status_codes: Optional[List[int]] = None,
iphone_support: bool = True):
self.user_agent = user_agent if user_agent is not None else default_user_agent()
self.request_timeout = request_timeout
self._session = self.get_anonymous_session()
self.username = None
self.user_id = None
self.sleep = sleep
self.quiet = quiet
self.max_connection_attempts = max_connection_attempts
self._graphql_page_length = 50
self._root_rhx_gis = None
self.two_factor_auth_pending = None
self.iphone_support = iphone_support
self.iphone_headers = default_iphone_headers()
# error log, filled with error() and printed at the end of Instaloader.main()
self.error_log = [] # type: List[str]
self.error_log: List[str] = []
self._rate_controller = rate_controller(self) if rate_controller is not None else RateController(self)
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
# Can be set to True for testing, disables suppression of InstaloaderContext._error_catcher
self.raise_all_errors = False
# HTTP status codes that should cause an AbortDownloadException
self.fatal_status_codes = fatal_status_codes or []
# Cache profile from id (mapping from id to Profile)
self.profile_id_cache = dict() # type: Dict[int, Any]
self.profile_id_cache: Dict[int, Any] = dict()
@contextmanager
def anonymous_copy(self):
session = self._session
username = self.username
user_id = self.user_id
iphone_headers = self.iphone_headers
self._session = self.get_anonymous_session()
self.username = None
self.user_id = None
self.iphone_headers = default_iphone_headers()
try:
yield self
finally:
self._session.close()
self.username = username
self._session = session
self.user_id = user_id
self.iphone_headers = iphone_headers
@property
def is_logged_in(self) -> bool:
@@ -113,6 +163,16 @@ class InstaloaderContext:
if repeat_at_end:
self.error_log.append(msg)
def __repr__(self):
return f"<InstaloaderContext is_logged_in={self.is_logged_in}>"
@property
def has_stored_errors(self) -> bool:
"""Returns whether any error has been reported and stored to be repeated at program termination.
.. versionadded: 4.12"""
return bool(self.error_log)
def close(self):
"""Print error log and close session"""
if self.error_log and not self.quiet:
@@ -148,11 +208,11 @@ class InstaloaderContext:
'Referer': 'https://www.instagram.com/',
'User-Agent': self.user_agent,
'X-Instagram-AJAX': '1',
'X-Requested-With': 'XMLHttpRequest'}
'X-Requested-With': 'XMLHttpRequest',
'x-ig-app-id': '936619743392459'}
if empty_session_only:
del header['Host']
del header['Origin']
del header['Referer']
del header['X-Instagram-AJAX']
del header['X-Requested-With']
return header
@@ -169,35 +229,56 @@ class InstaloaderContext:
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
return session
def save_session_to_file(self, sessionfile):
"""Not meant to be used directly, use :meth:`Instaloader.save_session_to_file`."""
pickle.dump(requests.utils.dict_from_cookiejar(self._session.cookies), sessionfile)
def save_session(self):
"""Not meant to be used directly, use :meth:`Instaloader.save_session`."""
return requests.utils.dict_from_cookiejar(self._session.cookies)
def load_session_from_file(self, username, sessionfile):
"""Not meant to be used directly, use :meth:`Instaloader.load_session_from_file`."""
def update_cookies(self, cookie):
""".. versionadded:: 4.11"""
self._session.cookies.update(cookie)
def load_session(self, username, sessiondata):
"""Not meant to be used directly, use :meth:`Instaloader.load_session`."""
session = requests.Session()
session.cookies = requests.utils.cookiejar_from_dict(pickle.load(sessionfile))
session.cookies = requests.utils.cookiejar_from_dict(sessiondata)
session.headers.update(self._default_http_header())
session.headers.update({'X-CSRFToken': session.cookies.get_dict()['csrftoken']})
# Override default timeout behavior.
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
self._session = session
self.username = username
def save_session_to_file(self, sessionfile):
"""Not meant to be used directly, use :meth:`Instaloader.save_session_to_file`."""
pickle.dump(self.save_session(), sessionfile)
def load_session_from_file(self, username, sessionfile):
"""Not meant to be used directly, use :meth:`Instaloader.load_session_from_file`."""
self.load_session(username, pickle.load(sessionfile))
def test_login(self) -> Optional[str]:
"""Not meant to be used directly, use :meth:`Instaloader.test_login`."""
data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})
return data["data"]["user"]["username"] if data["data"]["user"] is not None else None
try:
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):
"""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 ConnectionException: If connection to Instagram failed.
: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
import http.client
# pylint:disable=protected-access
@@ -210,20 +291,25 @@ class InstaloaderContext:
# Override default timeout behavior.
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
csrf_json = self.get_json('accounts/login/', {}, session=session)
csrf_token = csrf_json['config']['csrf_token']
# Make a request to Instagram's root URL, which will set the session's csrftoken cookie
# Not using self.get_json() here, because we need to access the cookie
session.get('https://www.instagram.com/')
# Add session's csrftoken cookie to session headers
csrf_token = session.cookies.get_dict()['csrftoken']
session.headers.update({'X-CSRFToken': csrf_token})
# Not using self.get_json() here, because we need to access csrftoken cookie
self.do_sleep()
# Workaround credits to pgrimaud.
# See: https://github.com/pgrimaud/instagram-user-feed/commit/96ad4cf54d1ad331b337f325c73e664999a6d066
enc_password = '#PWD_INSTAGRAM_BROWSER:0:{}:{}'.format(int(datetime.now().timestamp()), passwd)
login = session.post('https://www.instagram.com/accounts/login/ajax/',
login = session.post('https://www.instagram.com/api/v1/web/accounts/login/ajax/',
data={'enc_password': enc_password, 'username': user}, allow_redirects=True)
try:
resp_json = login.json()
except json.decoder.JSONDecodeError as err:
raise ConnectionException(
raise LoginException(
"Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason)
) from err
if resp_json.get('two_factor_required'):
@@ -235,35 +321,37 @@ class InstaloaderContext:
resp_json['two_factor_info']['two_factor_identifier'])
raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.")
if resp_json.get('checkpoint_url'):
raise ConnectionException("Login: Checkpoint required. Point your browser to "
"https://www.instagram.com{} - "
"follow the instructions, then retry.".format(resp_json.get('checkpoint_url')))
raise LoginException(
f"Login: Checkpoint required. Point your browser to {resp_json.get('checkpoint_url')} - "
f"follow the instructions, then retry."
)
if resp_json['status'] != 'ok':
if 'message' in resp_json:
raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message']))
raise LoginException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message']))
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:
# Issue #472
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:
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 resp_json['user']:
# '{"authenticated": false, "user": true, "status": "ok"}'
raise BadCredentialsException('Login error: Wrong password.')
else:
# '{"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
# 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"}'
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session
self.username = user
self.user_id = resp_json['userId']
def two_factor_login(self, two_factor_code):
"""Second step of login if 2FA is enabled.
@@ -296,83 +384,110 @@ class InstaloaderContext:
if self.sleep:
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',
session: Optional[requests.Session] = None, _attempt=1) -> Dict[str, Any]:
session: Optional[requests.Session] = None, _attempt=1,
response_headers: Optional[Dict[str, Any]] = None,
use_post: bool = False) -> Dict[str, Any]:
"""JSON request to Instagram.
: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 session: Session to use, or None to use self.session
:param use_post: Use POST instead of GET to make the request
:return: Decoded response dictionary
:raises QueryReturnedBadRequestException: When the server responds with a 400.
:raises AbortDownloadException: When the server responds with
'feedback_required'/'checkpoint_required'/'challenge_required'
:raises QueryReturnedBadRequestException: When the server responds with a 400 (and not
'feedback_required'/'checkpoint_required'/'challenge_required').
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When query repeatedly failed.
.. versionchanged:: 4.13
Added `use_post` parameter.
"""
is_graphql_query = 'query_hash' in params and 'graphql/query' in path
is_doc_id_query = 'doc_id' in params and 'graphql/query' in path
is_iphone_query = host == 'i.instagram.com'
is_other_query = not is_graphql_query and 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
try:
self.do_sleep()
if is_graphql_query:
self._rate_controller.wait_before_query(params['query_hash'])
if is_doc_id_query:
self._rate_controller.wait_before_query(params['doc_id'])
if is_iphone_query:
self._rate_controller.wait_before_query('iphone')
if is_other_query:
self._rate_controller.wait_before_query('other')
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:
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}".format(
host, path, resp.status_code, resp.reason, redirect
body = ""
if resp.headers['Content-Type'].startswith('application/json'):
body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '')
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format(
host, path, resp.status_code, resp.reason, redirect, body
))
while resp.is_redirect:
redirect_url = resp.headers['location']
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
if redirect_url.startswith('https://www.instagram.com/accounts/login'):
if (redirect_url.startswith('https://www.instagram.com/accounts/login') or
redirect_url.startswith('https://i.instagram.com/accounts/login')):
if not self.is_logged_in:
raise LoginRequiredException("Redirected to login page. Use --login.")
# alternate rate limit exceeded behavior
raise TooManyRequestsException("Redirected to 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 " +
"some time, recreate the session and try again")
if redirect_url.startswith('https://{}/'.format(host)):
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
params=params, allow_redirects=False)
else:
break
if response_headers is not None:
response_headers.clear()
response_headers.update(resp.headers)
if resp.status_code == 400:
raise QueryReturnedBadRequestException("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:
raise QueryReturnedNotFoundException("404 Not Found")
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code == 429:
raise TooManyRequestsException("429 Too Many Requests")
raise TooManyRequestsException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
is_html_query = not is_graphql_query and not "__a" in params and host == "www.instagram.com"
if is_html_query:
match = re.search(r'window\._sharedData = (.*);</script>', resp.text)
if match is None:
raise QueryReturnedNotFoundException("Could not find \"window._sharedData\" in html response.")
resp_json = json.loads(match.group(1))
entry_data = resp_json.get('entry_data')
post_or_profile_page = list(entry_data.values())[0] if entry_data is not None else None
if post_or_profile_page is None:
raise ConnectionException("\"window._sharedData\" does not contain required keys.")
# If GraphQL data is missing in `window._sharedData`, search for it in `__additionalDataLoaded`.
if 'graphql' not in post_or_profile_page[0]:
match = re.search(r'window\.__additionalDataLoaded\(.*?({.*"graphql":.*})\);</script>',
resp.text)
if match is not None:
post_or_profile_page[0]['graphql'] = json.loads(match.group(1))['graphql']
return resp_json
raise ConnectionException(self._response_error(resp))
else:
resp_json = resp.json()
if 'status' in resp_json and resp_json['status'] != "ok":
if 'message' in resp_json:
raise ConnectionException("Returned \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message']))
else:
raise ConnectionException("Returned \"{}\" status.".format(resp_json['status']))
raise ConnectionException(self._response_error(resp))
return resp_json
except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err:
error_string = "JSON Query to {}: {}".format(path, err)
@@ -386,25 +501,30 @@ class InstaloaderContext:
if isinstance(err, TooManyRequestsException):
if is_graphql_query:
self._rate_controller.handle_429(params['query_hash'])
if is_doc_id_query:
self._rate_controller.handle_429(params['doc_id'])
if is_iphone_query:
self._rate_controller.handle_429('iphone')
if is_other_query:
self._rate_controller.handle_429('other')
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1,
response_headers=response_headers)
except KeyboardInterrupt:
self.error("[skipped by user]", repeat_at_end=False)
raise ConnectionException(error_string) from err
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.
:param query_hash: Query identifying hash.
:param variables: Variables for the Query.
: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.
.. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter.
"""
with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True))
@@ -418,12 +538,6 @@ class InstaloaderContext:
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',
params={'query_hash': query_hash,
'variables': variables_json},
@@ -432,10 +546,52 @@ class InstaloaderContext:
self.error("GraphQL response did not contain a \"status\" field.")
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],
query_referer: Optional[str],
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]]:
"""
Retrieve a list of GraphQL nodes.
@@ -447,7 +603,7 @@ class InstaloaderContext:
def _query():
query_variables['first'] = self._graphql_page_length
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:
new_page_length = int(self._graphql_page_length / 2)
if new_page_length >= 12:
@@ -480,11 +636,89 @@ class InstaloaderContext:
.. versionadded:: 4.2.1"""
with copy_session(self._session, self.request_timeout) as tempsession:
tempsession.headers['User-Agent'] = 'Instagram 146.0.0.27.125 (iPhone12,1; iOS 13_3; en_US; en-US; ' \
'scale=2.00; 1656x3584; 190542906)'
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
# Set headers to simulate an API request from iPad
tempsession.headers['ig-intended-user-id'] = str(self.user_id)
tempsession.headers['x-pigeon-rawclienttime'] = '{:.6f}'.format(time.time())
# Add headers obtained from previous iPad request
tempsession.headers.update(self.iphone_headers)
# Extract key information from cookies if we haven't got it already from a previous request
header_cookies_mapping = {'x-mid': 'mid',
'ig-u-ds-user-id': 'ds_user_id',
'x-ig-device-id': 'ig_did',
'x-ig-family-device-id': 'ig_did',
'family_device_id': 'ig_did'}
# Map the cookie value to the matching HTTP request header
cookies = tempsession.cookies.get_dict().copy()
for key, value in header_cookies_mapping.items():
if value in cookies:
if key not in tempsession.headers:
tempsession.headers[key] = cookies[value]
else:
# Remove the cookie value if it's already specified as a header
tempsession.cookies.pop(value, None)
# Edge case for ig-u-rur header due to special string encoding in cookie
if 'rur' in cookies:
if 'ig-u-rur' not in tempsession.headers:
tempsession.headers['ig-u-rur'] = cookies['rur'].strip('\"').encode('utf-8') \
.decode('unicode_escape')
else:
tempsession.cookies.pop('rur', None)
# Remove headers specific to Desktop version
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With', 'Referer']:
tempsession.headers.pop(header, None)
return self.get_json(path, params, 'i.instagram.com', tempsession)
# No need for cookies if we have a bearer token
if 'authorization' in tempsession.headers:
tempsession.cookies.clear()
response_headers = dict() # type: Dict[str, Any]
response = self.get_json(path, params, 'i.instagram.com', tempsession, response_headers=response_headers)
# Extract the ig-set-* headers and use them in the next request
for key, value in response_headers.items():
if key.startswith('ig-set-'):
self.iphone_headers[key.replace('ig-set-', '')] = value
elif key.startswith('x-ig-set-'):
self.iphone_headers[key.replace('x-ig-set-', 'x-ig-')] = value
return response
def get_page_data(self, path: str) -> List[Dict[str, Any]]:
"""Load a page of ``www.instagram.com`` as a logged-out visitor and return the data it embeds.
Instagram renders public profile and post pages with the results of their GraphQL queries
embedded in the HTML, even when it refuses anonymous API requests for the same data.
:param path: URL, relative to ``www.instagram.com/``
:return: The ``data`` object of each query result that the page embeds, in page order.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When the request failed.
"""
self.do_sleep()
self._rate_controller.wait_before_query('other')
url = 'https://www.instagram.com/{0}'.format(path)
# Instagram only embeds the data when the request looks like a browser's page navigation.
headers = {'Accept': 'text/html', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none'}
try:
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, headers=headers, allow_redirects=False)
except requests.exceptions.RequestException as err:
raise ConnectionException("Page request to {}: {}".format(url, err)) from err
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
data: List[Dict[str, Any]] = []
for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', resp.text, re.DOTALL):
with suppress(json.decoder.JSONDecodeError):
data.extend(_embedded_query_data(json.loads(script.group(1))))
return data
def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None:
"""Write raw response data into a file.
@@ -514,11 +748,11 @@ class InstaloaderContext:
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url))
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file.
@@ -528,16 +762,27 @@ class InstaloaderContext:
:raises ConnectionException: When download repeatedly failed."""
self.write_raw(self.get_raw(url), filename)
@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
def head(self, url: str, allow_redirects: bool = False) -> requests.Response:
"""HEAD a URL anonymously.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises QueryReturnedForbiddenException: When the server responds with a 403.
:raises ConnectionException: When request failed.
.. versionadded:: 4.7.6
"""
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.head(url, allow_redirects=allow_redirects)
if resp.status_code == 200:
return resp
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
class RateController:
@@ -558,7 +803,7 @@ class RateController:
def __init__(self, context: InstaloaderContext):
self._context = context
self._query_timestamps = dict() # type: Dict[str, List[float]]
self._query_timestamps: Dict[str, List[float]] = dict()
self._earliest_next_request_time = 0.0
self._iphone_earliest_next_request_time = 0.0
@@ -566,7 +811,6 @@ class RateController:
"""Wait given number of seconds."""
# Not static, to allow for the behavior of this method to depend on context-inherent properties, such as
# whether we are logged in.
# pylint:disable=no-self-use
time.sleep(secs)
def _dump_query_timestamps(self, current_time: float, failed_query_type: str):
@@ -590,7 +834,6 @@ class RateController:
control."""
# Not static, to allow for the count_per_sliding_window to depend on context-inherent properties, such as
# whether we are logged in.
# pylint:disable=no-self-use
return 75 if query_type == 'other' else 200
def _reqs_in_sliding_window(self, query_type: Optional[str], current_time: float, window: float) -> List[float]:
+126
View File
@@ -0,0 +1,126 @@
import configparser
from datetime import datetime, timezone
from typing import Optional
from os.path import dirname
from os import makedirs
class LatestStamps:
"""LatestStamps class.
Convenience class for retrieving and storing data from the :option:`--latest-stamps` file.
:param latest_stamps_file: path to file.
.. versionadded:: 4.8"""
PROFILE_ID = 'profile-id'
PROFILE_PIC = 'profile-pic'
POST_TIMESTAMP = 'post-timestamp'
TAGGED_TIMESTAMP = 'tagged-timestamp'
IGTV_TIMESTAMP = 'igtv-timestamp'
REELS_TIMESTAMP = 'reels-timestamp'
STORY_TIMESTAMP = 'story-timestamp'
ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z'
def __init__(self, latest_stamps_file):
self.file = latest_stamps_file
self.data = configparser.ConfigParser()
self.data.read(latest_stamps_file)
def _save(self):
if dn := dirname(self.file):
makedirs(dn, exist_ok=True)
with open(self.file, 'w') as f:
self.data.write(f)
def _ensure_section(self, section: str):
if not self.data.has_section(section):
self.data.add_section(section)
def get_profile_id(self, profile_name: str) -> Optional[int]:
"""Returns stored ID of profile."""
try:
return self.data.getint(profile_name, self.PROFILE_ID)
except (configparser.Error, ValueError):
return None
def save_profile_id(self, profile_name: str, profile_id: int):
"""Stores ID of profile."""
self._ensure_section(profile_name)
self.data.set(profile_name, self.PROFILE_ID, str(profile_id))
self._save()
def rename_profile(self, old_profile: str, new_profile: str):
"""Renames a profile."""
self._ensure_section(new_profile)
for option in [self.PROFILE_ID, self.PROFILE_PIC, self.POST_TIMESTAMP,
self.TAGGED_TIMESTAMP, self.IGTV_TIMESTAMP, self.STORY_TIMESTAMP]:
if self.data.has_option(old_profile, option):
value = self.data.get(old_profile, option)
self.data.set(new_profile, option, value)
self.data.remove_section(old_profile)
self._save()
def _get_timestamp(self, section: str, key: str) -> datetime:
try:
return datetime.strptime(self.data.get(section, key), self.ISO_FORMAT)
except (configparser.Error, ValueError):
return datetime.fromtimestamp(0, timezone.utc)
def _set_timestamp(self, section: str, key: str, timestamp: datetime):
self._ensure_section(section)
self.data.set(section, key, timestamp.strftime(self.ISO_FORMAT))
self._save()
def get_last_post_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's posts."""
return self._get_timestamp(profile_name, self.POST_TIMESTAMP)
def set_last_post_timestamp(self, profile_name: str, timestamp: datetime):
"""Sets timestamp of last download of a profile's posts."""
self._set_timestamp(profile_name, self.POST_TIMESTAMP, timestamp)
def get_last_tagged_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's tagged posts."""
return self._get_timestamp(profile_name, self.TAGGED_TIMESTAMP)
def set_last_tagged_timestamp(self, profile_name: str, timestamp: datetime):
"""Sets timestamp of last download of a profile's tagged posts."""
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:
"""Returns timestamp of last download of a profile's igtv posts."""
return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP)
def set_last_igtv_timestamp(self, profile_name: str, timestamp: datetime):
"""Sets timestamp of last download of a profile's igtv posts."""
self._set_timestamp(profile_name, self.IGTV_TIMESTAMP, timestamp)
def get_last_story_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's stories."""
return self._get_timestamp(profile_name, self.STORY_TIMESTAMP)
def set_last_story_timestamp(self, profile_name: str, timestamp: datetime):
"""Sets timestamp of last download of a profile's stories."""
self._set_timestamp(profile_name, self.STORY_TIMESTAMP, timestamp)
def get_profile_pic(self, profile_name: str) -> str:
"""Returns filename of profile's last downloaded profile pic."""
try:
return self.data.get(profile_name, self.PROFILE_PIC)
except configparser.Error:
return ""
def set_profile_pic(self, profile_name: str, profile_pic: str):
"""Sets filename of profile's last downloaded profile pic."""
self._ensure_section(profile_name)
self.data.set(profile_name, self.PROFILE_PIC, profile_pic)
self._save()
+98 -43
View File
@@ -7,17 +7,19 @@ from datetime import datetime, timedelta
from lzma import LZMAError
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar
from .exceptions import AbortDownloadException, InvalidArgumentException, QueryReturnedBadRequestException
from .exceptions import InvalidArgumentException
from .instaloadercontext import InstaloaderContext
FrozenNodeIterator = NamedTuple('FrozenNodeIterator',
[('query_hash', str),
('query_variables', Dict),
('query_referer', Optional[str]),
('context_username', Optional[str]),
('total_index', int),
('best_before', Optional[float]),
('remaining_data', Optional[Dict])])
class FrozenNodeIterator(NamedTuple):
query_hash: Optional[str]
query_variables: Dict
query_referer: Optional[str]
context_username: Optional[str]
total_index: int
best_before: Optional[float]
remaining_data: Optional[Dict]
first_node: Optional[Dict]
doc_id: Optional[str]
FrozenNodeIterator.query_hash.__doc__ = """The GraphQL ``query_hash`` parameter."""
FrozenNodeIterator.query_variables.__doc__ = """The GraphQL ``query_variables`` parameter."""
FrozenNodeIterator.query_referer.__doc__ = """The HTTP referer used for the GraphQL query."""
@@ -26,7 +28,8 @@ FrozenNodeIterator.total_index.__doc__ = """Number of items that have already be
FrozenNodeIterator.best_before.__doc__ = """Date when parts of the stored nodes might have expired."""
FrozenNodeIterator.remaining_data.__doc__ = \
"""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.doc_id.__doc__ = """The GraphQL ``doc_id`` parameter."""
T = TypeVar('T')
@@ -63,21 +66,27 @@ class NodeIterator(Iterator[T]):
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.
.. 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)
def __init__(self,
context: InstaloaderContext,
query_hash: str,
query_hash: Optional[str],
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
node_wrapper: Callable[[Dict], T],
query_variables: Optional[Dict[str, Any]] = 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,
doc_id: Optional[str] = None):
self._context = context
self._query_hash = query_hash
self._doc_id = doc_id
self._edge_extractor = edge_extractor
self._node_wrapper = node_wrapper
self._query_variables = query_variables if query_variables is not None else {}
@@ -89,28 +98,42 @@ class NodeIterator(Iterator[T]):
self._best_before = datetime.now() + NodeIterator._shelf_life
else:
self._data = self._query()
self._first_node: Optional[Dict] = None
self._is_first = is_first
def _query(self, after: Optional[str] = None) -> Dict:
pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any]
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
try:
data = self._edge_extractor(
self._context.graphql_query(
self._query_hash, {**self._query_variables, **pagination_variables}, self._query_referer
)
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
except QueryReturnedBadRequestException:
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
)
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}
if after is not None:
pagination_variables['after'] = after
data = self._edge_extractor(
self._context.graphql_query(
query_hash, {**self._query_variables, **pagination_variables}, self._query_referer
)
)
self._best_before = datetime.now() + NodeIterator._shelf_life
return data
def __iter__(self):
return self
@@ -125,17 +148,25 @@ class NodeIterator(Iterator[T]):
except KeyboardInterrupt:
self._page_index, self._total_index = page_index, total_index
raise
return self._node_wrapper(node)
if self._data['page_info']['has_next_page']:
item = self._node_wrapper(node)
if self._is_first is not None:
if self._is_first(item, self.first_item):
self._first_node = node
else:
if self._first_node is None:
self._first_node = node
return item
if self._data.get('page_info', {}).get('has_next_page'):
query_response = self._query(self._data['page_info']['end_cursor'])
page_index, data = self._page_index, self._data
try:
self._page_index = 0
self._data = query_response
except KeyboardInterrupt:
self._page_index, self._data = page_index, data
raise
return self.__next__()
if self._data['edges'] != query_response['edges'] and len(query_response['edges']) > 0:
page_index, data = self._page_index, self._data
try:
self._page_index = 0
self._data = query_response
except KeyboardInterrupt:
self._page_index, self._data = page_index, data
raise
return self.__next__()
raise StopIteration()
@property
@@ -157,6 +188,25 @@ class NodeIterator(Iterator[T]):
).encode())
return base64.urlsafe_b64encode(magic_hash.digest()).decode()
@property
def first_item(self) -> Optional[T]:
"""
If this iterator has produced any items, returns the first item produced.
It is possible to override what is considered the first item (for example, to consider the
newest item in case items are not in strict chronological order) by passing a callback
function as the `is_first` parameter when creating the class.
.. versionadded:: 4.8
.. versionchanged:: 4.9.2
What is considered the first item can be overridden.
"""
return self._node_wrapper(self._first_node) if self._first_node is not None else None
@staticmethod
def page_length() -> int:
return NodeIterator._graphql_page_length
def freeze(self) -> FrozenNodeIterator:
"""Freeze the iterator for later resuming."""
remaining_data = None
@@ -171,6 +221,8 @@ class NodeIterator(Iterator[T]):
total_index=max(self.total_index - 1, 0),
best_before=self._best_before.timestamp() if self._best_before else None,
remaining_data=remaining_data,
first_node=self._first_node,
doc_id=self._doc_id,
)
def thaw(self, frozen: FrozenNodeIterator) -> None:
@@ -188,7 +240,8 @@ class NodeIterator(Iterator[T]):
if (self._query_hash != frozen.query_hash or
self._query_variables != frozen.query_variables 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.")
if not frozen.best_before:
raise InvalidArgumentException("\"best before\" date missing.")
@@ -197,6 +250,8 @@ class NodeIterator(Iterator[T]):
self._total_index = frozen.total_index
self._best_before = datetime.fromtimestamp(frozen.best_before)
self._data = frozen.remaining_data
if frozen.first_node is not None:
self._first_node = frozen.first_node
@contextmanager
@@ -259,11 +314,11 @@ def resumable_iteration(context: InstaloaderContext,
is_resuming = True
start_index = iterator.total_index
context.log("Resuming from {}.".format(resume_file_path))
except (InvalidArgumentException, LZMAError, json.decoder.JSONDecodeError) as exc:
except (InvalidArgumentException, LZMAError, json.decoder.JSONDecodeError, EOFError) as exc:
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
try:
yield is_resuming, start_index
except (KeyboardInterrupt, AbortDownloadException):
except (Exception, KeyboardInterrupt):
if os.path.dirname(resume_file_path):
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
save(iterator.freeze(), resume_file_path)
+46
View File
@@ -0,0 +1,46 @@
from typing import Any, Callable, Dict, Iterator, Optional, TypeVar
from .instaloadercontext import InstaloaderContext
T = TypeVar('T')
class SectionIterator(Iterator[T]):
"""Iterator for the new 'sections'-style responses.
.. versionadded:: 4.9"""
def __init__(self,
context: InstaloaderContext,
sections_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
media_wrapper: Callable[[Dict], T],
query_path: str,
first_data: Optional[Dict[str, Any]] = None):
self._context = context
self._sections_extractor = sections_extractor
self._media_wrapper = media_wrapper
self._query_path = query_path
self._data = first_data or self._query()
self._page_index = 0
self._section_index = 0
def __iter__(self):
return self
def _query(self, max_id: Optional[str] = None) -> Dict[str, Any]:
pagination_variables = {"max_id": max_id} if max_id is not None else {}
return self._sections_extractor(
self._context.get_json(self._query_path, params={"__a": 1, "__d": "dis", **pagination_variables})
)
def __next__(self) -> T:
if self._page_index < len(self._data['sections']):
media = self._data['sections'][self._page_index]['layout_content']['medias'][self._section_index]['media']
self._section_index += 1
if self._section_index >= len(self._data['sections'][self._page_index]['layout_content']['medias']):
self._section_index = 0
self._page_index += 1
return self._media_wrapper(media)
if self._data['more_available']:
self._page_index, self._section_index, self._data = 0, 0, self._query(self._data["next_max_id"])
return self.__next__()
raise StopIteration()
+1023 -161
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -18,10 +18,13 @@ def get_version():
raise SystemExit("Could not find version string.")
if sys.version_info < (3, 6):
sys.exit('Instaloader requires Python >= 3.6.')
if sys.version_info < (3, 10):
sys.exit('Instaloader requires Python >= 3.10.')
requirements = ['requests>=2.4']
requirements = ['requests>=2.25']
optional_requirements = {
'browser_cookie3': ['browser_cookie3>=0.19.1'],
}
keywords = (['instagram', 'instagram-scraper', 'instagram-client', 'instagram-feed', 'downloader', 'videos', 'photos',
'pictures', 'instagram-user-photos', 'instagram-photos', 'instagram-metadata', 'instagram-downloader',
@@ -43,7 +46,8 @@ setup(
'from Instagram.',
long_description=open(os.path.join(SRC, 'README.rst')).read(),
install_requires=requirements,
python_requires='>=3.6',
python_requires='>=3.10',
extras_require=optional_requirements,
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
zip_safe=False,
keywords=keywords,
@@ -54,10 +58,12 @@ setup(
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14',
'Programming Language :: Python :: 3.15',
'Programming Language :: Python :: 3 :: Only',
'Topic :: Internet',
'Topic :: Multimedia :: Graphics'
+4
View File
@@ -0,0 +1,4 @@
# Instaloader Tests
Integration tests for Instaloader features that require Instagram access are maintained here:
https://github.com/instaloader/instaloader-tests
-214
View File
@@ -1,214 +0,0 @@
"""Unit Tests for Instaloader"""
import os
import shutil
import tempfile
import unittest
from itertools import islice
from typing import Optional
import instaloader
PROFILE_WITH_HIGHLIGHTS = 325732271
PUBLIC_PROFILE = "selenagomez"
PUBLIC_PROFILE_ID = 460563723
PUBLIC_PROFILE_WITH_IGTV = "natgeo"
HASHTAG = "kitten"
LOCATION = "362629379"
OWN_USERNAME = "aandergr"
NORMAL_MAX_COUNT = 2
PAGING_MAX_COUNT = 15
PRIVATE_PROFILE = "aandergr"
PRIVATE_PROFILE_ID = 1706625676
EMPTY_PROFILE = "not_public"
EMPTY_PROFILE_ID = 1928659031
ratecontroller = None # type: Optional[instaloader.RateController]
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()