Compare commits

..
Author SHA1 Message Date
Alexander Graf 3338260f5d Release of version 4.16 2026-10-26 22:31:03 +01:00
Alexander Graf 49b0df42b4 Merge branch 'upcoming/v4.16' 2026-10-26 22:30:31 +01:00
Alexander Graf 756f097626 Prevent user_id setter 2026-10-26 22:12:33 +01:00
Vassilis Papadimas 9e0158de54 fix get_iphone_json: send derived bearer token and correct user_id (#2771) 2026-10-26 22:06:39 +01:00
Alexander Graf d035c9b7c7 Update dependencies 2026-10-26 21:54:49 +01:00
Alexander Graf 00a6784029 Fix Profile.from_id() 2026-10-26 21:44:16 +01:00
Alexander Graf 56493a8a78 First Release Candidate for Version 4.16 2026-10-24 16:21:33 +02:00
Alexander Graf a7119ef1e3 Merge branch 'master' into upcoming/v4.16 2026-10-24 16:05:19 +02:00
André Koch-Kramer bf3f6e2bfb Remove PolarisFeedShareMenu relay provider from NodeIterator 2026-10-24 15:14:18 +02:00
Alexander Graf 221cd7f8bc Add versionchanged directive for Profile.mediacount 2026-10-24 11:22:10 +02:00
Alexander Graf 328e93b0bc Annotate Profile.mediacount as Optional 2026-10-23 23:40:53 +02:00
Alexander Graf 653efed311 Query Post metadata through api/graphql when not logged in 2026-10-23 23:40:31 +02:00
Alexander Graf 133e5c442c Fetch clips play count only when Post.video_play_count is accessed 2026-10-23 23:40:13 +02:00
Alexander Graf 28d991f765 Fix Profile.mediacount from the logged-out profile page 2026-10-23 23:39:34 +02:00
Alexander Graf fa6baebc66 Raise LoginRequiredException in Profile.get_posts() for private profiles when not logged in 2026-10-23 23:36:53 +02:00
Alexander Graf c0e47314c2 Fix Profile.get_posts() when not logged in 2026-10-23 23:33:28 +02:00
Alexander Graf e628a8d04a Allow api/graphql queries when not logged in 2026-10-23 23:27:30 +02:00
Alexander Graf 3641b44796 First Beta Release for Version 4.16 2026-10-21 22:18:21 +02:00
Alexander Graf c56552c5e9 Merge branch 'master' into upcoming/v4.16 2026-10-21 22:01:45 +02:00
Alexander Graf 6cca56ec71 Do not let MyPy analyze the untyped browser_cookie3 module 2026-10-21 21:53:24 +02:00
Alexander Graf 93ff75f51c Annotate InstaloaderContext.username as Optional[str] 2026-10-21 21:53:18 +02:00
Alexander Graf 0cce956fac Remove the suggestion-mode option from .pylintrc
PyLint 4 removed it.
2026-10-21 21:53:12 +02:00
Alexander Graf 9252370503 Raise PrivateProfileNotFollowedException in Profile.get_posts() 2026-10-21 21:53:01 +02:00
Alexander Graf d972d9a48c Query logged-in profile posts through the web app's api/graphql endpoint
graphql/query answers with feedback_required; api/graphql needs the
fb_dtsg, lsd and av tokens of the logged-in page.
2026-10-21 21:48:43 +02:00
Mayank PadiaandClaude Opus 5.5 7d543dc644 Fix Profile.get_posts() when logged in by updating the posts query
Instagram now answers doc_id 28975909992013618 with 400 "feedback_required".
Use the query the web app currently sends for a profile's posts tab
(PolarisProfilePostsTabContentQuery_connection), doc_id 29104886249145907,
with its current relay provider variables.

(cherry picked from commit 804894a0b1, #2785)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:48:43 +02:00
Alexander Graf 4948d7a63c Share the page request of get_page_data() 2026-10-21 21:48:43 +02:00
Alexander Graf 1eab8cafda Report the login redirect in get_page_data()
Instead of reporting "302 Found" for profiles that need a login.
2026-10-21 21:44:35 +02:00
Alexander Graf 54033f9ea6 Share the session setup of the two GraphQL query methods 2026-10-21 21:43:47 +02:00
Alexander Graf bafd1bf14c Turn the retry in _retry_on_connection_error() into a loop 2026-10-21 21:34:24 +02:00
Alexander Graf 309708225c Remove the dead _attempt parameter of get_raw()
Unused since a9e79e9 moved the download retry into
_retry_on_connection_error().
2026-10-21 21:31:13 +02:00
Alexander Graf ba70aec490 Share the download status-code handling of get_raw() and head()
Kept separate from _raise_for_status(), as CDN downloads follow a
different status policy than the API.
2026-10-21 21:28:08 +02:00
Alexander Graf fb018c81ca Do not re-send POST data as query arguments when following a redirect 2026-10-21 21:28:08 +02:00
Alexander Graf a6c279211d Do not crash on a fatal status response without Content-Type 2026-10-21 21:22:27 +02:00
Alexander Graf f4e1f3c95e Refactor InstaloaderContext.get_json() for readability
Split it into small helpers and retry in a loop instead of recursively.
This also fixes retried POST queries being repeated as GET.
2026-10-21 21:21:14 +02:00
Alexander Graf 21d10a7f3e Merge branch 'master' into upcoming/v4.16 2026-10-12 22:07:09 +02:00
Alexander Graf c7d3e8dc0e Merge branch 'master' into upcoming/v4.16 2026-10-11 21:07:50 +02:00
Alexander Graf 0c143b6121 Merge branch 'master' into upcoming/v4.16 2026-07-26 11:19:13 +02:00
Alexander Graf b806153ab6 Merge branch 'master' into upcoming/v4.16 2026-03-21 22:07:52 +01:00
Luis Fernando Illapa 247bddcc9f Add progress counter to "Retrieving highlights" log line (#2634)
Introduce progress output for highlight retrieval, displaying a counter of
the current highlight index relative to the total number of highlights. This
mirrors the existing per-item download counter and provides consistent,
useful feedback when downloading multiple highlights. It uses the same
dynamic-width format as other Instaloader progress indicators.

To support this feature, the Iterator returned by get_highlights(user) is
materialized into a list so the total number of highlights can be determined
upfront. This does not alter functional behavior, since all highlights were
already iterated over, and preserves ordering while enabling fixed-width
formatted progress output.
2026-01-18 17:08:58 +01:00
11 changed files with 593 additions and 417 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
cache: 'pipenv'
- name: "Install Dependencies"
run: |
python -m pip install pipenv==2026.8.0
python -m pip install pipenv==2026.10.3
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: "Build Documentation"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
+2 -1
View File
@@ -22,9 +22,10 @@ jobs:
cache: 'pipenv'
- name: Install Dependencies
run: |
python -m pip install pipenv==2026.8.0
python -m pip install pipenv==2026.10.3
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint
if: matrix.python-version != '3.10'
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
- name: MyPy
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader
-4
View File
@@ -93,10 +93,6 @@ py-version=3.8
# Discover python modules and packages in the file system subtree.
recursive=no
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
Generated
+178 -178
View File
@@ -24,11 +24,11 @@
},
"certifi": {
"hashes": [
"sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775",
"sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"
"sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd",
"sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1"
],
"markers": "python_version >= '3.7'",
"version": "==2026.7.22"
"version": "==2026.10.23"
},
"charset-normalizer": {
"hashes": [
@@ -216,6 +216,14 @@
"markers": "python_version >= '3.9'",
"version": "==3.20"
},
"jeepney": {
"hashes": [
"sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683",
"sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"
],
"markers": "python_version >= '3.7'",
"version": "==0.9.0"
},
"lz4": {
"hashes": [
"sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc",
@@ -281,52 +289,52 @@
},
"pycryptodomex": {
"hashes": [
"sha256:0428f19f13452c6b89bbaf2c530f84f369873811dfa77f0cee0da4f40fb0474f",
"sha256:0afd3f4cb2bd690cbf2b24e5d330e883cd5d8dcf3370aafc2aed16f676fff91e",
"sha256:3b6276fe007464502df796710fb57dcfeb813b6ccac0eabaf85100be1ad358f2",
"sha256:408fb76e0af11937381532c992d9f7c0dcf987878cbe61d772b0e788343e81c5",
"sha256:42c8221e3a4ca04b070cb388fca004815f6544951bfb2cc6c7958e293b7caf64",
"sha256:556e063b52f338bd924fe5c40692cc1f50cb0453bd1d6580b7ffff2eade1a894",
"sha256:55f22d72250d7923aac3c2442ec6a1dd8774d2efa3bdcb0f68d5e137d372268e",
"sha256:582efdf767d72c12538949b876a64b9a7af30f2cff063c06dd63ece32c4e5c1d",
"sha256:5be79564e05ed620a63f353ea50e0d474eb598f70cda1b28c2ca8e9e2f02c139",
"sha256:5fa46af1281be707e0ec5878aea0f77d58d8d4b465f63f7385a71ae0080bfff9",
"sha256:6a62122fb4c85befcc01ec3a1ae5b2ba81689481aba1d383b4e21e34f18900dd",
"sha256:72742921e388856044163e711643244b6d5c7062846dc0676b1f9f0743846ab3",
"sha256:770db9a5b9d30d8a48486bc163870763f3fe0f983c780403cd15c332618fa51c",
"sha256:78bb214b8016aade97a357071d753a5c11687bce2ba693da48218c3472ece287",
"sha256:793b9f5ba7e9147a6a6d8e284854e3711e12f43d4ef57a26b3415b2455a9801a",
"sha256:7ffcedf4210d8c206fe9852ccde9f78ec9ce35e8039f0a989900ffe710e7a5db",
"sha256:8293a92e6f71d6de57364432ff0ba48244959a81c632574eb5586e7e67f5de59",
"sha256:88d37b28b450c06a03bd5570467c601fe8c7dbe493ca39771716f56fdbe35d6e",
"sha256:8a2a634c86c0a59b28b0d51bbf7e5d1feed00dc7ebdc789fe968f771f1430b47",
"sha256:8d6c5e01e4fb1ddd20cc24d4d8706643dd78b5279453dabcf10ba4e0683bf9b2",
"sha256:908f92fbc307b642b65580d0295698f423b057018d87ae43d441f1d1a00674a2",
"sha256:96b60c0f36ab7c11a826c116633b08afeac58b9b9c066a5ad7cd373ed996363c",
"sha256:99c19dcd9846d6bf9886eb758c6d8e06c5bfd8a0c83802551f1aa56e80fe13dc",
"sha256:abf768c285d289c4a0e0678f055663162c9d6257c79f5904fbd2692ade94da1c",
"sha256:b4f7f9c737ca1af9a79962d3289eaa6521869915ee9fbb9168e578277126691b",
"sha256:b5d0afc29fc461d599379f814f5ba378e58c69418588034619d76747bc2b5d5d",
"sha256:b6690bfae043c81108e28e31dc7e3afa8cb1b1eb0ba590fdbff2b66e4c6913e7",
"sha256:bb393480b5845ef4acbc9f713e4c473ad08be81bc3cdb5ef017cd02bdafd36e1",
"sha256:c2f42f24d7112ebb93d3aed581d011c82c20101da7d7a6248145c530e99bd2eb",
"sha256:c55e6c42f9eb43d90c15792bf69bc09cb3e13a5e55a30eecb555695a02b0d4c0",
"sha256:c7055400f773856cd219680d15525c6fb147237534cbe5394a5f92f57f7efaed",
"sha256:c7b8ad51baf3505dd3d94ec974855359d15769e45c58c2b89c6d4612b1f7ed94",
"sha256:da9996407d03684221a4e1603b70fad97d8772185e5f75addcd97fcbc4c3281f",
"sha256:dc685ab0b420597fe9a3187555c14d5b4d87c1518a99646397018457dc2a70c8",
"sha256:e42a2e9531c40315290278d9eee93ea5031eda3c83969f5709ada51a1c7491b4",
"sha256:e635c7ac056f2a47d79e2bf7042fb3fc6d175f47796a954388a3cec1f2e082a4",
"sha256:e8a7463d346015b42a0fb9c9179ebb7cccfd86bf40199c6796b546ac7be0b85c",
"sha256:eb14a078429434b017f1fb26a75a8fcd62c12befb6b3d37ca39353dc2b5843fd",
"sha256:ef2bd306328a8447d4d2af6037efddd67ef16630876ed3ec84babd16ca558941",
"sha256:f6af77f15a91bb6c16607067cfd201330cf5dfefe1c715e955be1fb06d368613",
"sha256:f96e10f0edff9d4b6b253e0a78f7c819749521d3ffe6af0139e08fcd35384405",
"sha256:fab40542a863cc142a763aa92630d613925fe3e937bea3ba5fb735e0c42d4f96",
"sha256:ff8c1a125b2a7a2933219e9e523aabca31c920839c3c10968b56ec9a76c81d1e"
"sha256:03e2027f81fe6b700e7ff614d79111559bfb05ed8c4a9de9d2152d1a0a0768de",
"sha256:09081666ffc599976c0b8b29caf2cf82212c0f05bed233c8f8ed53e4f6e1d356",
"sha256:147c742f73bbe8791d8c454b9972330d7ea501de94e8172957af375f9bdb4a7f",
"sha256:347c37708688414d9b3ba69e3a60df4c82f3d9f9d0fcc1d645f85252d997207b",
"sha256:3bbcc1807502da4b5d66c94357a99589c8547aa9ad2f9ecfa537b2e9a347538b",
"sha256:455596da1c6a1d534051c1883761f9c0122a8126375827dd7cb7f0bf83c63a3a",
"sha256:48d9058dd4c8f3af83e048b6ff83d9c6300841c42579f77fdfab19e49156a512",
"sha256:514d685de4b0227d35d7114047fbd575bf162bff373e07af665aa41eee089dd0",
"sha256:569fdbeff936cbd5beb852b3969dc2f58fb255bd221b3f7a8999c035c30ec958",
"sha256:5966f829f64c833cc72a8694cfee05ad50446a44167842c92dec4ebf6b84d72c",
"sha256:5b37b86a3771d6aa21cccf9f8448460e4be11e68bb8e699133ce632cb986f521",
"sha256:6159dc74824c591b4c294f8f96b74a71c3b5e9f637dab2f2da14cfa32dc24d47",
"sha256:637b4bbc8165921b13d2b9d706e3697a9c6077d7564bfb61a3b7005182b2a7ac",
"sha256:6773f8c65b6e3d7524f4e18542ac2aa8856979785792ca0359d69bca27a269c6",
"sha256:77a48a855776101a61b932ddedbf522e1ead9e20b321bec0f64dd7434ac4fcc3",
"sha256:77ef934c2b2eab3c57a431daf858fcbf272134691bcc8d729787d17ad4cbf758",
"sha256:794f32227a480ab3b39971ac4a53dfa8ed444e28c0ddecbdc35f26d038bb46da",
"sha256:7a81e9be7084af3591912475b22c5d0646a9160f3974b6b7300138781fffdc0d",
"sha256:7f30261641dac5ae60e0af2fb11ad7c87200575b0ce403f7531576b0f38a52be",
"sha256:82eb0dd8a95be97f03527b108ee49f9b86a7c534fa47fa7a510be3eab8ea9acd",
"sha256:8321c315c4418dfe45a811b792380438ec81185deaaf202dbcd77711b4f51796",
"sha256:8f27725ec41d6722bd4f729a403a38238d844127f39cf462991e693b9c9c2608",
"sha256:9a732b9f5603b153dcdc071d6b342f192981907519827f2c1e4aeac41e2b34c2",
"sha256:9b94a8c647c1f80b4c82c7ac9642dda94a71abbb4efa29ad652c410ad7a39879",
"sha256:9bb353c764c144a9fc03302ef3763ad0c3e75bc7ca41f445cc98455f36da6c59",
"sha256:9dd5104a1d8ccf725a3c1b40b21d5ed47530b6499abba30955c174d895c1be2f",
"sha256:9fd3b792942e3b0937f9e39a1c56030cc41bbea058fb5a8af652ff6fd02a553e",
"sha256:a692d2484ca8fa2c69f45c63f2b30cd00b8512834e3a522fc297d981dde5b34c",
"sha256:b1c7a61b0b644e9780aa9f6eee301fb77c813b1e41262f6f4f387de0558e8926",
"sha256:b299a1c10e45e1b71b34b048d555d3a8f366240942af89be5db379f88edb9542",
"sha256:b3eda6b9416ef35b232403caa2bc6e25cc87530448d505a45d859210eab74051",
"sha256:bd06ab1d9cf90e8b198daae3b364e9ac23a640d210bb8faddad6627cf2e33be7",
"sha256:bf9c87112ee78e21be984c5873c17615d7b3f7305ba01f472931dc77a70ffb26",
"sha256:c20fb5e8cf874dc182091d6df122b8b588501d23e7b500acf662c49899afdd0f",
"sha256:cc64f4e1fc07a155ef31eb9815183a6a8eb13dfd6876ab049c01125223aa0c40",
"sha256:cdec09935db4cbd74da5b577ea7d9959c73374060b22abf7d505429c0da7ed63",
"sha256:e87928d8f37952ba53d215836cb3d89c63c1367f037d055c3a5b3f4403f4c6b6",
"sha256:edc1deb28fceab6b78e12bae506eaef62ee2fc1b3cabc96f7932f0d51e368acd",
"sha256:eeac2c9acbd2d9f0ca493fdf692ce8245ca37cbebee08c496025868d4b8ef70f",
"sha256:f3ed97cdde1d96894057778095238bad3dd94d3e46dc07d6d618e6d4b7700cce",
"sha256:f87d74a804ca949f46a6babe70247a789f6401f229566e90174c4b7033b87cc5",
"sha256:fd487cc20730dc9d294e000126be86a02079485fa6cb723d8f01eddf86e96f5a",
"sha256:fe19e03b81aefdeaa579afc6262fc03504fce93455129d6ef0da79ddb48ca47d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'",
"version": "==3.24.0"
"markers": "python_version >= '2.7' and python_version != '3.0' and python_version != '3.1' and python_version != '3.2' and python_version != '3.3' and python_version != '3.4' and python_version != '3.5' and python_version != '3.6'",
"version": "==3.24.1"
},
"requests": {
"hashes": [
@@ -364,66 +372,66 @@
},
"ast-serialize": {
"hashes": [
"sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba",
"sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980",
"sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b",
"sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb",
"sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790",
"sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6",
"sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb",
"sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72",
"sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4",
"sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854",
"sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348",
"sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76",
"sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9",
"sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca",
"sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f",
"sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976",
"sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672",
"sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317",
"sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345",
"sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90",
"sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3",
"sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b",
"sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca",
"sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3",
"sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a",
"sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31",
"sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c",
"sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a",
"sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700",
"sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace",
"sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493",
"sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515",
"sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7",
"sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01",
"sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5",
"sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303",
"sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba",
"sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460",
"sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c",
"sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f",
"sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82",
"sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db",
"sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6",
"sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14",
"sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25",
"sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093",
"sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89",
"sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085",
"sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52",
"sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37",
"sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f",
"sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e",
"sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7",
"sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7",
"sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198",
"sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d",
"sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96"
"sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5",
"sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8",
"sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd",
"sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605",
"sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a",
"sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6",
"sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5",
"sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568",
"sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6",
"sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4",
"sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47",
"sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c",
"sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456",
"sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025",
"sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769",
"sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43",
"sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489",
"sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede",
"sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a",
"sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4",
"sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593",
"sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd",
"sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad",
"sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8",
"sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011",
"sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d",
"sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a",
"sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82",
"sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8",
"sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96",
"sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117",
"sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd",
"sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611",
"sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98",
"sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b",
"sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51",
"sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef",
"sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331",
"sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8",
"sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3",
"sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226",
"sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e",
"sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6",
"sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49",
"sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7",
"sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c",
"sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe",
"sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5",
"sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a",
"sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6",
"sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9",
"sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8",
"sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7",
"sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372",
"sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037",
"sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa",
"sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0"
],
"markers": "python_version >= '3.7'",
"version": "==0.12.1"
"version": "==0.13.1"
},
"astroid": {
"hashes": [
@@ -443,11 +451,11 @@
},
"certifi": {
"hashes": [
"sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775",
"sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"
"sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd",
"sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1"
],
"markers": "python_version >= '3.7'",
"version": "==2026.7.22"
"version": "==2026.10.23"
},
"charset-normalizer": {
"hashes": [
@@ -638,11 +646,11 @@
},
"docutils": {
"hashes": [
"sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968",
"sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de"
"sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea",
"sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e"
],
"markers": "python_version >= '3.9'",
"version": "==0.22.4"
"version": "==0.23"
},
"idna": {
"hashes": [
@@ -854,14 +862,6 @@
"markers": "python_version >= '3.9'",
"version": "==0.16.0"
},
"macholib": {
"hashes": [
"sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea",
"sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"
],
"markers": "sys_platform == 'darwin'",
"version": "==1.16.4"
},
"markupsafe": {
"hashes": [
"sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98",
@@ -1130,11 +1130,11 @@
},
"platformdirs": {
"hashes": [
"sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250",
"sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7"
"sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0",
"sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"
],
"markers": "python_version >= '3.10'",
"version": "==4.12.4"
"markers": "python_version >= '3.11'",
"version": "==4.13.5"
},
"prettytable": {
"hashes": [
@@ -1196,25 +1196,25 @@
"sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9"
],
"index": "pypi",
"markers": "python_version < '3.16' and python_version >= '3.8'",
"markers": "python_version >= '3.8' and python_version < '3.16'",
"version": "==6.22.3"
},
"pyinstaller-hooks-contrib": {
"hashes": [
"sha256:4d825786ad7a9b7dbcc52d612748a34562fe273461bcfd88f4d549c594bbf8f9",
"sha256:6d21d65323ea4467753afc21f52d7d0e69416ae8101dd88269bf273ad48db084"
"sha256:2748767901acb5cecd1304652b6c7add5bffcff1d71d01207fe24a5d42942a56",
"sha256:fb3e67dd64122561724c48ad0366ac9e8c16e638b63bf7d85de03bf3fdbf06c3"
],
"markers": "python_version >= '3.8'",
"version": "==2026.8"
"version": "==2026.9"
},
"pylint": {
"hashes": [
"sha256:235f13dc418c0041c649b42a5c35c99f2ffc6ca8b6a7574958eac5335906a68a",
"sha256:e34c8636edc7d60a2eaadca4ad6c18a49e202361987a26e81b2a17fa92be28e1"
"sha256:9928603068edfa0d1a3c167f174b099d4b97c3db75d32d0fcdd029770b4713a9",
"sha256:a85357cae24f33ad8d86c8f3daaa92c600ae4012b54a57299cee76000e9364cf"
],
"index": "pypi",
"markers": "python_full_version >= '3.10.0'",
"version": "==4.1.2"
"version": "==4.1.3"
},
"pywin32-ctypes": {
"hashes": [
@@ -1244,11 +1244,11 @@
},
"setuptools": {
"hashes": [
"sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670",
"sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73"
"sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc",
"sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2"
],
"markers": "python_version >= '3.10'",
"version": "==84.0.0"
"version": "==84.2.1"
},
"snowballstemmer": {
"hashes": [
@@ -1260,21 +1260,21 @@
},
"sphinx": {
"hashes": [
"sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb",
"sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978"
"sha256:4c3ec13b612712c0d3ce4cbdd9e3eb91e68577e07a8e31f32f21cf178453578c",
"sha256:90470459f3cab15d9dfed146c71cf77694b0445df17b2e0e69059cb9f7fc9ee4"
],
"index": "pypi",
"markers": "python_version >= '3.12'",
"version": "==9.1.0"
"markers": "python_version >= '3.13'",
"version": "==9.2.0"
},
"sphinx-autodoc-typehints": {
"hashes": [
"sha256:f5bac0996e76a2007fa26bc5393bb065ceb6cb4d062283fb5c69830c791c9716",
"sha256:f5db988060f68a231dbc230df97d81421e54a0860778a63216b9f71958a9afdd"
"sha256:b0ad1f96503d69b63c2a1c043f12e2a727c9549e6503d4adbf2125dbf5ff50ed",
"sha256:de41ed4001f4818bb6d37c22fd10ee44a93e6c8e945c6c7e3984e28a9df87d1c"
],
"index": "pypi",
"markers": "python_version >= '3.12'",
"version": "==3.13.9"
"version": "==3.17.2"
},
"sphinxcontrib-applehelp": {
"hashes": [
@@ -1311,11 +1311,11 @@
},
"sphinxcontrib-jsmath": {
"hashes": [
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
"sha256:15ca92fe0eec82f0baf91a7efcc51ffacdb605924be6327683a52ebdacb6744c",
"sha256:551f780cf9687f9c3c74f0cf9f18aca06e0d6958853453f3803fe4d357c5b924"
],
"markers": "python_version >= '3.5'",
"version": "==1.0.1"
"markers": "python_version >= '3.9'",
"version": "==2.0.0"
},
"sphinxcontrib-qthelp": {
"hashes": [
@@ -1407,11 +1407,11 @@
},
"tomlkit": {
"hashes": [
"sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304",
"sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"
"sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437",
"sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6"
],
"markers": "python_version >= '3.9'",
"version": "==0.15.1"
"version": "==0.15.2"
},
"types-requests": {
"hashes": [
@@ -1440,38 +1440,38 @@
},
"wcwidth": {
"hashes": [
"sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2",
"sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b",
"sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2",
"sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270",
"sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec",
"sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec",
"sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9",
"sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724",
"sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8",
"sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c",
"sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892",
"sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724",
"sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04",
"sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14",
"sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389",
"sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07",
"sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e",
"sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed",
"sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76",
"sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79",
"sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17",
"sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b",
"sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa",
"sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e",
"sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7",
"sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d",
"sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7",
"sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4",
"sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f"
"sha256:093469e01d881bff3db93ff3f56c364dc87c441ea01f9f41c22de0c0bdf5c6ef",
"sha256:12786620375dc83fabf4b2b2aa52416fef2bc6094522ea29c8a53c75b0dfe9b6",
"sha256:14410eb73178cc492aa9bf22f7c12e3bf930e5470b7220cf6f530efbfa2e03f3",
"sha256:17619066f95c10f44d7ae08ee456602456bc66cc9d5e91966ec73d799acebc5f",
"sha256:29c0685f5f5f8815b88308fdcbe2cd12a92996a21a5b1e18352d165174ef7a6d",
"sha256:2b58fc8cf2de1bf9cdb68b8beaa5be198742414a7c8cd97aa51ec039783ffa49",
"sha256:2cca7b5d0b9d0ae49068d3ce806e965326a987f555b62eaa322223396e10d01b",
"sha256:4831662c396dd4c59a016ec53c0ac96738006d653a00482777d9a2e2ea323d62",
"sha256:79e09e9824c88f5e275ca99e921685567a5c5c1bb6165c16e27ddd1adab33a95",
"sha256:87e4101789415a6cf55ec4c53eceeb4782c2654c88692941cb588f19dca1959f",
"sha256:8f892713ab5795d3abae181e9c02db0eb24de4084f9ee080535369d3d98d7bb5",
"sha256:901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d",
"sha256:9c4b0dd04cca520792100f6110d10be262c94b285a281d1759c551f159214633",
"sha256:9e5090d253f5df99659cddfd3ed90dd21f6ff51989b0421331a63fb2681cbe27",
"sha256:a1526cd25c6420e5153bb7d764c7509e692215ee3ad9b725db1ad665d47eae09",
"sha256:a8c9ce14c6b4a499e61de6de7e9e456de11be8bc991554e2ab2ed4feb40b9664",
"sha256:ab1e5190fb82476e75fbf963e9d59b4fb1c1e1f4ced82069a0c989016aa959f7",
"sha256:b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2",
"sha256:b52f265a694dda6b3ad9050c8bd89158ad519109ef7c5eb110b906cc49f25763",
"sha256:b7969c9aa258d536df54c8ac8af5cad0c74a6268c9972e1fb60a5f29feffb66d",
"sha256:befbf46467100e43fd8c8cc0c031a0105f13f5418c3be95fdcf0c47214487967",
"sha256:c36954849aafae1092d6f9de9548c5280c1d6841637a27ca288b2241c802c4ab",
"sha256:d68f09093c9bec9226a52edc47487042ed4f64c5df91d06f00419495071c0279",
"sha256:d76b7c715c5ca0103b28915f699f1020ae21f06f1d22cdd85a7a97f7b1536eed",
"sha256:d8905f1390d828d745fdd1904374c90386d902d3cfa9ee88e9875099f75a4e17",
"sha256:e05458491759aa42b7b85f36345b3e6df082d6d2d9bf616299668fd598e0209d",
"sha256:f279f32c8e206a9092fa350e53ca0c2bd8072e45b1c9fd31bb6d476b59b591c4",
"sha256:f28d77153303450ee13ebbe888297e75cf532c754555b6cabafc9bdda16c421c",
"sha256:fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954"
],
"markers": "python_version >= '3.9'",
"version": "==0.9.2"
"version": "==0.9.4"
}
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ bundled_packages = ' '.join(list(locked_packages) + ['psutil', 'pyinstaller'])
os.makedirs('dist/licenses', exist_ok=True)
# install dependencies, invoke PyInstaller and collect licenses
commands = ["pip install pipenv==2026.8.0",
commands = ["pip install pipenv==2026.10.3",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec",
f"pipenv run pip-licenses --packages {bundled_packages} --with-license-file "
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.15.5'
__version__ = '4.16'
try:
+1 -1
View File
@@ -16,7 +16,7 @@ from .instaloader import (get_default_session_filename, get_default_stamps_filen
from .instaloadercontext import default_user_agent
from .lateststamps import LatestStamps
try:
import browser_cookie3
import browser_cookie3 # type: ignore[import-untyped]
bc3_library = True
except ImportError:
bc3_library = False
+40 -23
View File
@@ -85,31 +85,36 @@ def _requires_login(func: Callable) -> Callable:
def _retry_on_connection_error(func: Callable) -> Callable:
"""Decorator to retry the function max_connection_attempts number of times.
Herewith-decorated functions need an ``_attempt`` keyword argument.
This is to decorate functions that do network requests that may fail. Note that
:meth:`.get_json`, :meth:`.get_iphone_json`, :meth:`.graphql_query` and :meth:`.graphql_node_list` already have
their own logic for retrying, hence functions that only use these for network access must not be decorated with this
decorator."""
decorator.
.. versionchanged:: 4.16
Decorated functions no longer need to accept an ``_attempt`` keyword argument.
"""
@wraps(func)
def call(instaloader, *args, **kwargs):
try:
return func(instaloader, *args, **kwargs)
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err)
if (kwargs.get('_attempt') or 1) == instaloader.context.max_connection_attempts:
raise ConnectionException(error_string) from None
instaloader.context.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
def call(instaloader, *args, **kwargs) -> Any:
# The failure we are currently retrying, if any. It tells us whether a ^C means "skip this
# download" rather than "abort everything", and whether to sleep before the next attempt.
pending_error_string: Optional[str] = None
for attempt in range(1, instaloader.context.max_connection_attempts + 1):
try:
if kwargs.get('_attempt'):
kwargs['_attempt'] += 1
else:
kwargs['_attempt'] = 2
instaloader.context.do_sleep()
return call(instaloader, *args, **kwargs)
if pending_error_string is not None:
instaloader.context.do_sleep()
return func(instaloader, *args, **kwargs)
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err)
if attempt == instaloader.context.max_connection_attempts:
raise ConnectionException(error_string) from None
instaloader.context.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
pending_error_string = error_string
except KeyboardInterrupt:
if pending_error_string is None:
raise
instaloader.context.error("[skipped by user]", repeat_at_end=False)
raise ConnectionException(error_string) from None
raise ConnectionException(pending_error_string) from None
raise AssertionError("_retry_on_connection_error() retry loop exited without result")
return call
@@ -335,7 +340,7 @@ class Instaloader:
@_retry_on_connection_error
def download_pic(self, filename: str, url: str, mtime: datetime,
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool:
filename_suffix: Optional[str] = None) -> bool:
"""Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated."""
if filename_suffix is not None:
@@ -519,8 +524,9 @@ class Instaloader:
'{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension))
@_retry_on_connection_error
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile],
_attempt: int = 1) -> None:
def download_title_pic(
self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile]
) -> None:
"""Downloads and saves a picture that does not have an association with a Post or StoryItem, such as a
Profile picture or a Highlight cover picture. Modification time is taken from the HTTP response headers.
@@ -963,6 +969,11 @@ class Instaloader:
.. versionchanged:: 4.3
Also downloads and saves the Highlight's cover pictures.
.. versionchanged:: 4.16
Add progress output for highlight retrieval, displaying a counter of the current
highlight number and the total number of highlights being processed. This provides
feedback similar to the per-item download counter already present in this function.
:param user: ID or Profile of the user whose highlights should get downloaded.
:param fast_update: If true, abort when first already-downloaded picture is encountered
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
@@ -970,14 +981,20 @@ class Instaloader:
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
:raises LoginRequiredException: If called without being logged in.
"""
for user_highlight in self.get_highlights(user):
user_highlights = list(self.get_highlights(user))
hl_size = len(user_highlights)
for hl_number, user_highlight in enumerate(user_highlights, start=1):
name = user_highlight.owner_username
highlight_target: Union[str, Path] = (filename_target
if filename_target
else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) /
_PostPathFormatter.sanitize_path(user_highlight.title,
self.sanitize_paths)))
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
self.context.log(
"[{0:{w}d}/{1:{w}d}] Retrieving highlights \"{2}\" from profile {3}".format(
hl_number, hl_size, user_highlight.title, name, w=len(str(hl_size))
)
)
self.download_highlight_cover(user_highlight, highlight_target)
totalcount = user_highlight.itemcount
count = 1
+292 -173
View File
@@ -9,6 +9,7 @@ import textwrap
import time
import urllib.parse
import uuid
from base64 import b64encode
from contextlib import contextmanager, suppress
from datetime import datetime, timedelta
from functools import partial
@@ -101,8 +102,7 @@ class InstaloaderContext:
self.user_agent = user_agent if user_agent is not None else default_user_agent()
self.request_timeout = request_timeout
self._session = self.get_anonymous_session()
self.username = None
self.user_id = None
self.username: Optional[str] = None
self.sleep = sleep
self.quiet = quiet
self.max_connection_attempts = max_connection_attempts
@@ -125,15 +125,16 @@ class InstaloaderContext:
# Cache profile from id (mapping from id to Profile)
self.profile_id_cache: Dict[int, Any] = dict()
# Tokens of the logged-in web page for api/graphql queries, by sessionid cookie
self._web_api_tokens_cache: Dict[str, Dict[str, str]] = dict()
@contextmanager
def anonymous_copy(self):
session = self._session
username = self.username
user_id = self.user_id
iphone_headers = self.iphone_headers
self._session = self.get_anonymous_session()
self.username = None
self.user_id = None
self.iphone_headers = default_iphone_headers()
try:
yield self
@@ -141,9 +142,22 @@ class InstaloaderContext:
self._session.close()
self.username = username
self._session = session
self.user_id = user_id
self.iphone_headers = iphone_headers
@property
def user_id(self) -> Optional[str]:
"""ID of the user this session is logged in as.
Derived from the ``ds_user_id`` cookie of the current session, so it is set for every
logged-in session, and ``None`` if not logged in."""
return self._session.cookies.get_dict().get('ds_user_id') or None
@user_id.setter
def user_id(self, user_id: Optional[str]) -> None:
# The setter is only provided for backwards-compatibility with pre-4.16.
if user_id != self.user_id:
raise AttributeError("user_id cannot be set to a different value.")
@property
def is_logged_in(self) -> bool:
"""True, if this Instaloader instance is logged in."""
@@ -351,7 +365,6 @@ class InstaloaderContext:
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session
self.username = user
self.user_id = resp_json['userId']
def two_factor_login(self, two_factor_code):
"""Second step of login if 2FA is enabled.
@@ -401,8 +414,102 @@ class InstaloaderContext:
f" when accessing {resp.url}"
)
@staticmethod
def _rate_limit_key(path: str, params: Dict[str, Any], host: str) -> str:
"""Determine the :class:`RateController` bucket that a request is accounted to."""
if 'graphql/query' in path or path == 'api/graphql':
if 'query_hash' in params:
return params['query_hash']
if 'doc_id' in params:
return params['doc_id']
if host == 'i.instagram.com':
return 'iphone'
return 'other'
def _raise_if_fatal_status(self, resp: requests.Response, host: str, path: str) -> None:
if resp.status_code not in self.fatal_status_codes:
return
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
body = ""
if resp.headers.get('Content-Type', '').startswith('application/json'):
body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '')
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format(
host, path, resp.status_code, resp.reason, redirect, body
))
def _raise_if_login_redirect(self, resp: requests.Response) -> None:
"""Turn a redirect to the login page into a meaningful exception.
Instagram answers requests it will not serve anonymously with a redirect to the login page
rather than with an error status, so this needs to be checked wherever we do not follow
redirects automatically.
"""
if not resp.is_redirect:
return
redirect_url = resp.headers.get('location', '')
if not (redirect_url.startswith('https://www.instagram.com/accounts/login') or
redirect_url.startswith('https://i.instagram.com/accounts/login')):
return
if not self.is_logged_in:
raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.")
raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " +
"some time, recreate the session and try again")
def _follow_redirects(self, sess: requests.Session, resp: requests.Response,
host: str, path: str, params: Dict[str, Any], use_post: bool) -> requests.Response:
"""Follow redirects that stay on *host*, and reject those to the login page."""
# Redirects are followed with GET. For a POST request, `params` is the request body and must not be
# turned into query arguments.
redirect_params = None if use_post else params
while resp.is_redirect:
redirect_url = resp.headers['location']
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
self._raise_if_login_redirect(resp)
if not redirect_url.startswith('https://{}/'.format(host)):
break
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
params=redirect_params, allow_redirects=False)
return resp
def _raise_for_status(self, resp: requests.Response) -> None:
"""Translate an unsuccessful HTTP status code into the corresponding exception."""
if resp.status_code == 400:
with suppress(json.decoder.JSONDecodeError):
if resp.json().get("message") in [
"feedback_required",
"checkpoint_required",
"challenge_required",
]:
# Raise AbortDownloadException in case of substantial Instagram
# requirements to stop producing more requests
raise AbortDownloadException(self._response_error(resp))
raise QueryReturnedBadRequestException(self._response_error(resp))
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code == 429:
raise TooManyRequestsException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
def _raise_for_download_status(self, resp: requests.Response) -> None:
"""Translate an unsuccessful status code of an anonymous download request into an exception.
This is deliberately a different policy from :meth:`_raise_for_status`, which applies to the
JSON API: a download may be answered with 403 for an invalid URL signature, and is never
answered with the 400 or 429 that the API uses.
"""
if resp.status_code == 200:
return
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com',
session: Optional[requests.Session] = None, _attempt=1,
session: Optional[requests.Session] = None,
response_headers: Optional[Dict[str, Any]] = None,
use_post: bool = False) -> Dict[str, Any]:
"""JSON request to Instagram.
@@ -422,96 +529,112 @@ class InstaloaderContext:
.. versionchanged:: 4.13
Added `use_post` parameter.
.. versionchanged:: 4.16
Removed the internal `_attempt` parameter; retries are no longer implemented by recursion.
"""
is_graphql_query = 'query_hash' in params and 'graphql/query' in path
is_doc_id_query = 'doc_id' in params and 'graphql/query' in path
is_iphone_query = host == 'i.instagram.com'
is_other_query = not is_graphql_query and not is_doc_id_query and host == "www.instagram.com"
sess = session if session else self._session
try:
self.do_sleep()
if is_graphql_query:
self._rate_controller.wait_before_query(params['query_hash'])
if is_doc_id_query:
self._rate_controller.wait_before_query(params['doc_id'])
if is_iphone_query:
self._rate_controller.wait_before_query('iphone')
if is_other_query:
self._rate_controller.wait_before_query('other')
if use_post:
resp = sess.post('https://{0}/{1}'.format(host, path), data=params, allow_redirects=False)
else:
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
if resp.status_code in self.fatal_status_codes:
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
body = ""
if resp.headers['Content-Type'].startswith('application/json'):
body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '')
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format(
host, path, resp.status_code, resp.reason, redirect, body
))
while resp.is_redirect:
redirect_url = resp.headers['location']
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
if (redirect_url.startswith('https://www.instagram.com/accounts/login') or
redirect_url.startswith('https://i.instagram.com/accounts/login')):
if not self.is_logged_in:
raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.")
raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " +
"some time, recreate the session and try again")
if redirect_url.startswith('https://{}/'.format(host)):
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
params=params, allow_redirects=False)
else:
break
if response_headers is not None:
response_headers.clear()
response_headers.update(resp.headers)
if resp.status_code == 400:
with suppress(json.decoder.JSONDecodeError):
if resp.json().get("message") in [
"feedback_required",
"checkpoint_required",
"challenge_required",
]:
# Raise AbortDownloadException in case of substantial Instagram
# requirements to stop producing more requests
raise AbortDownloadException(self._response_error(resp))
raise QueryReturnedBadRequestException(self._response_error(resp))
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code == 429:
raise TooManyRequestsException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
else:
resp_json = resp.json()
if 'status' in resp_json and resp_json['status'] != "ok":
raise ConnectionException(self._response_error(resp))
return resp_json
except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err:
error_string = "JSON Query to {}: {}".format(path, err)
if _attempt == self.max_connection_attempts:
if isinstance(err, QueryReturnedNotFoundException):
raise QueryReturnedNotFoundException(error_string) from err
else:
raise ConnectionException(error_string) from err
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
sess = session or self._session
rate_limit_key = self._rate_limit_key(path, params, host)
url = 'https://{0}/{1}'.format(host, path)
# The failure we are currently retrying, if any. It drives the 429 backoff performed before the
# next attempt, and tells us whether a ^C means "skip this query" rather than "abort everything".
pending_error: Optional[BaseException] = None
pending_error_string = ""
for attempt in range(1, self.max_connection_attempts + 1):
try:
if isinstance(err, TooManyRequestsException):
if is_graphql_query:
self._rate_controller.handle_429(params['query_hash'])
if is_doc_id_query:
self._rate_controller.handle_429(params['doc_id'])
if is_iphone_query:
self._rate_controller.handle_429('iphone')
if is_other_query:
self._rate_controller.handle_429('other')
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1,
response_headers=response_headers)
if isinstance(pending_error, TooManyRequestsException):
self._rate_controller.handle_429(rate_limit_key)
self.do_sleep()
self._rate_controller.wait_before_query(rate_limit_key)
if use_post:
resp = sess.post(url, data=params, allow_redirects=False)
else:
resp = sess.get(url, params=params, allow_redirects=False)
self._raise_if_fatal_status(resp, host, path)
resp = self._follow_redirects(sess, resp, host, path, params, use_post)
if response_headers is not None:
response_headers.clear()
response_headers.update(resp.headers)
self._raise_for_status(resp)
resp_json = resp.json()
if 'status' in resp_json and resp_json['status'] != "ok":
raise ConnectionException(self._response_error(resp))
return resp_json
except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err:
error_string = "JSON Query to {}: {}".format(path, err)
if attempt == self.max_connection_attempts:
if isinstance(err, QueryReturnedNotFoundException):
raise QueryReturnedNotFoundException(error_string) from err
raise ConnectionException(error_string) from err
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
pending_error, pending_error_string = err, error_string
except KeyboardInterrupt:
if pending_error is None:
raise
self.error("[skipped by user]", repeat_at_end=False)
raise ConnectionException(error_string) from err
raise ConnectionException(pending_error_string) from pending_error
raise AssertionError("get_json() retry loop exited without result")
@contextmanager
def _graphql_session(self, referer: Optional[str], csrf: Optional[str] = None,
lsd: Optional[str] = None) -> Iterator[requests.Session]:
"""Short-lived session carrying the headers that the GraphQL endpoint expects.
Pass the page's `lsd` token for the web app's ``api/graphql`` endpoint."""
with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True))
del tmpsession.headers['Connection']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
if csrf is not None:
tmpsession.headers['x-csrftoken'] = csrf
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
if lsd is not None:
tmpsession.headers.update({'x-fb-lsd': lsd, 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin'})
yield tmpsession
def _csrf_token(self) -> str:
"""The csrftoken cookie value, requesting the start page once if we do not have one yet."""
def token_from_cookies() -> Optional[str]:
return next((c.value for c in self._session.cookies if c.name == 'csrftoken' and c.value), None)
csrf = token_from_cookies()
if csrf:
return csrf
self._session.get('https://www.instagram.com/', timeout=self.request_timeout)
return token_from_cookies() or ''
def _web_api_tokens(self) -> Dict[str, str]:
"""The tokens that the web app sends along with its api/graphql queries, from the start page."""
sessionid = self._session.cookies.get_dict().get("sessionid", "")
if sessionid not in self._web_api_tokens_cache:
html = self._get_page(self._session, "")
tokens = {}
for name, pattern in [
("fb_dtsg", r'"DTSGInitialData",\[\],\{[^{}]*"token":"([^"]+)"'),
("lsd", r'"LSD",\[\],\{[^{}]*"token":"([^"]+)"'),
("av", r'"IG_USER_EIMU":"?(\d+)'),
]:
match = re.search(pattern, html)
if match is not None:
tokens[name] = match.group(1)
# Logged out, the start page has no fb_dtsg, and the web app sends its queries without.
elif name != "fb_dtsg" or self.is_logged_in:
raise BadResponseException(
"The start page did not contain the token {}.".format(name)
)
self._web_api_tokens_cache[sessionid] = tokens
return self._web_api_tokens_cache[sessionid]
def graphql_query(self, query_hash: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]:
@@ -526,18 +649,8 @@ class InstaloaderContext:
.. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter.
"""
with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True))
del tmpsession.headers['Connection']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
variables_json = json.dumps(variables, separators=(',', ':'))
variables_json = json.dumps(variables, separators=(',', ':'))
with self._graphql_session(referer) as tmpsession:
resp_json = self.get_json('graphql/query',
params={'query_hash': query_hash,
'variables': variables_json},
@@ -547,7 +660,7 @@ class InstaloaderContext:
return resp_json
def doc_id_graphql_query(self, doc_id: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]:
referer: Optional[str] = None, web_api: bool = False) -> Dict[str, Any]:
"""
Do a doc_id-based GraphQL Query using method POST.
@@ -556,35 +669,23 @@ class InstaloaderContext:
:param doc_id: doc_id for the query.
:param variables: Variables for the Query.
:param referer: HTTP Referer, or None.
:param web_api: Query the web app's ``api/graphql`` endpoint, as the web app does, rather than
``graphql/query``.
:return: The server's response dictionary.
.. versionchanged:: 4.16
Added `web_api` parameter.
"""
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:
variables_json = json.dumps(variables, separators=(',', ':'))
params = {'variables': variables_json,
'doc_id': doc_id,
'server_timestamps': 'true',
**(self._web_api_tokens() if web_api else {})}
with self._graphql_session(referer, csrf=self._csrf_token(), lsd=params.get('lsd')) as tmpsession:
resp_json = self.get_json('api/graphql' if web_api else 'graphql/query', params=params,
session=tmpsession, use_post=True)
# api/graphql does not respond with a "status" field.
if not web_api and 'status' not in resp_json:
self.error("GraphQL response did not contain a \"status\" field.")
return resp_json
@@ -637,12 +738,33 @@ class InstaloaderContext:
.. versionadded:: 4.2.1"""
with copy_session(self._session, self.request_timeout) as tempsession:
# Set headers to simulate an API request from iPad
tempsession.headers['ig-intended-user-id'] = str(self.user_id)
# ig-intended-user-id should name the logged-in user; self.user_id is derived
# from the ds_user_id cookie, so it is set for any logged-in session. Without a
# logged-in user, omit the header rather than sending the literal string "None".
if self.user_id is not None:
tempsession.headers['ig-intended-user-id'] = self.user_id
tempsession.headers['x-pigeon-rawclienttime'] = '{:.6f}'.format(time.time())
# Add headers obtained from previous iPad request
tempsession.headers.update(self.iphone_headers)
# The mobile API requires an Authorization bearer token for most
# endpoints (reels_media, users/info, ...); without it they answer
# with empty data or a "fail" status. A token is handed out via
# the ig-set-authorization response header, but only by the few
# endpoints that still accept cookie-only requests (e.g.
# media/<id>/info/), so relying on having seen one makes success
# depend on request order. The token is deterministically derived
# from the session cookies, so construct it if none is known yet.
if 'authorization' not in tempsession.headers and self.user_id is not None:
sessionid = tempsession.cookies.get_dict().get('sessionid')
if sessionid:
token = b64encode(json.dumps(
{'ds_user_id': self.user_id,
'sessionid': sessionid},
separators=(',', ':')).encode()).decode()
tempsession.headers['authorization'] = 'Bearer IGT:2:{}'.format(token)
# Extract key information from cookies if we haven't got it already from a previous request
header_cookies_mapping = {'x-mid': 'mid',
'ig-u-ds-user-id': 'ds_user_id',
@@ -688,6 +810,25 @@ class InstaloaderContext:
return response
def _get_page(self, session: requests.Session, path: str) -> str:
"""Load a page of ``www.instagram.com`` and return its HTML."""
self.do_sleep()
self._rate_controller.wait_before_query('other')
url = 'https://www.instagram.com/{0}'.format(path)
# Instagram only embeds the data when the request looks like a browser's page navigation.
headers = {'Accept': 'text/html', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none'}
try:
resp = session.get(url, headers=headers, allow_redirects=False)
except requests.exceptions.RequestException as err:
raise ConnectionException("Page request to {}: {}".format(url, err)) from err
self._raise_if_login_redirect(resp)
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
return resp.text
def get_page_data(self, path: str) -> List[Dict[str, Any]]:
"""Load a page of ``www.instagram.com`` as a logged-out visitor and return the data it embeds.
@@ -699,23 +840,10 @@ class InstaloaderContext:
: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))
with self.get_anonymous_session() as anonymous_session:
html = self._get_page(anonymous_session, path)
data: List[Dict[str, Any]] = []
for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', resp.text, re.DOTALL):
for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', html, re.DOTALL):
with suppress(json.decoder.JSONDecodeError):
data.extend(_embedded_query_data(json.loads(script.group(1))))
return data
@@ -732,27 +860,26 @@ class InstaloaderContext:
file.write(resp)
os.replace(filename + '.temp', filename)
def get_raw(self, url: str, _attempt=1) -> requests.Response:
def get_raw(self, url: str) -> requests.Response:
"""Downloads a file anonymously.
This does not retry on its own; callers that need retrying are decorated with
``_retry_on_connection_error``.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises QueryReturnedForbiddenException: When the server responds with a 403.
:raises ConnectionException: When download failed.
.. versionadded:: 4.2.1"""
.. versionadded:: 4.2.1
.. versionchanged:: 4.16
Removed the `_attempt` parameter.
"""
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, stream=True)
if resp.status_code == 200:
resp.raw.decode_content = True
return resp
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
self._raise_for_download_status(resp)
resp.raw.decode_content = True
return resp
def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file.
@@ -773,16 +900,8 @@ class InstaloaderContext:
"""
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.head(url, allow_redirects=allow_redirects)
if resp.status_code == 200:
return resp
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp))
self._raise_for_download_status(resp)
return resp
class RateController:
+9 -4
View File
@@ -69,6 +69,9 @@ class NodeIterator(Iterator[T]):
.. versionchanged: 4.13
Included support for `doc_id`-based queries (using POST method).
.. versionchanged: 4.16
Added `web_api` parameter, see :meth:`InstaloaderContext.doc_id_graphql_query`.
"""
_graphql_page_length = 12
@@ -83,10 +86,12 @@ class NodeIterator(Iterator[T]):
query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T, Optional[T]], bool]] = None,
doc_id: Optional[str] = None):
doc_id: Optional[str] = None,
web_api: bool = False):
self._context = context
self._query_hash = query_hash
self._doc_id = doc_id
self._web_api = web_api
self._edge_extractor = edge_extractor
self._node_wrapper = node_wrapper
self._query_variables = query_variables if query_variables is not None else {}
@@ -110,15 +115,15 @@ class NodeIterator(Iterator[T]):
return self._query_query_hash(self._query_hash, after)
def _query_doc_id(self, doc_id: str, after: Optional[str] = None) -> Dict:
pagination_variables: Dict[str, Any] = {'__relay_internal__pv__PolarisFeedShareMenurelayprovider': False}
pagination_variables: Dict[str, Any] = {}
if after is not None:
pagination_variables['after'] = after
pagination_variables['before'] = None
pagination_variables['first'] = 12
pagination_variables['first'] = NodeIterator._graphql_page_length
pagination_variables['last'] = None
data = self._edge_extractor(
self._context.doc_id_graphql_query(
doc_id, {**self._query_variables, **pagination_variables}, self._query_referer
doc_id, {**self._query_variables, **pagination_variables}, self._query_referer, self._web_api
)
)
self._best_before = datetime.now() + NodeIterator._shelf_life
+68 -30
View File
@@ -279,7 +279,7 @@ class Post:
return None
@staticmethod
def _normalize_post_data(media: Dict[str, Any], context: 'InstaloaderContext') -> Dict[str, Any]:
def _normalize_post_data(media: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize a Polaris media item to a legacy-compatible node, preserving the source structure."""
media_types = {1: "GraphImage", 2: "GraphVideo", 8: "GraphSidecar"}
media_type = media.get("media_type")
@@ -306,10 +306,6 @@ class Post:
pic_json["video_duration"] = media.get("video_duration")
pic_json["video_view_count"] = media.get("view_count")
pic_json["video_play_count"] = media.get("play_count")
if media_type == 2 and pic_json["video_view_count"] is None:
pic_json["video_play_count"] = Post._fetch_play_count_from_clips(
context, media["user"]["pk"], media["code"]
)
caption = media.get("caption")
caption_text = caption.get("text") if isinstance(caption, dict) else None
pic_json["edge_media_to_caption"] = (
@@ -419,12 +415,14 @@ class Post:
"shortcode": self.shortcode,
"__relay_internal__pv__PolarisAIGMMediaWebLabelEnabledrelayprovider": False,
},
# As the logged-out web app does.
web_api=not self._context.is_logged_in,
)
web_info = (resp.get("data") or {}).get("xdt_api__v1__media__shortcode__web_info") or {}
items = web_info.get("items")
if not items:
raise BadResponseException("Fetching Post metadata failed.")
self._full_metadata_dict = Post._normalize_post_data(items[0], self._context)
self._full_metadata_dict = Post._normalize_post_data(items[0])
if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict)
raise PostChangedException
@@ -697,7 +695,11 @@ class Post:
.. versionadded:: 4.14.3"""
if self.is_video:
return self._field('video_play_count')
play_count = self._field('video_play_count')
# Not logged in, the clips connection is null.
if play_count is None and self.video_view_count is None and self._context.is_logged_in:
play_count = Post._fetch_play_count_from_clips(self._context, str(self.owner_id), self.shortcode)
return play_count
return None
@property
@@ -1025,16 +1027,24 @@ class Profile:
"""
if profile_id in context.profile_id_cache:
return context.profile_id_cache[profile_id]
# Resolve the current username from the user id, then load the full profile.
# The GraphQL user query previously used here started responding with HTTP 400.
try:
user = context.get_json(
"api/v1/users/{0}/info/".format(profile_id), params={}
).get('user')
user = context.doc_id_graphql_query(
'27937681195819736',
{
"id": str(profile_id),
"render_surface": "PROFILE",
"__relay_internal__pv__PolarisCannesGuardianExperienceEnabledrelayprovider": True,
"__relay_internal__pv__PolarisCASB976ProfileEnabledrelayprovider": False,
"__relay_internal__pv__PolarisRepostsConsumptionEnabledrelayprovider": False,
"__relay_internal__pv__PolarisWebSchoolsEnabledrelayprovider": False,
"enable_integrity_filters": True,
},
web_api=True,
).get('data', {}).get('user')
except QueryReturnedNotFoundException:
user = None
if user and user.get('username'):
profile = cls.from_username(context, user['username'])
profile = cls(context, cls._normalize_profile_data(user))
else:
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
str(profile_id) + ").")
@@ -1137,6 +1147,9 @@ class Profile:
normalized['id'] = normalized['pk']
if 'edge_owner_to_timeline_media' not in normalized and 'media_count' in normalized:
normalized['edge_owner_to_timeline_media'] = {'count': normalized['media_count']}
# The logged-out profile page has all_media_count instead.
if 'edge_owner_to_timeline_media' not in normalized and 'all_media_count' in normalized:
normalized['edge_owner_to_timeline_media'] = {'count': normalized['all_media_count']}
if 'edge_felix_video_timeline' not in normalized:
normalized['edge_felix_video_timeline'] = {'count': 0}
if 'edge_followed_by' not in normalized and 'follower_count' in normalized:
@@ -1222,7 +1235,11 @@ class Profile:
return self._metadata('followed_by_viewer')
@property
def mediacount(self) -> int:
def mediacount(self) -> Optional[int]:
"""Number of posts, or None if unavailable (e.g. when not logged in).
.. versionchanged:: 4.16
Returns None if unavailable (e.g. when not logged in)."""
return self._metadata('edge_owner_to_timeline_media', 'count')
@property
@@ -1374,35 +1391,53 @@ class Profile:
def get_posts(self) -> NodeIterator[Post]:
"""Retrieve all posts from a profile.
:rtype: NodeIterator[Post]"""
self._obtain_metadata()
:rtype: NodeIterator[Post]
:raises PrivateProfileNotFollowedException: When logged in and the profile is private and not followed.
:raises LoginRequiredException: When not logged in and the profile is private."""
if not self._context.is_logged_in:
# As the logged-out web app, take the first posts from the profile page, and query further ones
# (PolarisLoggedOutDesktopWWWProfilePostsTabContentQuery_connection) by the page's "id" of the
# profile, which is not its user ID.
page = self._context.get_page_data(f"{self.username}/")
# The page shows no posts of a private profile, rather than an error.
if any((data.get("xig_user_by_username") or {}).get("is_private") for data in page):
raise LoginRequiredException(f"Login required to access the posts of private profile {self.username}.")
user = next((data["xig_user_by_username"] for data in page
if "polaris_ordered_timeline_connection" in (data.get("xig_user_by_username") or {})),
None)
if user is None:
raise BadResponseException(f"The page of profile {self.username} did not contain its posts.")
return NodeIterator(
context=self._context,
edge_extractor=lambda d: d["data"]["user"]["edge_owner_to_timeline_media"],
node_wrapper=lambda n: Post(self._context, n, self),
edge_extractor=lambda d: d["data"]["node"]["polaris_ordered_timeline_connection"],
# The nodes lack most of the post's metadata, as in get_reels().
node_wrapper=lambda n: Post.from_shortcode(self._context, n["code"]),
query_variables={
"data": {
"count": 12,
"include_relationship_info": True,
"latest_besties_reel_media": True,
"latest_reel_media": True,
},
"id": self.userid,
"id": user["id"],
"__relay_internal__pv__PolarisSEOIsCrawlerrelayprovider": False,
},
query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(),
doc_id="7950326061742207",
doc_id="28904005159232689",
query_hash=None,
first_data=self._metadata("edge_owner_to_timeline_media"),
first_data=user["polaris_ordered_timeline_connection"],
web_api=True,
)
self._obtain_metadata()
# Instagram answers with an empty timeline rather than an error in that case. Unlike followed_by_viewer,
# which defaults to False, friendship_status tells whether the follow state is actually known.
if (self.is_private and self.username != (self._context.username or '').lower() and
(self._node.get('friendship_status') or {}).get('following') is False):
raise PrivateProfileNotFollowedException("Profile {} is private but not followed.".format(self.username))
return NodeIterator(
context=self._context,
edge_extractor=lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"],
node_wrapper=lambda n: Post.from_iphone_struct(self._context, n),
# The query and variables that the web app sends for a profile's posts tab
# (PolarisProfilePostsTabContentQuery_connection). Instagram answers the
# previous query, 7898261790222653, with "execution error".
# previous queries, 7898261790222653 ("execution error") and 28975909992013618
# (400 "feedback_required"), with errors.
# It must be sent to the web app's api/graphql endpoint.
query_variables={
"data": {
"count": 12,
@@ -1415,13 +1450,16 @@ class Profile:
"include_multi_captions": True,
"username": self.username,
"__relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider": True,
"__relay_internal__pv__PolarisShortDramaEnabledrelayprovider": False,
"__relay_internal__pv__PolarisShortDramaEnabledrelayprovider": True,
"__relay_internal__pv__PolarisSEOIsCrawlerrelayprovider": False,
"__relay_internal__pv__PolarisThreadsStyleCommentsEnabledrelayprovider": False,
"__relay_internal__pv__PolarisReelsRecoDebugOverlayEnabledrelayprovider": False,
},
query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(),
doc_id="28975909992013618",
doc_id="29104886249145907",
query_hash=None,
web_api=True,
)
def get_saved_posts(self) -> NodeIterator[Post]: