Compare commits

...
39 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' cache: 'pipenv'
- name: "Install Dependencies" - name: "Install Dependencies"
run: | 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 pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: "Build Documentation" - name: "Build Documentation"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n" run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
+2 -1
View File
@@ -22,9 +22,10 @@ jobs:
cache: 'pipenv' cache: 'pipenv'
- name: Install Dependencies - name: Install Dependencies
run: | 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 pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint - name: PyLint
if: matrix.python-version != '3.10'
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
- name: MyPy - name: MyPy
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader 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. # Discover python modules and packages in the file system subtree.
recursive=no 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 # Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code. # active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no unsafe-load-any-extension=no
Generated
+178 -178
View File
@@ -24,11 +24,11 @@
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd",
"sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2026.7.22" "version": "==2026.10.23"
}, },
"charset-normalizer": { "charset-normalizer": {
"hashes": [ "hashes": [
@@ -216,6 +216,14 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==3.20" "version": "==3.20"
}, },
"jeepney": {
"hashes": [
"sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683",
"sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"
],
"markers": "python_version >= '3.7'",
"version": "==0.9.0"
},
"lz4": { "lz4": {
"hashes": [ "hashes": [
"sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc",
@@ -281,52 +289,52 @@
}, },
"pycryptodomex": { "pycryptodomex": {
"hashes": [ "hashes": [
"sha256:0428f19f13452c6b89bbaf2c530f84f369873811dfa77f0cee0da4f40fb0474f", "sha256:03e2027f81fe6b700e7ff614d79111559bfb05ed8c4a9de9d2152d1a0a0768de",
"sha256:0afd3f4cb2bd690cbf2b24e5d330e883cd5d8dcf3370aafc2aed16f676fff91e", "sha256:09081666ffc599976c0b8b29caf2cf82212c0f05bed233c8f8ed53e4f6e1d356",
"sha256:3b6276fe007464502df796710fb57dcfeb813b6ccac0eabaf85100be1ad358f2", "sha256:147c742f73bbe8791d8c454b9972330d7ea501de94e8172957af375f9bdb4a7f",
"sha256:408fb76e0af11937381532c992d9f7c0dcf987878cbe61d772b0e788343e81c5", "sha256:347c37708688414d9b3ba69e3a60df4c82f3d9f9d0fcc1d645f85252d997207b",
"sha256:42c8221e3a4ca04b070cb388fca004815f6544951bfb2cc6c7958e293b7caf64", "sha256:3bbcc1807502da4b5d66c94357a99589c8547aa9ad2f9ecfa537b2e9a347538b",
"sha256:556e063b52f338bd924fe5c40692cc1f50cb0453bd1d6580b7ffff2eade1a894", "sha256:455596da1c6a1d534051c1883761f9c0122a8126375827dd7cb7f0bf83c63a3a",
"sha256:55f22d72250d7923aac3c2442ec6a1dd8774d2efa3bdcb0f68d5e137d372268e", "sha256:48d9058dd4c8f3af83e048b6ff83d9c6300841c42579f77fdfab19e49156a512",
"sha256:582efdf767d72c12538949b876a64b9a7af30f2cff063c06dd63ece32c4e5c1d", "sha256:514d685de4b0227d35d7114047fbd575bf162bff373e07af665aa41eee089dd0",
"sha256:5be79564e05ed620a63f353ea50e0d474eb598f70cda1b28c2ca8e9e2f02c139", "sha256:569fdbeff936cbd5beb852b3969dc2f58fb255bd221b3f7a8999c035c30ec958",
"sha256:5fa46af1281be707e0ec5878aea0f77d58d8d4b465f63f7385a71ae0080bfff9", "sha256:5966f829f64c833cc72a8694cfee05ad50446a44167842c92dec4ebf6b84d72c",
"sha256:6a62122fb4c85befcc01ec3a1ae5b2ba81689481aba1d383b4e21e34f18900dd", "sha256:5b37b86a3771d6aa21cccf9f8448460e4be11e68bb8e699133ce632cb986f521",
"sha256:72742921e388856044163e711643244b6d5c7062846dc0676b1f9f0743846ab3", "sha256:6159dc74824c591b4c294f8f96b74a71c3b5e9f637dab2f2da14cfa32dc24d47",
"sha256:770db9a5b9d30d8a48486bc163870763f3fe0f983c780403cd15c332618fa51c", "sha256:637b4bbc8165921b13d2b9d706e3697a9c6077d7564bfb61a3b7005182b2a7ac",
"sha256:78bb214b8016aade97a357071d753a5c11687bce2ba693da48218c3472ece287", "sha256:6773f8c65b6e3d7524f4e18542ac2aa8856979785792ca0359d69bca27a269c6",
"sha256:793b9f5ba7e9147a6a6d8e284854e3711e12f43d4ef57a26b3415b2455a9801a", "sha256:77a48a855776101a61b932ddedbf522e1ead9e20b321bec0f64dd7434ac4fcc3",
"sha256:7ffcedf4210d8c206fe9852ccde9f78ec9ce35e8039f0a989900ffe710e7a5db", "sha256:77ef934c2b2eab3c57a431daf858fcbf272134691bcc8d729787d17ad4cbf758",
"sha256:8293a92e6f71d6de57364432ff0ba48244959a81c632574eb5586e7e67f5de59", "sha256:794f32227a480ab3b39971ac4a53dfa8ed444e28c0ddecbdc35f26d038bb46da",
"sha256:88d37b28b450c06a03bd5570467c601fe8c7dbe493ca39771716f56fdbe35d6e", "sha256:7a81e9be7084af3591912475b22c5d0646a9160f3974b6b7300138781fffdc0d",
"sha256:8a2a634c86c0a59b28b0d51bbf7e5d1feed00dc7ebdc789fe968f771f1430b47", "sha256:7f30261641dac5ae60e0af2fb11ad7c87200575b0ce403f7531576b0f38a52be",
"sha256:8d6c5e01e4fb1ddd20cc24d4d8706643dd78b5279453dabcf10ba4e0683bf9b2", "sha256:82eb0dd8a95be97f03527b108ee49f9b86a7c534fa47fa7a510be3eab8ea9acd",
"sha256:908f92fbc307b642b65580d0295698f423b057018d87ae43d441f1d1a00674a2", "sha256:8321c315c4418dfe45a811b792380438ec81185deaaf202dbcd77711b4f51796",
"sha256:96b60c0f36ab7c11a826c116633b08afeac58b9b9c066a5ad7cd373ed996363c", "sha256:8f27725ec41d6722bd4f729a403a38238d844127f39cf462991e693b9c9c2608",
"sha256:99c19dcd9846d6bf9886eb758c6d8e06c5bfd8a0c83802551f1aa56e80fe13dc", "sha256:9a732b9f5603b153dcdc071d6b342f192981907519827f2c1e4aeac41e2b34c2",
"sha256:abf768c285d289c4a0e0678f055663162c9d6257c79f5904fbd2692ade94da1c", "sha256:9b94a8c647c1f80b4c82c7ac9642dda94a71abbb4efa29ad652c410ad7a39879",
"sha256:b4f7f9c737ca1af9a79962d3289eaa6521869915ee9fbb9168e578277126691b", "sha256:9bb353c764c144a9fc03302ef3763ad0c3e75bc7ca41f445cc98455f36da6c59",
"sha256:b5d0afc29fc461d599379f814f5ba378e58c69418588034619d76747bc2b5d5d", "sha256:9dd5104a1d8ccf725a3c1b40b21d5ed47530b6499abba30955c174d895c1be2f",
"sha256:b6690bfae043c81108e28e31dc7e3afa8cb1b1eb0ba590fdbff2b66e4c6913e7", "sha256:9fd3b792942e3b0937f9e39a1c56030cc41bbea058fb5a8af652ff6fd02a553e",
"sha256:bb393480b5845ef4acbc9f713e4c473ad08be81bc3cdb5ef017cd02bdafd36e1", "sha256:a692d2484ca8fa2c69f45c63f2b30cd00b8512834e3a522fc297d981dde5b34c",
"sha256:c2f42f24d7112ebb93d3aed581d011c82c20101da7d7a6248145c530e99bd2eb", "sha256:b1c7a61b0b644e9780aa9f6eee301fb77c813b1e41262f6f4f387de0558e8926",
"sha256:c55e6c42f9eb43d90c15792bf69bc09cb3e13a5e55a30eecb555695a02b0d4c0", "sha256:b299a1c10e45e1b71b34b048d555d3a8f366240942af89be5db379f88edb9542",
"sha256:c7055400f773856cd219680d15525c6fb147237534cbe5394a5f92f57f7efaed", "sha256:b3eda6b9416ef35b232403caa2bc6e25cc87530448d505a45d859210eab74051",
"sha256:c7b8ad51baf3505dd3d94ec974855359d15769e45c58c2b89c6d4612b1f7ed94", "sha256:bd06ab1d9cf90e8b198daae3b364e9ac23a640d210bb8faddad6627cf2e33be7",
"sha256:da9996407d03684221a4e1603b70fad97d8772185e5f75addcd97fcbc4c3281f", "sha256:bf9c87112ee78e21be984c5873c17615d7b3f7305ba01f472931dc77a70ffb26",
"sha256:dc685ab0b420597fe9a3187555c14d5b4d87c1518a99646397018457dc2a70c8", "sha256:c20fb5e8cf874dc182091d6df122b8b588501d23e7b500acf662c49899afdd0f",
"sha256:e42a2e9531c40315290278d9eee93ea5031eda3c83969f5709ada51a1c7491b4", "sha256:cc64f4e1fc07a155ef31eb9815183a6a8eb13dfd6876ab049c01125223aa0c40",
"sha256:e635c7ac056f2a47d79e2bf7042fb3fc6d175f47796a954388a3cec1f2e082a4", "sha256:cdec09935db4cbd74da5b577ea7d9959c73374060b22abf7d505429c0da7ed63",
"sha256:e8a7463d346015b42a0fb9c9179ebb7cccfd86bf40199c6796b546ac7be0b85c", "sha256:e87928d8f37952ba53d215836cb3d89c63c1367f037d055c3a5b3f4403f4c6b6",
"sha256:eb14a078429434b017f1fb26a75a8fcd62c12befb6b3d37ca39353dc2b5843fd", "sha256:edc1deb28fceab6b78e12bae506eaef62ee2fc1b3cabc96f7932f0d51e368acd",
"sha256:ef2bd306328a8447d4d2af6037efddd67ef16630876ed3ec84babd16ca558941", "sha256:eeac2c9acbd2d9f0ca493fdf692ce8245ca37cbebee08c496025868d4b8ef70f",
"sha256:f6af77f15a91bb6c16607067cfd201330cf5dfefe1c715e955be1fb06d368613", "sha256:f3ed97cdde1d96894057778095238bad3dd94d3e46dc07d6d618e6d4b7700cce",
"sha256:f96e10f0edff9d4b6b253e0a78f7c819749521d3ffe6af0139e08fcd35384405", "sha256:f87d74a804ca949f46a6babe70247a789f6401f229566e90174c4b7033b87cc5",
"sha256:fab40542a863cc142a763aa92630d613925fe3e937bea3ba5fb735e0c42d4f96", "sha256:fd487cc20730dc9d294e000126be86a02079485fa6cb723d8f01eddf86e96f5a",
"sha256:ff8c1a125b2a7a2933219e9e523aabca31c920839c3c10968b56ec9a76c81d1e" "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'", "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.0" "version": "==3.24.1"
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
@@ -364,66 +372,66 @@
}, },
"ast-serialize": { "ast-serialize": {
"hashes": [ "hashes": [
"sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5",
"sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8",
"sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd",
"sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605",
"sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a",
"sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6",
"sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5",
"sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568",
"sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6",
"sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4",
"sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47",
"sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c",
"sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456",
"sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025",
"sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769",
"sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43",
"sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489",
"sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede",
"sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a",
"sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4",
"sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593",
"sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd",
"sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad",
"sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8",
"sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011",
"sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d",
"sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a",
"sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82",
"sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8",
"sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96",
"sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117",
"sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd",
"sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611",
"sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98",
"sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b",
"sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51",
"sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef",
"sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331",
"sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8",
"sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3",
"sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226",
"sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e",
"sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6",
"sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49",
"sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7",
"sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c",
"sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe",
"sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5",
"sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a",
"sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6",
"sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9",
"sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8",
"sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7",
"sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372",
"sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037",
"sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa",
"sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96" "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.12.1" "version": "==0.13.1"
}, },
"astroid": { "astroid": {
"hashes": [ "hashes": [
@@ -443,11 +451,11 @@
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd",
"sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2026.7.22" "version": "==2026.10.23"
}, },
"charset-normalizer": { "charset-normalizer": {
"hashes": [ "hashes": [
@@ -638,11 +646,11 @@
}, },
"docutils": { "docutils": {
"hashes": [ "hashes": [
"sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea",
"sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de" "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==0.22.4" "version": "==0.23"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
@@ -854,14 +862,6 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==0.16.0" "version": "==0.16.0"
}, },
"macholib": {
"hashes": [
"sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea",
"sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"
],
"markers": "sys_platform == 'darwin'",
"version": "==1.16.4"
},
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
"sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98", "sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98",
@@ -1130,11 +1130,11 @@
}, },
"platformdirs": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0",
"sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7" "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.11'",
"version": "==4.12.4" "version": "==4.13.5"
}, },
"prettytable": { "prettytable": {
"hashes": [ "hashes": [
@@ -1196,25 +1196,25 @@
"sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9" "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9"
], ],
"index": "pypi", "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" "version": "==6.22.3"
}, },
"pyinstaller-hooks-contrib": { "pyinstaller-hooks-contrib": {
"hashes": [ "hashes": [
"sha256:4d825786ad7a9b7dbcc52d612748a34562fe273461bcfd88f4d549c594bbf8f9", "sha256:2748767901acb5cecd1304652b6c7add5bffcff1d71d01207fe24a5d42942a56",
"sha256:6d21d65323ea4467753afc21f52d7d0e69416ae8101dd88269bf273ad48db084" "sha256:fb3e67dd64122561724c48ad0366ac9e8c16e638b63bf7d85de03bf3fdbf06c3"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2026.8" "version": "==2026.9"
}, },
"pylint": { "pylint": {
"hashes": [ "hashes": [
"sha256:235f13dc418c0041c649b42a5c35c99f2ffc6ca8b6a7574958eac5335906a68a", "sha256:9928603068edfa0d1a3c167f174b099d4b97c3db75d32d0fcdd029770b4713a9",
"sha256:e34c8636edc7d60a2eaadca4ad6c18a49e202361987a26e81b2a17fa92be28e1" "sha256:a85357cae24f33ad8d86c8f3daaa92c600ae4012b54a57299cee76000e9364cf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_full_version >= '3.10.0'", "markers": "python_full_version >= '3.10.0'",
"version": "==4.1.2" "version": "==4.1.3"
}, },
"pywin32-ctypes": { "pywin32-ctypes": {
"hashes": [ "hashes": [
@@ -1244,11 +1244,11 @@
}, },
"setuptools": { "setuptools": {
"hashes": [ "hashes": [
"sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc",
"sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==84.0.0" "version": "==84.2.1"
}, },
"snowballstemmer": { "snowballstemmer": {
"hashes": [ "hashes": [
@@ -1260,21 +1260,21 @@
}, },
"sphinx": { "sphinx": {
"hashes": [ "hashes": [
"sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", "sha256:4c3ec13b612712c0d3ce4cbdd9e3eb91e68577e07a8e31f32f21cf178453578c",
"sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978" "sha256:90470459f3cab15d9dfed146c71cf77694b0445df17b2e0e69059cb9f7fc9ee4"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.12'", "markers": "python_version >= '3.13'",
"version": "==9.1.0" "version": "==9.2.0"
}, },
"sphinx-autodoc-typehints": { "sphinx-autodoc-typehints": {
"hashes": [ "hashes": [
"sha256:f5bac0996e76a2007fa26bc5393bb065ceb6cb4d062283fb5c69830c791c9716", "sha256:b0ad1f96503d69b63c2a1c043f12e2a727c9549e6503d4adbf2125dbf5ff50ed",
"sha256:f5db988060f68a231dbc230df97d81421e54a0860778a63216b9f71958a9afdd" "sha256:de41ed4001f4818bb6d37c22fd10ee44a93e6c8e945c6c7e3984e28a9df87d1c"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.12'", "markers": "python_version >= '3.12'",
"version": "==3.13.9" "version": "==3.17.2"
}, },
"sphinxcontrib-applehelp": { "sphinxcontrib-applehelp": {
"hashes": [ "hashes": [
@@ -1311,11 +1311,11 @@
}, },
"sphinxcontrib-jsmath": { "sphinxcontrib-jsmath": {
"hashes": [ "hashes": [
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:15ca92fe0eec82f0baf91a7efcc51ffacdb605924be6327683a52ebdacb6744c",
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" "sha256:551f780cf9687f9c3c74f0cf9f18aca06e0d6958853453f3803fe4d357c5b924"
], ],
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.9'",
"version": "==1.0.1" "version": "==2.0.0"
}, },
"sphinxcontrib-qthelp": { "sphinxcontrib-qthelp": {
"hashes": [ "hashes": [
@@ -1407,11 +1407,11 @@
}, },
"tomlkit": { "tomlkit": {
"hashes": [ "hashes": [
"sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437",
"sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97" "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==0.15.1" "version": "==0.15.2"
}, },
"types-requests": { "types-requests": {
"hashes": [ "hashes": [
@@ -1440,38 +1440,38 @@
}, },
"wcwidth": { "wcwidth": {
"hashes": [ "hashes": [
"sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2", "sha256:093469e01d881bff3db93ff3f56c364dc87c441ea01f9f41c22de0c0bdf5c6ef",
"sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b", "sha256:12786620375dc83fabf4b2b2aa52416fef2bc6094522ea29c8a53c75b0dfe9b6",
"sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2", "sha256:14410eb73178cc492aa9bf22f7c12e3bf930e5470b7220cf6f530efbfa2e03f3",
"sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270", "sha256:17619066f95c10f44d7ae08ee456602456bc66cc9d5e91966ec73d799acebc5f",
"sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec", "sha256:29c0685f5f5f8815b88308fdcbe2cd12a92996a21a5b1e18352d165174ef7a6d",
"sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec", "sha256:2b58fc8cf2de1bf9cdb68b8beaa5be198742414a7c8cd97aa51ec039783ffa49",
"sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9", "sha256:2cca7b5d0b9d0ae49068d3ce806e965326a987f555b62eaa322223396e10d01b",
"sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724", "sha256:4831662c396dd4c59a016ec53c0ac96738006d653a00482777d9a2e2ea323d62",
"sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8", "sha256:79e09e9824c88f5e275ca99e921685567a5c5c1bb6165c16e27ddd1adab33a95",
"sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c", "sha256:87e4101789415a6cf55ec4c53eceeb4782c2654c88692941cb588f19dca1959f",
"sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892", "sha256:8f892713ab5795d3abae181e9c02db0eb24de4084f9ee080535369d3d98d7bb5",
"sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724", "sha256:901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d",
"sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04", "sha256:9c4b0dd04cca520792100f6110d10be262c94b285a281d1759c551f159214633",
"sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14", "sha256:9e5090d253f5df99659cddfd3ed90dd21f6ff51989b0421331a63fb2681cbe27",
"sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389", "sha256:a1526cd25c6420e5153bb7d764c7509e692215ee3ad9b725db1ad665d47eae09",
"sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07", "sha256:a8c9ce14c6b4a499e61de6de7e9e456de11be8bc991554e2ab2ed4feb40b9664",
"sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e", "sha256:ab1e5190fb82476e75fbf963e9d59b4fb1c1e1f4ced82069a0c989016aa959f7",
"sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed", "sha256:b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2",
"sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76", "sha256:b52f265a694dda6b3ad9050c8bd89158ad519109ef7c5eb110b906cc49f25763",
"sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79", "sha256:b7969c9aa258d536df54c8ac8af5cad0c74a6268c9972e1fb60a5f29feffb66d",
"sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17", "sha256:befbf46467100e43fd8c8cc0c031a0105f13f5418c3be95fdcf0c47214487967",
"sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b", "sha256:c36954849aafae1092d6f9de9548c5280c1d6841637a27ca288b2241c802c4ab",
"sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa", "sha256:d68f09093c9bec9226a52edc47487042ed4f64c5df91d06f00419495071c0279",
"sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e", "sha256:d76b7c715c5ca0103b28915f699f1020ae21f06f1d22cdd85a7a97f7b1536eed",
"sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7", "sha256:d8905f1390d828d745fdd1904374c90386d902d3cfa9ee88e9875099f75a4e17",
"sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d", "sha256:e05458491759aa42b7b85f36345b3e6df082d6d2d9bf616299668fd598e0209d",
"sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7", "sha256:f279f32c8e206a9092fa350e53ca0c2bd8072e45b1c9fd31bb6d476b59b591c4",
"sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4", "sha256:f28d77153303450ee13ebbe888297e75cf532c754555b6cabafc9bdda16c421c",
"sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f" "sha256:fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954"
], ],
"markers": "python_version >= '3.9'", "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) os.makedirs('dist/licenses', exist_ok=True)
# install dependencies, invoke PyInstaller and collect licenses # 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", f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec", "pipenv run pyinstaller --log-level=DEBUG instaloader.spec",
f"pipenv run pip-licenses --packages {bundled_packages} --with-license-file " 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.""" """Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.15.5' __version__ = '4.16'
try: 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 .instaloadercontext import default_user_agent
from .lateststamps import LatestStamps from .lateststamps import LatestStamps
try: try:
import browser_cookie3 import browser_cookie3 # type: ignore[import-untyped]
bc3_library = True bc3_library = True
except ImportError: except ImportError:
bc3_library = False 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: def _retry_on_connection_error(func: Callable) -> Callable:
"""Decorator to retry the function max_connection_attempts number of times. """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 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 :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 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) @wraps(func)
def call(instaloader, *args, **kwargs): def call(instaloader, *args, **kwargs) -> Any:
try: # The failure we are currently retrying, if any. It tells us whether a ^C means "skip this
return func(instaloader, *args, **kwargs) # download" rather than "abort everything", and whether to sleep before the next attempt.
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err: pending_error_string: Optional[str] = None
error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err) for attempt in range(1, instaloader.context.max_connection_attempts + 1):
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)
try: try:
if kwargs.get('_attempt'): if pending_error_string is not None:
kwargs['_attempt'] += 1 instaloader.context.do_sleep()
else: return func(instaloader, *args, **kwargs)
kwargs['_attempt'] = 2 except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
instaloader.context.do_sleep() error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err)
return call(instaloader, *args, **kwargs) 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: except KeyboardInterrupt:
if pending_error_string is None:
raise
instaloader.context.error("[skipped by user]", repeat_at_end=False) 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 return call
@@ -335,7 +340,7 @@ class Instaloader:
@_retry_on_connection_error @_retry_on_connection_error
def download_pic(self, filename: str, url: str, mtime: datetime, def download_pic(self, filename: str, url: str, mtime: datetime,
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool: filename_suffix: Optional[str] = None) -> bool:
"""Downloads and saves picture with given url under given directory with given timestamp. """Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated.""" Returns true, if file was actually downloaded, i.e. updated."""
if filename_suffix is not None: if filename_suffix is not None:
@@ -519,8 +524,9 @@ class Instaloader:
'{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension)) '{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension))
@_retry_on_connection_error @_retry_on_connection_error
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile], def download_title_pic(
_attempt: int = 1) -> None: 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 """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. 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 .. versionchanged:: 4.3
Also downloads and saves the Highlight's cover pictures. 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 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 fast_update: If true, abort when first already-downloaded picture is encountered
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern :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 :param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
:raises LoginRequiredException: If called without being logged in. :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 name = user_highlight.owner_username
highlight_target: Union[str, Path] = (filename_target highlight_target: Union[str, Path] = (filename_target
if filename_target if filename_target
else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) / else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) /
_PostPathFormatter.sanitize_path(user_highlight.title, _PostPathFormatter.sanitize_path(user_highlight.title,
self.sanitize_paths))) 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) self.download_highlight_cover(user_highlight, highlight_target)
totalcount = user_highlight.itemcount totalcount = user_highlight.itemcount
count = 1 count = 1
+292 -173
View File
@@ -9,6 +9,7 @@ import textwrap
import time import time
import urllib.parse import urllib.parse
import uuid import uuid
from base64 import b64encode
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import partial 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.user_agent = user_agent if user_agent is not None else default_user_agent()
self.request_timeout = request_timeout self.request_timeout = request_timeout
self._session = self.get_anonymous_session() self._session = self.get_anonymous_session()
self.username = None self.username: Optional[str] = None
self.user_id = None
self.sleep = sleep self.sleep = sleep
self.quiet = quiet self.quiet = quiet
self.max_connection_attempts = max_connection_attempts self.max_connection_attempts = max_connection_attempts
@@ -125,15 +125,16 @@ class InstaloaderContext:
# Cache profile from id (mapping from id to Profile) # Cache profile from id (mapping from id to Profile)
self.profile_id_cache: Dict[int, Any] = dict() 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 @contextmanager
def anonymous_copy(self): def anonymous_copy(self):
session = self._session session = self._session
username = self.username username = self.username
user_id = self.user_id
iphone_headers = self.iphone_headers iphone_headers = self.iphone_headers
self._session = self.get_anonymous_session() self._session = self.get_anonymous_session()
self.username = None self.username = None
self.user_id = None
self.iphone_headers = default_iphone_headers() self.iphone_headers = default_iphone_headers()
try: try:
yield self yield self
@@ -141,9 +142,22 @@ class InstaloaderContext:
self._session.close() self._session.close()
self.username = username self.username = username
self._session = session self._session = session
self.user_id = user_id
self.iphone_headers = iphone_headers 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 @property
def is_logged_in(self) -> bool: def is_logged_in(self) -> bool:
"""True, if this Instaloader instance is logged in.""" """True, if this Instaloader instance is logged in."""
@@ -351,7 +365,6 @@ class InstaloaderContext:
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']}) session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session self._session = session
self.username = user self.username = user
self.user_id = resp_json['userId']
def two_factor_login(self, two_factor_code): def two_factor_login(self, two_factor_code):
"""Second step of login if 2FA is enabled. """Second step of login if 2FA is enabled.
@@ -401,8 +414,102 @@ class InstaloaderContext:
f" when accessing {resp.url}" 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', 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, response_headers: Optional[Dict[str, Any]] = None,
use_post: bool = False) -> Dict[str, Any]: use_post: bool = False) -> Dict[str, Any]:
"""JSON request to Instagram. """JSON request to Instagram.
@@ -422,96 +529,112 @@ class InstaloaderContext:
.. versionchanged:: 4.13 .. versionchanged:: 4.13
Added `use_post` parameter. 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 sess = session or self._session
is_doc_id_query = 'doc_id' in params and 'graphql/query' in path rate_limit_key = self._rate_limit_key(path, params, host)
is_iphone_query = host == 'i.instagram.com' url = 'https://{0}/{1}'.format(host, path)
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 # The failure we are currently retrying, if any. It drives the 429 backoff performed before the
try: # next attempt, and tells us whether a ^C means "skip this query" rather than "abort everything".
self.do_sleep() pending_error: Optional[BaseException] = None
if is_graphql_query: pending_error_string = ""
self._rate_controller.wait_before_query(params['query_hash'])
if is_doc_id_query: for attempt in range(1, self.max_connection_attempts + 1):
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)
try: try:
if isinstance(err, TooManyRequestsException): if isinstance(pending_error, TooManyRequestsException):
if is_graphql_query: self._rate_controller.handle_429(rate_limit_key)
self._rate_controller.handle_429(params['query_hash']) self.do_sleep()
if is_doc_id_query: self._rate_controller.wait_before_query(rate_limit_key)
self._rate_controller.handle_429(params['doc_id'])
if is_iphone_query: if use_post:
self._rate_controller.handle_429('iphone') resp = sess.post(url, data=params, allow_redirects=False)
if is_other_query: else:
self._rate_controller.handle_429('other') resp = sess.get(url, params=params, allow_redirects=False)
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1,
response_headers=response_headers) 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: except KeyboardInterrupt:
if pending_error is None:
raise
self.error("[skipped by user]", repeat_at_end=False) 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], def graphql_query(self, query_hash: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]: referer: Optional[str] = None) -> Dict[str, Any]:
@@ -526,18 +649,8 @@ class InstaloaderContext:
.. versionchanged:: 4.13.1 .. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter. Removed the `rhx_gis` parameter.
""" """
with copy_session(self._session, self.request_timeout) as tmpsession: variables_json = json.dumps(variables, separators=(',', ':'))
tmpsession.headers.update(self._default_http_header(empty_session_only=True)) with self._graphql_session(referer) as tmpsession:
del tmpsession.headers['Connection']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
variables_json = json.dumps(variables, separators=(',', ':'))
resp_json = self.get_json('graphql/query', resp_json = self.get_json('graphql/query',
params={'query_hash': query_hash, params={'query_hash': query_hash,
'variables': variables_json}, 'variables': variables_json},
@@ -547,7 +660,7 @@ class InstaloaderContext:
return resp_json return resp_json
def doc_id_graphql_query(self, doc_id: str, variables: Dict[str, Any], 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. 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 doc_id: doc_id for the query.
:param variables: Variables for the Query. :param variables: Variables for the Query.
:param referer: HTTP Referer, or None. :param referer: HTTP Referer, or None.
:param 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. :return: The server's response dictionary.
.. versionchanged:: 4.16
Added `web_api` parameter.
""" """
csrf = next((c.value for c in self._session.cookies variables_json = json.dumps(variables, separators=(',', ':'))
if c.name == 'csrftoken' and c.value), None) params = {'variables': variables_json,
if not csrf: 'doc_id': doc_id,
self._session.get('https://www.instagram.com/', timeout=self.request_timeout) 'server_timestamps': 'true',
csrf = next((c.value for c in self._session.cookies **(self._web_api_tokens() if web_api else {})}
if c.name == 'csrftoken' and c.value), '') 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,
with copy_session(self._session, self.request_timeout) as tmpsession: session=tmpsession, use_post=True)
tmpsession.headers.update(self._default_http_header(empty_session_only=True)) # api/graphql does not respond with a "status" field.
del tmpsession.headers['Connection'] if not web_api and 'status' not in resp_json:
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.") self.error("GraphQL response did not contain a \"status\" field.")
return resp_json return resp_json
@@ -637,12 +738,33 @@ class InstaloaderContext:
.. versionadded:: 4.2.1""" .. versionadded:: 4.2.1"""
with copy_session(self._session, self.request_timeout) as tempsession: with copy_session(self._session, self.request_timeout) as tempsession:
# Set headers to simulate an API request from iPad # 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()) tempsession.headers['x-pigeon-rawclienttime'] = '{:.6f}'.format(time.time())
# Add headers obtained from previous iPad request # Add headers obtained from previous iPad request
tempsession.headers.update(self.iphone_headers) 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 # Extract key information from cookies if we haven't got it already from a previous request
header_cookies_mapping = {'x-mid': 'mid', header_cookies_mapping = {'x-mid': 'mid',
'ig-u-ds-user-id': 'ds_user_id', 'ig-u-ds-user-id': 'ds_user_id',
@@ -688,6 +810,25 @@ class InstaloaderContext:
return response 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]]: 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. """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 QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When the request failed. :raises ConnectionException: When the request failed.
""" """
self.do_sleep() with self.get_anonymous_session() as anonymous_session:
self._rate_controller.wait_before_query('other') html = self._get_page(anonymous_session, path)
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]] = [] 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): with suppress(json.decoder.JSONDecodeError):
data.extend(_embedded_query_data(json.loads(script.group(1)))) data.extend(_embedded_query_data(json.loads(script.group(1))))
return data return data
@@ -732,27 +860,26 @@ class InstaloaderContext:
file.write(resp) file.write(resp)
os.replace(filename + '.temp', filename) 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. """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 QueryReturnedNotFoundException: When the server responds with a 404.
:raises QueryReturnedForbiddenException: When the server responds with a 403. :raises QueryReturnedForbiddenException: When the server responds with a 403.
:raises ConnectionException: When download failed. :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: with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, stream=True) resp = anonymous_session.get(url, stream=True)
if resp.status_code == 200: self._raise_for_download_status(resp)
resp.raw.decode_content = True resp.raw.decode_content = True
return resp 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))
def get_and_write_raw(self, url: str, filename: str) -> None: def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file. """Downloads and writes anonymously-requested raw data into a file.
@@ -773,16 +900,8 @@ class InstaloaderContext:
""" """
with self.get_anonymous_session() as anonymous_session: with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.head(url, allow_redirects=allow_redirects) resp = anonymous_session.head(url, allow_redirects=allow_redirects)
if resp.status_code == 200: self._raise_for_download_status(resp)
return resp 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: class RateController:
+9 -4
View File
@@ -69,6 +69,9 @@ class NodeIterator(Iterator[T]):
.. versionchanged: 4.13 .. versionchanged: 4.13
Included support for `doc_id`-based queries (using POST method). 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 _graphql_page_length = 12
@@ -83,10 +86,12 @@ class NodeIterator(Iterator[T]):
query_referer: Optional[str] = None, query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None, first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T, Optional[T]], bool]] = None, is_first: Optional[Callable[[T, Optional[T]], bool]] = None,
doc_id: Optional[str] = None): doc_id: Optional[str] = None,
web_api: bool = False):
self._context = context self._context = context
self._query_hash = query_hash self._query_hash = query_hash
self._doc_id = doc_id self._doc_id = doc_id
self._web_api = web_api
self._edge_extractor = edge_extractor self._edge_extractor = edge_extractor
self._node_wrapper = node_wrapper self._node_wrapper = node_wrapper
self._query_variables = query_variables if query_variables is not None else {} self._query_variables = query_variables if query_variables is not None else {}
@@ -110,15 +115,15 @@ class NodeIterator(Iterator[T]):
return self._query_query_hash(self._query_hash, after) return self._query_query_hash(self._query_hash, after)
def _query_doc_id(self, doc_id: str, after: Optional[str] = None) -> Dict: 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: if after is not None:
pagination_variables['after'] = after pagination_variables['after'] = after
pagination_variables['before'] = None pagination_variables['before'] = None
pagination_variables['first'] = 12 pagination_variables['first'] = NodeIterator._graphql_page_length
pagination_variables['last'] = None pagination_variables['last'] = None
data = self._edge_extractor( data = self._edge_extractor(
self._context.doc_id_graphql_query( 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 self._best_before = datetime.now() + NodeIterator._shelf_life
+68 -30
View File
@@ -279,7 +279,7 @@ class Post:
return None return None
@staticmethod @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.""" """Normalize a Polaris media item to a legacy-compatible node, preserving the source structure."""
media_types = {1: "GraphImage", 2: "GraphVideo", 8: "GraphSidecar"} media_types = {1: "GraphImage", 2: "GraphVideo", 8: "GraphSidecar"}
media_type = media.get("media_type") media_type = media.get("media_type")
@@ -306,10 +306,6 @@ class Post:
pic_json["video_duration"] = media.get("video_duration") pic_json["video_duration"] = media.get("video_duration")
pic_json["video_view_count"] = media.get("view_count") pic_json["video_view_count"] = media.get("view_count")
pic_json["video_play_count"] = media.get("play_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 = media.get("caption")
caption_text = caption.get("text") if isinstance(caption, dict) else None caption_text = caption.get("text") if isinstance(caption, dict) else None
pic_json["edge_media_to_caption"] = ( pic_json["edge_media_to_caption"] = (
@@ -419,12 +415,14 @@ class Post:
"shortcode": self.shortcode, "shortcode": self.shortcode,
"__relay_internal__pv__PolarisAIGMMediaWebLabelEnabledrelayprovider": False, "__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 {} web_info = (resp.get("data") or {}).get("xdt_api__v1__media__shortcode__web_info") or {}
items = web_info.get("items") items = web_info.get("items")
if not items: if not items:
raise BadResponseException("Fetching Post metadata failed.") raise BadResponseException("Fetching Post metadata failed.")
self._full_metadata_dict = Post._normalize_post_data(items[0], self._context) self._full_metadata_dict = Post._normalize_post_data(items[0])
if self.shortcode != self._full_metadata_dict['shortcode']: if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict) self._node.update(self._full_metadata_dict)
raise PostChangedException raise PostChangedException
@@ -697,7 +695,11 @@ class Post:
.. versionadded:: 4.14.3""" .. versionadded:: 4.14.3"""
if self.is_video: 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 return None
@property @property
@@ -1025,16 +1027,24 @@ class Profile:
""" """
if profile_id in context.profile_id_cache: if profile_id in context.profile_id_cache:
return context.profile_id_cache[profile_id] return context.profile_id_cache[profile_id]
# 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: try:
user = context.get_json( user = context.doc_id_graphql_query(
"api/v1/users/{0}/info/".format(profile_id), params={} '27937681195819736',
).get('user') {
"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: except QueryReturnedNotFoundException:
user = None user = None
if user and user.get('username'): if user and user.get('username'):
profile = cls.from_username(context, user['username']) profile = cls(context, cls._normalize_profile_data(user))
else: else:
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " + raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
str(profile_id) + ").") str(profile_id) + ").")
@@ -1137,6 +1147,9 @@ class Profile:
normalized['id'] = normalized['pk'] normalized['id'] = normalized['pk']
if 'edge_owner_to_timeline_media' not in normalized and 'media_count' in normalized: if 'edge_owner_to_timeline_media' not in normalized and 'media_count' in normalized:
normalized['edge_owner_to_timeline_media'] = {'count': normalized['media_count']} 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: if 'edge_felix_video_timeline' not in normalized:
normalized['edge_felix_video_timeline'] = {'count': 0} normalized['edge_felix_video_timeline'] = {'count': 0}
if 'edge_followed_by' not in normalized and 'follower_count' in normalized: if 'edge_followed_by' not in normalized and 'follower_count' in normalized:
@@ -1222,7 +1235,11 @@ class Profile:
return self._metadata('followed_by_viewer') return self._metadata('followed_by_viewer')
@property @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') return self._metadata('edge_owner_to_timeline_media', 'count')
@property @property
@@ -1374,35 +1391,53 @@ class Profile:
def get_posts(self) -> NodeIterator[Post]: def get_posts(self) -> NodeIterator[Post]:
"""Retrieve all posts from a profile. """Retrieve all posts from a profile.
:rtype: NodeIterator[Post]""" :rtype: NodeIterator[Post]
self._obtain_metadata() :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: 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( return NodeIterator(
context=self._context, context=self._context,
edge_extractor=lambda d: d["data"]["user"]["edge_owner_to_timeline_media"], edge_extractor=lambda d: d["data"]["node"]["polaris_ordered_timeline_connection"],
node_wrapper=lambda n: Post(self._context, n, self), # 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={ query_variables={
"data": { "id": user["id"],
"count": 12, "__relay_internal__pv__PolarisSEOIsCrawlerrelayprovider": False,
"include_relationship_info": True,
"latest_besties_reel_media": True,
"latest_reel_media": True,
},
"id": self.userid,
}, },
query_referer="https://www.instagram.com/{0}/".format(self.username), query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(), is_first=Profile._make_is_newest_checker(),
doc_id="7950326061742207", doc_id="28904005159232689",
query_hash=None, 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( return NodeIterator(
context=self._context, context=self._context,
edge_extractor=lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"], 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), 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 # The query and variables that the web app sends for a profile's posts tab
# (PolarisProfilePostsTabContentQuery_connection). Instagram answers the # (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={ query_variables={
"data": { "data": {
"count": 12, "count": 12,
@@ -1415,13 +1450,16 @@ class Profile:
"include_multi_captions": True, "include_multi_captions": True,
"username": self.username, "username": self.username,
"__relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider": True, "__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, "__relay_internal__pv__PolarisReelsRecoDebugOverlayEnabledrelayprovider": False,
}, },
query_referer="https://www.instagram.com/{0}/".format(self.username), query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(), is_first=Profile._make_is_newest_checker(),
doc_id="28975909992013618", doc_id="29104886249145907",
query_hash=None, query_hash=None,
web_api=True,
) )
def get_saved_posts(self) -> NodeIterator[Post]: def get_saved_posts(self) -> NodeIterator[Post]: